From b4fbff032b392e9fe68da41a394a6aee6054e8cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 27 Jun 2026 13:32:09 +0300 Subject: [PATCH 001/417] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=BA=D0=B0=20=C2=AB=D0=9A=D0=B0=D0=BA=20=D1=83?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=D0=B8=D1=87=D0=B8=D1=82=D1=8C=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=BE=20=D1=81=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=BE=D0=BA=C2=BB=20=D0=B2=20=D0=B4=D0=B8=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Над «Откуда собирать заявки» добавлена строка-подсказка с tooltip: лимит распределяется по поставщикам равномерно; даже если не выбирается полностью — просто увеличить лимит, и сделок придёт больше. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../js/views/projects/NewProjectDialog.vue | 20 +++++++++++++++++++ app/tests/Frontend/NewProjectDialog.spec.ts | 12 +++++++++++ 2 files changed, 32 insertions(+) diff --git a/app/resources/js/views/projects/NewProjectDialog.vue b/app/resources/js/views/projects/NewProjectDialog.vue index 7964735c..6b134fff 100644 --- a/app/resources/js/views/projects/NewProjectDialog.vue +++ b/app/resources/js/views/projects/NewProjectDialog.vue @@ -83,6 +83,26 @@ 📣 Лидерра поставит проект в сбор сразу после создания. Первые лиды пойдут с {{ leadStart }}. +
+ Как увеличить количество сделок + + + +
+
Откуда собирать заявки { expect(text).toContain('СМС'); }); + it('shows hint «Как увеличить количество сделок» with explanatory tooltip', async () => { + const wrapper = factory(); + await flushPromises(); + expect(wrapper.text()).toContain('Как увеличить количество сделок'); + const tips = wrapper.findAllComponents({ name: 'VTooltip' }); + const boost = tips.find((t) => + String(t.props('text')).includes('распределяется на нескольких поставщиков'), + ); + expect(boost).toBeTruthy(); + expect(String(boost?.props('text'))).toContain('увеличьте лимит'); + }); + it('switching to SMS tab shows sms_senders field', async () => { const wrapper = factory(); await flushPromises(); From be363695fdc6859d02f2b7ece19e0ddcc3a07ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 11:09:07 +0300 Subject: [PATCH 002/417] =?UTF-8?q?fix(=D0=B1=D0=B8=D0=BB=D0=BB=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3):=20sweep=20=D0=B8=20reminder=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B1=D0=B8=D1=80=D0=B0=D1=8E=D1=82=20=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=D0=BD=D1=82=D0=BE=D0=B2=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20BYPASSRLS=20+=20playwright=20=D0=B2=20=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D1=8F=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корень: после переезда на Managed PG очередь ходит под ролью crm_app_user (RLS), и Tenant::query() в BalancePreflightSweepJob/BalanceFrozenReminderJob отдавал 0 строк без app.current_tenant_id — биллинг-преflight молча стал no-op с 26.06 (ни заморозок, ни снятия проектных блоков). Перечень тенантов теперь берётся через pgsql_supplier (BYPASSRLS), модель грузится внутри per-tenant SET LOCAL контекста. Логика проверена на боевых данных: t25/t26 снимутся, t27/t30 заморозятся. Playwright рантайма supplier-портала объявлен в dependencies ровно 1.59.0 под chromium-1217 + package-lock синхронизирован; деплой ставит его npm ci --omit=dev, durable к чистке node_modules. Тесты Billing 18/18, pint/phpstan чисто. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Jobs/Billing/BalanceFrozenReminderJob.php | 76 +++++++++++-------- .../Jobs/Billing/BalancePreflightSweepJob.php | 33 +++++--- app/package-lock.json | 47 +++++++++++- app/package.json | 3 +- docs/observer/STATUS.md | 8 +- 5 files changed, 122 insertions(+), 45 deletions(-) diff --git a/app/app/Jobs/Billing/BalanceFrozenReminderJob.php b/app/app/Jobs/Billing/BalanceFrozenReminderJob.php index 991a2253..e56442ad 100644 --- a/app/app/Jobs/Billing/BalanceFrozenReminderJob.php +++ b/app/app/Jobs/Billing/BalanceFrozenReminderJob.php @@ -51,49 +51,65 @@ final class BalanceFrozenReminderJob implements ShouldQueue // Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому». $tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow')); - Tenant::query() + // Переезд на Managed PG (26.06.2026): очередь под ролью crm_app_user (RLS). + // Список замороженных тенантов брать через дефолтное соединение нельзя — без + // app.current_tenant_id policy tenants_self_isolation отдаёт 0 строк (тот же + // баг, что у BalancePreflightSweepJob). Берём id через pgsql_supplier (BYPASSRLS). + $tenantIds = DB::connection('pgsql_supplier')->table('tenants') ->whereNotNull('frozen_by_balance_at') ->whereNull('deleted_at') - ->chunkById(200, function (Collection $tenants) use ($service, $tiers): void { - foreach ($tenants as $tenant) { - /** @var Tenant $tenant */ - $this->processTenant($tenant, $service, $tiers); - } - }); + ->orderBy('id') + ->pluck('id'); + + foreach ($tenantIds as $tenantId) { + $this->processTenant((int) $tenantId, $service, $tiers); + } } /** * @param Collection $tiers */ - private function processTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void + private function processTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void { - // diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK). - $hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false)); + // SET LOCAL внутри транзакции восстанавливает tenant-контекст: и Tenant::find, + // и requiredLeadsForTomorrow() (читает projects) RLS-зависимы. mark()/alreadySent() + // идут через pgsql_supplier (BYPASSRLS) — им контекст не нужен. + DB::transaction(function () use ($tenantId, $service, $tiers): void { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); - $window = $this->matchWindow($hours); - if ($window === null) { - return; // вне окон reminder/final - } + $tenant = Tenant::find($tenantId); + if ($tenant === null || $tenant->frozen_by_balance_at === null) { + return; // разморожен/удалён между pluck и обработкой. + } - $marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent'; - if ($this->alreadySent($tenant->id, $marker)) { - return; - } + // diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK). + $hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false)); - // Re-evaluate для актуального дефицита в тексте письма. - $result = $service->evaluate( - balanceRub: (string) $tenant->balance_rub, - deliveredInMonth: (int) $tenant->delivered_in_month, - requiredLeads: $tenant->requiredLeadsForTomorrow(), - tiers: $tiers, - ); + $window = $this->matchWindow($hours); + if ($window === null) { + return; // вне окон reminder/final + } - $mail = $window === 'reminder' - ? new BalanceFrozenReminderMail($tenant, $result) - : new BalanceFrozenFinalMail($tenant, $result); + $marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent'; + if ($this->alreadySent($tenant->id, $marker)) { + return; + } - Mail::queue($mail); - $this->mark($tenant, $marker, $result); + // Re-evaluate для актуального дефицита в тексте письма. + $result = $service->evaluate( + balanceRub: (string) $tenant->balance_rub, + deliveredInMonth: (int) $tenant->delivered_in_month, + requiredLeads: $tenant->requiredLeadsForTomorrow(), + tiers: $tiers, + ); + + $mail = $window === 'reminder' + ? new BalanceFrozenReminderMail($tenant, $result) + : new BalanceFrozenFinalMail($tenant, $result); + + Mail::queue($mail); + $this->mark($tenant, $marker, $result); + }); } private function matchWindow(int $hours): ?string diff --git a/app/app/Jobs/Billing/BalancePreflightSweepJob.php b/app/app/Jobs/Billing/BalancePreflightSweepJob.php index 61f213f1..8824b8fd 100644 --- a/app/app/Jobs/Billing/BalancePreflightSweepJob.php +++ b/app/app/Jobs/Billing/BalancePreflightSweepJob.php @@ -41,25 +41,40 @@ final class BalancePreflightSweepJob implements ShouldQueue // Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому». $tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow')); - Tenant::query()->whereNull('deleted_at')->chunkById(200, function (Collection $tenants) use ($service, $tiers): void { - foreach ($tenants as $tenant) { - /** @var Tenant $tenant */ - $this->evaluateTenant($tenant, $service, $tiers); - } - }); + // Переезд на Managed PG (26.06.2026): очередь ходит в БД под ролью crm_app_user + // (RLS). Перечень тенантов брать через ДЕФОЛТНОЕ соединение нельзя — без + // app.current_tenant_id RLS-policy tenants_self_isolation отдаёт 0 строк, и + // sweep молча превращался в no-op (ни заморозок, ни снятия блоков). Берём id + // через pgsql_supplier (BYPASSRLS — системный контекст), как джоба уже делает + // для balance_freeze_log. Дальше per-tenant SET LOCAL восстанавливает контекст. + $tenantIds = DB::connection('pgsql_supplier')->table('tenants') + ->whereNull('deleted_at') + ->orderBy('id') + ->pluck('id'); + + foreach ($tenantIds as $tenantId) { + $this->evaluateTenant((int) $tenantId, $service, $tiers); + } } /** * @param Collection $tiers */ - private function evaluateTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void + private function evaluateTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void { // Spec C deploy hotfix (25.05.2026): CLI-команды и фоновые джобы не проходят // через SetTenantContext middleware → app.current_tenant_id не выставлен → // RLS-policy на projects падает с "unrecognized configuration parameter". // Зеркалим mechanic SetTenantContext: SET LOCAL внутри транзакции (PgBouncer-safe). - DB::transaction(function () use ($tenant, $service, $tiers): void { - DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id); + DB::transaction(function () use ($tenantId, $service, $tiers): void { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + // Модель грузим ВНУТРИ контекста — под RLS-ролью без SET LOCAL Tenant::find + // вернёт null (id-isolation policy). После SET LOCAL запись своей компании видна. + $tenant = Tenant::find($tenantId); + if ($tenant === null) { + return; // удалён между pluck и обработкой — пропускаем. + } $required = $tenant->requiredLeadsForTomorrow(); $result = $service->evaluate( diff --git a/app/package-lock.json b/app/package-lock.json index 14478d2d..ac1b9f01 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -5,7 +5,8 @@ "packages": { "": { "dependencies": { - "lucide-vue-next": "^1.0.0" + "lucide-vue-next": "^1.0.0", + "playwright": "1.59.0" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -7787,6 +7788,50 @@ "@vue/devtools-kit": "^7.7.9" } }, + "node_modules/playwright": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0.tgz", + "integrity": "sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0.tgz", + "integrity": "sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", diff --git a/app/package.json b/app/package.json index 6c011aa5..892d87d2 100644 --- a/app/package.json +++ b/app/package.json @@ -50,6 +50,7 @@ "vuetify": "^3.12.5" }, "dependencies": { - "lucide-vue-next": "^1.0.0" + "lucide-vue-next": "^1.0.0", + "playwright": "1.59.0" } } diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index af80f8b3..09a75ac7 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-06-28T07:15:07.331Z +Last updated: 2026-06-28T07:23:41.603Z | Контролёр | Состояние | Детали | |---|---|---| @@ -125,9 +125,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3440 | MsMpEng | 17.47ч | 0.0ч | -| 21928 | Code | 7.73ч | 0.0ч | -| 1212 | svchost | 4.46ч | NaNч | +| 3440 | MsMpEng | 17.50ч | 0.0ч | +| 21928 | Code | 7.75ч | NaNч | +| 1212 | svchost | 4.46ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. From fc58c96fe4f8ca80c4950b1aac60d226cd07851e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 11:19:02 +0300 Subject: [PATCH 003/417] =?UTF-8?q?docs(=D0=9F=D0=98=D0=9B=D0=9E=D0=A2):?= =?UTF-8?q?=20=D1=81=D0=BD=D0=B8=D0=BC=D0=BE=D0=BA=2028.06=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=87=D0=B8=D0=BD=D0=B5=D0=BD=20=D1=82=D0=B8=D1=85?= =?UTF-8?q?=D0=BE=20=D1=81=D0=BB=D0=BE=D0=BC=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=B1=D0=B8=D0=BB=D0=BB=D0=B8=D0=BD=D0=B3-=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=B6=20(RLS)=20+=20playwright=20durable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Свод за заход «закрывай хвосты»: разбор и фикс preflight-sweep/reminder (no-op с 26.06 из-за RLS-роли очереди), self-heal 4 проектов на проде, деньги t2 целы, playwright в deps. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/observer/STATUS.md | 8 ++++---- ПИЛОТ.md | 8 +++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 09a75ac7..5e99b459 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-06-28T07:23:41.603Z +Last updated: 2026-06-28T08:09:57.221Z | Контролёр | Состояние | Детали | |---|---|---| @@ -125,9 +125,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3440 | MsMpEng | 17.50ч | 0.0ч | -| 21928 | Code | 7.75ч | NaNч | -| 1212 | svchost | 4.46ч | 0.0ч | +| 3440 | MsMpEng | 17.61ч | NaNч | +| 21928 | Code | 7.87ч | 0.0ч | +| 1212 | svchost | 4.49ч | NaNч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. diff --git a/ПИЛОТ.md b/ПИЛОТ.md index adc8c1aa..ea6a0714 100644 --- a/ПИЛОТ.md +++ b/ПИЛОТ.md @@ -8,7 +8,13 @@ - Волатильную часть (доступ, версии, что развёрнуто) перед рискованными действиями **перепроверять реальной командой по SSH**, не доверять снимку вслепую. - Обновляется по команде заказчика **«обнови пилот»**. -> 🟢 **АКТУАЛЬНО (26.06.2026) — перебивает любые упоминания «ждут ООО / Б-1 / осталось подписать» в снимках ниже:** юр. лицо — **ИП, зарегистрировано** (НЕ ООО). **Договор с ЮKassa ПОДПИСАН 26.06** (№НЭК.448000.01), магазин **1392092** активен, приём платежей **включён со стороны ЮKassa**, СБП подключён, `info@liderra.ru` подтверждён. На проде заведены юрлицо+шлюз+webhook (см. снимок go-live ниже). **Остался последний шаг — включить флаг `billing_yookassa_enabled`** (+ живой тест) для перевода оплаты в боевой режим. «Регистрация ООО» как блокер — **снято, не актуально.** +> 🟢 **АКТУАЛЬНО (26.06.2026) — перебивает любые упоминания «ждут ООО / Б-1 / осталось подписать» в снимках ниже:** юр. лицо — **ИП, зарегистрировано** (НЕ ООО). **Договор с ЮKassa ПОДПИСАН 26.06** (№НЭК.448000.01), магазин **1392092** активен, приём платежей **включён со стороны ЮKassa**, СБП подключён, `info@liderra.ru` подтверждён. На проде заведены юрлицо+шлюз+webhook (см. снимок go-live ниже). **Флаг `billing_yookassa_enabled` — ВКЛЮЧЁН (подтверждено 27.06 на кластере, намеренно, штатное состояние по решению владельца).** НО **go-live онлайн-оплаты НЕ завершён:** успешной живой оплаты ещё не было (см. свежий снимок 27.06 ниже — 5 тестовых попыток 100₽ отменены на стороне ЮKassa, `paid=false`, деньги не списаны; happy-path «оплата→webhook→зачисление» в бою не проверялся; webhook IP-allowlist пуст). «Регистрация ООО» как блокер — **снято, не актуально.** + +**Снимок снят:** 28.06.2026 (~11:20 UTC) — **🔴→🟢 НАЙДЕН И ПОЧИНЕН ТИХО СЛОМАННЫЙ БИЛЛИНГ-СТОРОЖ + playwright durable + дашборд балансов/вложенности на проде.** По команде владельца «закрывай хвосты». **Главное — баг биллинга:** после переезда на Managed PG (26.06) очередь `liderra-queue` ходит в БД под ролью `crm_app_user` (RLS), и `BalancePreflightSweepJob`/`BalanceFrozenReminderJob` перебирали тенантов через `Tenant::query()` БЕЗ `app.current_tenant_id` → policy `tenants_self_isolation` отдавала **0 строк** → ночной преflight @18:00 МСК **молча стал no-op с 26.06** (heartbeat зелёный, `balance_freeze_log` пуст, ни заморозок бедных, ни снятия блоков у профинансированных). Симптом — 4 застрявших `preflight_blocked` проекта. **Фикс:** список тенантов теперь через `pgsql_supplier` (BYPASSRLS), модель грузится внутри per-tenant `SET LOCAL`. Тесты Billing 18/18, pint/phpstan чисто. **gitea/main + прод = `75dded78`** (cherry-pick поверх `cab0347f`; выкат `bin/deploy-source-edit.sh`, БД миграциями не тронута). **Прогнал sweep вручную на проде — самоисцеление подтверждено:** t25 (10090₽)→сняты блоки 2 проектов (omega/lkomega), t26 (300₽)→снят блок 1, t27 (тест example.org)→заморожен, t30 (`kaa_555@bk.ru`, 20₽ реальный)→заморожен + письмо «пополните»; `balance_freeze_log` снова пишет. **Деньги целы: t2 = 1 839 405 ₽** (до==после). **Playwright «несносимый»:** объявлен в `app/package.json → dependencies` ровно `1.59.0` (под browser chromium-1217) + lock; деплой получил шаг 6d `npm ci --omit=dev --legacy-peer-deps` (только прод-деп, без тяжёлого фронт-дерева, без скачивания браузера) → переживёт будущие чистки `node_modules` (корень падения синхрона 27.06). Поставлен на прод (3 пакета). **Дашборд (выкачено ранее 28.06):** плитка «Балансы сервисов» + «Пополнить», плитка «Клиенты», экран «Лиды» (серверная пагинация) + карточка лида (цепочка до источника), выбор периода. **🟡 Следующим заходом (решение владельца):** экран «Тенанты» на серверную пагинацию/фильтры для 1000 клиентов. **🔴 YC в минусе −606₽:** пополнить `console.yandex.cloud/billing/accounts/dn2w7fcvynjxe6elljct/payments`. Кодовая фраза стены — «роутер-наставник». + +**Снимок снят:** 27.06.2026 (~10:40 UTC) — **🟢 ВЫКАЧЕНО на боевой: (1) снят лишний лимит по ЧИСЛУ проектов, (2) подсказка «Как увеличить количество сделок» в диалоге проекта.** По команде владельца (онлайн-наблюдение за работающим клиентом + правки его затыков). **gitea/main + прод = `fa736136`** (продвинут моими выкатами `c7e015a9`→`7ac9af7c`→`fa736136`; ссылка на `c7e015a9` в снимке ~13:00 ниже — стейл/concurrent). **Что сделано:** (1) убран гейт `max_projects` в `ProjectService::create` + поле `active_projects.limit` с дашборда — **правило продукта: лимита по числу проектов НЕТ, только по балансу/заказанным лидам**; у всех тенантов `tenants.limits={}` → код брал дефолт `?? 10` и ложно блокировал tenant 2 (117 проектов) с «Достигнут лимит проектов (10)»; `max_users`/`api_rps` — мёртвые ключи (только в комментариях), колонка `limits` оставлена. (2) подсказка с tooltip в `NewProjectDialog.vue` над «Откуда собирать заявки». **Метод выката:** `bin/deploy-source-edit.sh` — **ПОЧИНЕН под кластер** (убраны DB-шаги через `sudo -u postgres` = старая rollback-база на VM; миграции теперь `php artisan migrate --force` в кластер по `.env`, для код-онли = «Nothing to migrate»); скрипт gitignored (внутри пароль gitea). **Verify:** HTTP **200** (главная+login), подсказка в живой сборке `ProjectsView-CSdkdAXR.js`, гейт лимита в боевом коде = 0 совпадений, **деньги целы tenant 2 = 1 839 600 ₽ / 999 731 лид**, миграций нет (БД не тронута). **Бэкап кода на проде** `/tmp/app-backup-2026-06-27-*.tgz`. **🔴 ОТКРЫТЫЙ БАГ (наблюдение по логам 27.06):** сессия клиента **рвётся через ~8–10 мин активной работы** (НЕ таймаут: `SESSION_LIFETIME=120` мин, Redis здоров/noeviction) + интерфейс молчит вместо «войдите заново» (сыпет 401 в фон) → клиент видит пустую страницу. Также путаница паролей у владельца (вводил пароль от `kdv1@bk.ru` на `info@lkomega.ru`; вход — `info@lkomega.ru`/`Liderra2026!`). Разбор: `docs/superpowers/findings/2026-06-27-live-client-observation.md` + `…/2026-06-27-remove-project-count-limit.md`. **Параллельная сессия** работала локально на ветке `feat/admin-command-center` (её коммит `1a92b702` в main НЕ влит — изолировал свои правки worktree+cherry-pick). Кодовая фраза стены — «роутер-наставник». + +**Снимок снят:** 27.06.2026 (~13:00 UTC) — **🔎 АУДИТ «пропавших правок» (байт-в-байт git↔прод) + РАЗБОР статуса онлайн-оплаты ЮKassa. Прод НЕ менялся — только чтение.** По запросу владельца «на проде пропала куча правок — найди всё, сверяй байт-в-байт». **Сквозная байт-в-байт сверка 1125 файлов `app/`+`db/` (git-blob-хеши ↔ `git hash-object` на проде):** код приложения `app/` — **100% идентичен git** (`c7e015a9`, он же gitea/main), ни одной пропавшей/откатившейся правки; фронт пересобран 27.06 07:51 UTC (свежий). Единственный дрейф — справочные `db/*.sql` на диске сервера заморожены на 25.06 (заголовок schema.sql v8.53), git и живая база — v8.57; **реальной потери нет** (эти файлы — исходники на диске, портал их в рантайме не читает; изменения 26.06 применены к живому кластеру и лежат в git). **Живая база-кластер (read-only через `pgsql_supplier`):** флаги фич все ВКЛ (`routing_match_by_snapshot=true`, `supplier_export_mode=online` — ничего не сбросилось при переезде), баланс клиента tenant 2 = **999 731 лид / 1016 сделок (990 актив.)** == эталон 26.06. **💳 ОБНАРУЖЕНО и РАЗОБРАНО: `billing_yookassa_enabled=true` — онлайн-оплата ВКЛ (намеренно, штатно), НО go-live НЕ завершён.** В `saas_transactions` 5 попыток пополнения 100₽ (26–27.06), все `pending`; запрос статуса у самой ЮKassa (read-only GET `verifyPayment`): все 3 с `gateway_payment_id` = **`canceled`** (оплата не завершена → не оплачено); детально проверен P6 — `paid=false`, причина **`expired_on_confirmation`** (оплату не довели до конца, через СБП). **Денег НЕ списано, баланс клиента цел.** Webhook в логах nginx — **0 обращений** (успешных платежей не было → нечего слать). **Итог: успешной живой оплаты ещё НЕ было — happy-path «оплата→webhook→зачисление» в бою не проверялся ни разу** (код под Pest-тестами, но end-to-end вживую — нет). **Хвосты go-live:** (1) `YOOKASSA_WEBHOOK_IPS` пуст (fail-open); (2) система ловит только `payment.succeeded`, `payment.canceled` не обрабатывает → отменённые висят `pending` навечно; (3) тесты гоняли на боевом `tenant 2` вместо тестового `tenant 28` «Демо-доступ для ЮKassa». **Откат оплаты:** флаг `billing_yookassa_enabled=false` — мгновенно. Кодовая фраза стены — «роутер-наставник». **Снимок снят:** 26.06.2026 (~13:40 UTC) — **💳 ЮKassa ONLINE-ОПЛАТА — НАСТРОЕНА на проде (шаги 2–4а ранбука), ФЛАГ ещё ВЫКЛ. Прод-код НЕ менялся.** По команде владельца «занимайся». **Со стороны ЮKassa всё включено:** заявка одобрена, **договор подписан** по СМС (№НЭК.448000.01 от 26.06), магазин **ShopID 1392092** (юрлицо ИП Кондратьев П.Д., ИНН 245210851872, ОГРНИП 326246800098042), **приём платежей ВКЛЮЧЁН** (письмо ЮMoney «Приём платежей включен»), **СБП подключён**, `info@liderra.ru` подтверждён. Секретный API-ключ выпущен (`live_…P8RI`). **Код интеграции 100% готов и под тестами** (драйвер/`OnlineTopupService`/`PaymentWebhookController` server-to-server+идемпотентность/админ-ввод ключей/развилка флага в `BillingController::topup()`, 12+ Pest зелёные) — ранбук `docs/superpowers/runbooks/2026-06-24-yookassa-online-payment-enable.md`. **Сделано на проде (в КЛАСТЕРНОЙ managed-базе, через Eloquent-бутстрап под www-data — НЕ через peer-psql!):** `legal_entities` ИП **id=1** (`ip_kondratev`, `vat_mode='usn_6'` — подтвердить у владельца УСН 6/15; банк/адрес позже), `payment_gateways` **yookassa id=2 is_active=true** (config зашифрован `Crypt::encrypt`, shop_id 1392092 + secret, `credentials()` roundtrip ок), webhook в кабинете ЮKassa **payment.succeeded → `https://liderra.ru/api/webhook/payment`**. **🔴 ОСТАЛОСЬ (за владельцем):** (5) включить флаг `billing_yookassa_enabled=true` в **кластере** + `cache:clear` + restart php-fpm (рубильник: пополнение у всех клиентов станет реальной оплатой; откат мгновенный — флаг в ВЫКЛ); (6) живой тест 100 ₽ с тестового тенанта (НЕ client1/tenant2); (4б, hardening) IP-allowlist `YOOKASSA_WEBHOOK_IPS` (сейчас пусто = fail-open; защита есть — сверка суммы/статуса + идемпотентность). **⚠️ GOTCHA:** на прод-VM `sudo -u postgres psql` = СТАРАЯ локальная rollback-БД (после переезда на managed), НЕ живой кластер — данные приложения менять только через app/Eloquent (`/var/www/liderra/app/`) или psql к хосту кластера; команды psql из ранбука 24.06 по этому пункту устарели. **💰 Деньги клиента ЦЕЛЫ** (добавлены лишь справочные строки юрлица/шлюза): tenant 2 = **1 838 400.00 ₽ / 1016 сделок**. Снимки кабинета/прода: `yookassa-*-2026-06-26.png` в корне. Кодовая фраза стены — «роутер-наставник». From 3fd457aa6a4ba64541e9a22be30bdc3ca9321635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 12:05:31 +0300 Subject: [PATCH 004/417] =?UTF-8?q?feat(=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=BA=D0=B0):=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=A2=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=D0=BD=D1=82=D1=8B=20=D0=BD=D0=B0=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80=D0=BD=D1=83=D1=8E=20=D0=BF=D0=B0=D0=B3?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D1=86=D0=B8=D1=8E/=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA/=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20(=D0=BC?= =?UTF-8?q?=D0=B0=D1=81=D1=88=D1=82=D0=B0=D0=B1=201000+)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdminTenantsView грузил всех тенантов разом и фильтровал в браузере — на 1000 клиентов поиск/чипы видели только первую страницу. Теперь страница из limit/offset + v-pagination; поиск (ILIKE), статус (производный trial/overdue/active/suspended) и тариф — серверные multi-фильтры. AdminTenantsController::index: statuses/tariffs через CASE/whereIn (статус зеркалит adminTenantsMapper.deriveStatus). Опции тарифов — отдельным запросом listAdminTariffPlans. Демо локально подтверждено. Тесты: фронт 34/34 (tenants), бэкенд 13/13 (+2 на statuses/tariffs); baseline getJson 13→15. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Api/AdminTenantsController.php | 37 ++++- app/phpstan-baseline.neon | 2 +- app/resources/js/api/admin.ts | 4 + .../js/views/admin/AdminTenantsView.vue | 139 ++++++++++++------ app/tests/Feature/AdminTenantsIndexTest.php | 45 ++++++ app/tests/Frontend/AdminTenantsView.spec.ts | 90 +++++++----- .../Frontend/AdminTenantsViewApi.spec.ts | 1 + 7 files changed, 233 insertions(+), 85 deletions(-) diff --git a/app/app/Http/Controllers/Api/AdminTenantsController.php b/app/app/Http/Controllers/Api/AdminTenantsController.php index 689749b0..170cee29 100644 --- a/app/app/Http/Controllers/Api/AdminTenantsController.php +++ b/app/app/Http/Controllers/Api/AdminTenantsController.php @@ -30,10 +30,14 @@ class AdminTenantsController extends Controller { use ResolvesAdminUserId; - /** GET /api/admin/tenants?status=&search=&limit=&offset= */ + /** GET /api/admin/tenants?status=&statuses=&tariffs=&search=&limit=&offset= */ public function index(Request $request): JsonResponse { $status = (string) $request->query('status', ''); + // statuses — производные статусы UI (trial/overdue/active/suspended), csv, multi. + // tariffs — имена тарифов (tariff_plans.name), csv, multi. + $statuses = $this->csvParam($request, 'statuses'); + $tariffs = $this->csvParam($request, 'tariffs'); $search = trim((string) $request->query('search', '')); $limit = max(1, min(500, (int) $request->query('limit', '100'))); $offset = max(0, (int) $request->query('offset', '0')); @@ -59,8 +63,22 @@ class AdminTenantsController extends Controller ]) ->whereNull('tenants.deleted_at'); - if ($status !== '') { - $query->where('tenants.status', $status); + // Производный статус — зеркалит adminTenantsMapper.deriveStatus (фронт): + // trial > suspended > overdue > active. Серверная фильтрация нужна для масштаба + // (1000 клиентов): без неё чипы фильтровали бы только загруженную страницу. + if ($statuses !== []) { + $query->whereIn(DB::raw("(CASE + WHEN tenants.is_trial THEN 'trial' + WHEN tenants.status = 'suspended' THEN 'suspended' + WHEN tenants.chargeback_unrecovered_rub > 0 OR tenants.balance_rub < 0 THEN 'overdue' + WHEN tenants.status = 'active' THEN 'active' + ELSE 'suspended' + END)"), $statuses); + } elseif ($status !== '') { + $query->where('tenants.status', $status); // back-compat: фильтр по сырой колонке + } + if ($tariffs !== []) { + $query->whereIn('tariff_plans.name', $tariffs); } if ($search !== '') { $like = '%'.$search.'%'; @@ -451,6 +469,19 @@ class AdminTenantsController extends Controller ]; } + /** + * Разбирает csv-параметр запроса в список непустых trimmed-строк. + * + * @return list + */ + private function csvParam(Request $request, string $key): array + { + return array_values(array_filter(array_map( + 'trim', + explode(',', (string) $request->query($key, '')), + ))); + } + /** * Aggregate-stats для page-head: total / active / trial / overdue / revenue. * Считается отдельным запросом без фильтров (показывает глобальную картину diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 30af1dbb..5963bb5e 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -657,7 +657,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' identifier: method.notFound - count: 13 + count: 15 path: tests/Feature/AdminTenantsIndexTest.php - diff --git a/app/resources/js/api/admin.ts b/app/resources/js/api/admin.ts index 11c42a05..a0d2d340 100644 --- a/app/resources/js/api/admin.ts +++ b/app/resources/js/api/admin.ts @@ -124,6 +124,10 @@ interface AdminTenantsStats { export interface ListAdminTenantsParams { status?: string; + /** Производные статусы UI (trial/overdue/active/suspended), csv — серверный multi-фильтр. */ + statuses?: string; + /** Имена тарифов (tariff_plans.name), csv — серверный multi-фильтр. */ + tariffs?: string; search?: string; limit?: number; offset?: number; diff --git a/app/resources/js/views/admin/AdminTenantsView.vue b/app/resources/js/views/admin/AdminTenantsView.vue index 3f825419..d52b22e7 100644 --- a/app/resources/js/views/admin/AdminTenantsView.vue +++ b/app/resources/js/views/admin/AdminTenantsView.vue @@ -2,18 +2,21 @@ /** * Админка → Тенанты. Список всех тенантов SaaS с балансами/тарифами/MRR. * - * Sprint 4 Phase B/1 (audit O-refactor-04 хвост): UI-блоки выделены в - * components/admin/tenants/{TenantsStatsHeader,TenantsFilters,TenantsTable}. - * State (filterStatuses/filterTariffs/clearFilters/tenantsState/stats и др.) - * остаётся в этом view ради `defineExpose`-контракта, который Vitest тесты - * используют для прямого доступа. + * Масштаб (28.06.2026): серверная пагинация + серверные фильтры (search/статус/тариф). + * Раньше грузили всех разом и фильтровали в браузере — на 1000 клиентов это не + * «смотрибельно» (поиск/чипы видели только первую страницу). Теперь: + * - страница из `limit/offset` (perPage), счётчик `total` с сервера → v-pagination; + * - поиск (org/subdomain/email ILIKE) — серверный, debounce 400мс; + * - статус (производный trial/overdue/active/suspended) и тариф — серверные multi. + * Бэкенд: AdminTenantsController::index (statuses/tariffs/search/limit/offset/total). + * + * State (filterStatuses/filterTariffs/clearFilters/tenantsState/stats и др.) остаётся + * в этом view ради `defineExpose`-контракта Vitest-тестов. * * Источник дизайна: liderra_v8_handoff/concepts/v8_admin.html секция #page-tenants. - * По схеме v8.7 §3 (tenants table) + ТЗ §22 (админка). - * * Click по строке → /admin/tenants/{code} (карточка тенанта). */ -import { computed, onMounted, reactive, ref } from 'vue'; +import { onMounted, reactive, ref, watch } from 'vue'; import { useRouter } from 'vue-router'; import { type AdminTenant, type TenantStatus } from '../../composables/mockTenants'; import { mapApiAdminTenant } from '../../composables/adminTenantsMapper'; @@ -35,34 +38,93 @@ const stats = reactive({ total: 0, active: 0, trial: 0, overdue: 0, monthlyReven const loading = ref(false); const fetchError = ref(false); -async function loadTenants() { +const search = ref(''); +const filterStatuses = ref([]); +const filterTariffs = ref([]); +const availableTariffs = ref([]); + +// Серверная пагинация. +const page = ref(1); +const perPage = ref(25); +const total = ref(0); +const totalPages = () => Math.max(1, Math.ceil(total.value / perPage.value)); + +async function loadTenants(): Promise { loading.value = true; fetchError.value = false; try { - const res = await adminApi.listAdminTenants(); + const res = await adminApi.listAdminTenants({ + search: search.value.trim(), + statuses: filterStatuses.value.join(','), + tariffs: filterTariffs.value.join(','), + limit: perPage.value, + offset: (page.value - 1) * perPage.value, + }); const mapped = res.tenants.map((t) => mapApiAdminTenant(t)); tenantsState.splice(0, tenantsState.length, ...mapped); + total.value = res.total; stats.total = res.stats.total; stats.active = res.stats.active; stats.trial = res.stats.trial; stats.overdue = res.stats.overdue; } catch { fetchError.value = true; + tenantsState.splice(0, tenantsState.length); } finally { loading.value = false; } } -onMounted(loadTenants); +// Опции тарифов для дропдауна — отдельным запросом (на странице видна только часть +// тенантов, поэтому список тарифов нельзя выводить из загруженного набора). +async function loadTariffOptions(): Promise { + try { + const plans = await adminApi.listAdminTariffPlans(); + availableTariffs.value = Array.from(new Set(plans.map((p) => p.name))).sort(); + } catch { + // дропдаун останется пустым — не критично для основного списка. + } +} + +// Поиск — debounce 400мс (планшет: печатает → ищет, без кнопки «Найти»). +let searchTimer: ReturnType | null = null; +watch(search, () => { + if (searchTimer) clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + page.value = 1; + void loadTenants(); + }, 400); +}); + +// Фильтры — сразу перезагрузка с 1-й страницы. +watch( + [filterStatuses, filterTariffs], + () => { + page.value = 1; + void loadTenants(); + }, + { deep: true }, +); + +function goPage(p: number): void { + page.value = p; + void loadTenants(); +} + +onMounted(() => { + void loadTariffOptions(); + void loadTenants(); +}); usePolling(loadTenants); -function openTenantDetail(t: AdminTenant) { +function openTenantDetail(t: AdminTenant): void { router.push({ name: 'admin-tenant-detail', params: { code: t.code } }); } -const search = ref(''); -const filterStatuses = ref([]); -const filterTariffs = ref([]); +function clearFilters(): void { + filterStatuses.value = []; + filterTariffs.value = []; +} const impersonationOpen = ref(false); const impersonationTenant = ref(null); @@ -70,21 +132,14 @@ const impersonationTenant = ref(null); const balanceDialogOpen = ref(false); const balanceTarget = ref(null); -const availableTariffs = computed(() => Array.from(new Set(tenantsState.map((t) => t.tariff))).sort()); - -function clearFilters() { - filterStatuses.value = []; - filterTariffs.value = []; -} - const ADMIN_USER_ID = 1; -function openImpersonation(tenant: AdminTenant) { +function openImpersonation(tenant: AdminTenant): void { impersonationTenant.value = tenant; impersonationOpen.value = true; } -function openBalanceDialog(tenant: AdminTenant) { +function openBalanceDialog(tenant: AdminTenant): void { balanceTarget.value = tenant; balanceDialogOpen.value = true; } @@ -106,22 +161,12 @@ defineExpose({ loading, fetchError, loadTenants, -}); - -const filteredTenants = computed(() => { - const q = search.value.trim().toLowerCase(); - const statuses = new Set(filterStatuses.value); - const tariffs = new Set(filterTariffs.value); - - return tenantsState.filter((t) => { - if (statuses.size > 0 && !statuses.has(t.status)) return false; - if (tariffs.size > 0 && !tariffs.has(t.tariff)) return false; - if (q) { - const haystack = `${t.name} ${t.inn} ${t.code}`.toLowerCase(); - if (!haystack.includes(q)) return false; - } - return true; - }); + search, + page, + perPage, + total, + availableTariffs, + goPage, }); @@ -153,12 +198,24 @@ const filteredTenants = computed(() => { /> +
+ Всего: {{ total }} + +
+ json('tenants.0.mrr_rub'))->toBeNull(); }); +test('GET /api/admin/tenants фильтрует по statuses (производный статус, multi)', function () { + // active: не trial, status=active, balance>=0, chargeback=0 + Tenant::factory()->create(['organization_name' => 'AC', 'status' => 'active', 'is_trial' => false, 'balance_rub' => '100', 'chargeback_unrecovered_rub' => '0']); + // trial: is_trial=true (приоритет выше всех) + Tenant::factory()->create(['organization_name' => 'TR', 'status' => 'active', 'is_trial' => true, 'balance_rub' => '0']); + // overdue: не trial, balance<0 + Tenant::factory()->create(['organization_name' => 'OV', 'status' => 'active', 'is_trial' => false, 'balance_rub' => '-50', 'chargeback_unrecovered_rub' => '0']); + // suspended: status=suspended + Tenant::factory()->create(['organization_name' => 'SU', 'status' => 'suspended', 'is_trial' => false, 'balance_rub' => '100', 'chargeback_unrecovered_rub' => '0']); + + $r = $this->getJson('/api/admin/tenants?statuses=overdue,trial'); + + $names = collect($r->json('tenants'))->pluck('organization_name')->all(); + expect($r->json('total'))->toBe(2); + expect($names)->toContain('TR'); + expect($names)->toContain('OV'); + expect($names)->not->toContain('AC'); + expect($names)->not->toContain('SU'); +}); + +test('GET /api/admin/tenants фильтрует по tariffs (имя тарифа, multi)', function () { + $mk = fn (string $name): int => (int) DB::table('tariff_plans')->insertGetId([ + 'code' => 'tp_'.bin2hex(random_bytes(4)), + 'name' => $name, + 'billing_model' => 'monthly', + 'price_monthly' => 990.00, + 'is_active' => true, + 'is_public' => true, + 'sort_order' => 1, + 'created_at' => now(), + ]); + $proId = $mk('Pro'); + $teamId = $mk('Команда'); + + $p = Tenant::factory()->create(['organization_name' => 'P1']); + DB::table('tenants')->where('id', $p->id)->update(['current_tariff_id' => $proId]); + $k = Tenant::factory()->create(['organization_name' => 'K1']); + DB::table('tenants')->where('id', $k->id)->update(['current_tariff_id' => $teamId]); + + $r = $this->getJson('/api/admin/tenants?tariffs=Pro'); + + expect($r->json('total'))->toBe(1); + expect($r->json('tenants.0.organization_name'))->toBe('P1'); +}); + test('GET /api/admin/tenants поддерживает limit + offset', function () { foreach (range(1, 5) as $i) { Tenant::factory()->create([ diff --git a/app/tests/Frontend/AdminTenantsView.spec.ts b/app/tests/Frontend/AdminTenantsView.spec.ts index b5a802df..9ff20df0 100644 --- a/app/tests/Frontend/AdminTenantsView.spec.ts +++ b/app/tests/Frontend/AdminTenantsView.spec.ts @@ -5,21 +5,22 @@ import { createRouter, createMemoryHistory } from 'vue-router'; import AdminTenantsView from '../../resources/js/views/admin/AdminTenantsView.vue'; import { MOCK_STATS, MOCK_TENANTS, type AdminTenant } from '../../resources/js/composables/mockTenants'; -// Мокаем api/admin: listAdminTenants возвращает пустой ответ — -// smoke-тесты затем seed'ят tenantsState/stats напрямую через vm (defineExpose). -vi.mock('../../resources/js/api/admin', async (importOriginal) => { - const orig = await importOriginal(); - return { - ...orig, - listAdminTenants: vi.fn().mockResolvedValue({ - tenants: [], - total: 0, - limit: 100, - offset: 0, - stats: { total: 0, active: 0, trial: 0, overdue: 0 }, - }), - }; -}); +// listAdminTenants мокаем пустым ответом — smoke/render-тесты затем seed'ят +// tenantsState/stats напрямую через vm (defineExpose). Серверные фильтры/пагинация: +// фильтр-тесты проверяют, что view зовёт listAdminTenants с правильными параметрами. +vi.mock('../../resources/js/api/admin', () => ({ + listAdminTenants: vi.fn().mockResolvedValue({ + tenants: [], + total: 0, + limit: 25, + offset: 0, + stats: { total: 0, active: 0, trial: 0, overdue: 0 }, + }), + listAdminTariffPlans: vi.fn().mockResolvedValue([ + { id: 1, name: 'Команда', price_monthly: '990.00' }, + { id: 2, name: 'Pro', price_monthly: '2990.00' }, + ]), +})); beforeEach(() => { vi.clearAllMocks(); @@ -28,7 +29,6 @@ beforeEach(() => { describe('AdminTenantsView.vue', () => { /** Монтирует view, ждёт mount-цикл, затем seed'ит state фикстурами. */ const factory = async () => { - // useRouter() в AdminTenantsView требует router-context в тестах. const router = createRouter({ history: createMemoryHistory(), routes: [ @@ -41,18 +41,19 @@ describe('AdminTenantsView.vue', () => { const wrapper = mount(AdminTenantsView, { global: { plugins: [createVuetify(), router], - // ImpersonationDialog + TenantBalanceDialog stubим — внутри используют api/admin axios. stubs: { ImpersonationDialog: true, TenantBalanceDialog: true }, }, }); await flushPromises(); - // Seed state напрямую через defineExpose — имитирует успешную загрузку с теми же фикстурами. + // Seed state напрямую через defineExpose — имитирует успешную загрузку страницы. const vm = wrapper.vm as unknown as { tenantsState: AdminTenant[]; stats: typeof MOCK_STATS; + total: number; }; vm.tenantsState.splice(0, vm.tenantsState.length, ...MOCK_TENANTS.map((t) => ({ ...t }))); Object.assign(vm.stats, MOCK_STATS); + vm.total = MOCK_TENANTS.length; await wrapper.vm.$nextTick(); return wrapper; }; @@ -86,7 +87,7 @@ describe('AdminTenantsView.vue', () => { }); }); - it('рендерит все 7 mock-tenants', async () => { + it('рендерит все 7 mock-tenants текущей страницы', async () => { const wrapper = await factory(); const rows = wrapper.findAll('tbody tr'); expect(rows.length).toBe(MOCK_TENANTS.length); @@ -130,14 +131,14 @@ describe('AdminTenantsView.vue', () => { expect(input.attributes('placeholder')).toContain('ИНН'); }); - it('фильтр по search оставляет только matching-tenants', async () => { + it('поиск передаётся на сервер параметром search', async () => { const wrapper = await factory(); - const input = wrapper.find('input[type="text"]'); - await input.setValue('Натяжные'); - await wrapper.vm.$nextTick(); - const rows = wrapper.findAll('tbody tr'); - expect(rows.length).toBe(1); - expect(rows[0].text()).toContain('Натяжные потолки СПб'); + const api = await import('../../resources/js/api/admin'); + const vm = wrapper.vm as unknown as { search: string; loadTenants: () => Promise }; + vm.search = 'Натяжные'; + await vm.loadTenants(); + // .some, а не .at(-1): usePolling может вставить фоновый вызов между действием и проверкой. + expect(vi.mocked(api.listAdminTenants).mock.calls.some((c) => c[0]?.search === 'Натяжные')).toBe(true); }); it('содержит Экспорт-кнопку и фильтры Статус/Тариф', async () => { @@ -147,24 +148,38 @@ describe('AdminTenantsView.vue', () => { expect(wrapper.find('[data-testid="filter-tariffs"]').exists()).toBe(true); }); - it('фильтр по статусу «overdue» оставляет только просроченных', async () => { + it('фильтр по статусу «overdue» уходит на сервер параметром statuses + сброс на 1 страницу', async () => { const wrapper = await factory(); - const vm = wrapper.vm as unknown as { filterStatuses: string[] }; + const api = await import('../../resources/js/api/admin'); + const vm = wrapper.vm as unknown as { filterStatuses: string[]; page: number }; + vm.page = 3; vm.filterStatuses = ['overdue']; await wrapper.vm.$nextTick(); - const rows = wrapper.findAll('tbody tr'); - expect(rows.length).toBe(1); - expect(rows[0].text()).toContain('Двери Премиум'); + await flushPromises(); + expect( + vi.mocked(api.listAdminTenants).mock.calls.some((c) => c[0]?.statuses === 'overdue' && c[0]?.offset === 0), + ).toBe(true); + expect(vm.page).toBe(1); }); - it('фильтр по тарифу «Pro» оставляет 1 row', async () => { + it('фильтр по тарифу «Pro» уходит на сервер параметром tariffs', async () => { const wrapper = await factory(); + const api = await import('../../resources/js/api/admin'); const vm = wrapper.vm as unknown as { filterTariffs: string[] }; vm.filterTariffs = ['Pro']; await wrapper.vm.$nextTick(); - const rows = wrapper.findAll('tbody tr'); - expect(rows.length).toBe(1); - expect(rows[0].text()).toContain('Кухни на заказ Екб'); + await flushPromises(); + expect(vi.mocked(api.listAdminTenants).mock.calls.some((c) => c[0]?.tariffs === 'Pro')).toBe(true); + }); + + it('пагинация: goPage шлёт offset и грузит страницу', async () => { + const wrapper = await factory(); + const api = await import('../../resources/js/api/admin'); + const vm = wrapper.vm as unknown as { goPage: (p: number) => void; perPage: number }; + vm.goPage(3); + await flushPromises(); + // offset = (3-1) * perPage + expect(vi.mocked(api.listAdminTenants).mock.calls.some((c) => c[0]?.offset === 2 * vm.perPage)).toBe(true); }); it('clearFilters сбрасывает оба фильтра + кнопка «Сбросить» появляется только когда фильтры активны', async () => { @@ -187,7 +202,6 @@ describe('AdminTenantsView.vue', () => { it('каждая строка имеет impersonate-кнопку (mdi-account-switch) с уникальным data-testid', async () => { const wrapper = await factory(); - // Все 7 mock-tenants должны иметь кнопку MOCK_TENANTS.forEach((t) => { const btn = wrapper.find(`[data-testid="impersonate-btn-${t.id}"]`); expect(btn.exists()).toBe(true); @@ -198,23 +212,19 @@ describe('AdminTenantsView.vue', () => { const wrapper = await factory(); const suspendedBtn = wrapper.find('[data-testid="impersonate-btn-105"]'); expect(suspendedBtn.exists()).toBe(true); - // v-btn disabled-state — атрибут disabled на DOM-элементе expect(suspendedBtn.attributes('disabled')).toBeDefined(); }); it('click на impersonate-кнопке открывает ImpersonationDialog с правильным tenant', async () => { const wrapper = await factory(); - // До click — диалог закрыт (modelValue=false) const dialogStub = wrapper.findComponent({ name: 'ImpersonationDialog' }); expect(dialogStub.exists()).toBe(true); expect(dialogStub.props('modelValue')).toBe(false); expect(dialogStub.props('tenant')).toBeNull(); - // Click по кнопке для Окна Москва (id=42) await wrapper.find('[data-testid="impersonate-btn-42"]').trigger('click'); await wrapper.vm.$nextTick(); - // Диалог открывается с этим tenant expect(dialogStub.props('modelValue')).toBe(true); expect(dialogStub.props('tenant')).toMatchObject({ id: 42, name: 'Окна Москва ООО' }); expect(dialogStub.props('requestedBy')).toBe(1); diff --git a/app/tests/Frontend/AdminTenantsViewApi.spec.ts b/app/tests/Frontend/AdminTenantsViewApi.spec.ts index 4c178136..51a6aeea 100644 --- a/app/tests/Frontend/AdminTenantsViewApi.spec.ts +++ b/app/tests/Frontend/AdminTenantsViewApi.spec.ts @@ -12,6 +12,7 @@ vi.mock('../../resources/js/api/admin', async (importOriginal) => { return { ...orig, listAdminTenants: vi.fn(), + listAdminTariffPlans: vi.fn().mockResolvedValue([]), }; }); From c94de99c3276fa5625533aedebc031ad16114a3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 12:17:59 +0300 Subject: [PATCH 005/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B4=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D0=B9=D0=BD-=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=20+=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дизайн и пошаговый план фичи «Автоподбор конкурентов» (ИИ-агент находит конкурентов и их источники). Движок — отдельной сессией, здесь розетка+заглушка. План сверен с кодом: RLS app.current_tenant_id, tenant-контекст SET LOCAL, тестовая БД liderra_testing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-28-avtopodbor-konkurentov.md | 1432 +++++++++++++++++ ...026-06-28-autopodbor-konkurentov-design.md | 421 +++++ 2 files changed, 1853 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-avtopodbor-konkurentov.md create mode 100644 docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-design.md diff --git a/docs/superpowers/plans/2026-06-28-avtopodbor-konkurentov.md b/docs/superpowers/plans/2026-06-28-avtopodbor-konkurentov.md new file mode 100644 index 00000000..8cd06df2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-avtopodbor-konkurentov.md @@ -0,0 +1,1432 @@ +# Автоподбор конкурентов — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Проектный git-safety:** этот проект под стеной «роутер-наставник». Коммиты в субагентах — через `subagent-driven-development` (project-local wrapper, Pravila §15.1). Коммит — только после явного «эскейп» владельца. Тесты с записью через supplier-PDO ОБЯЗАНЫ `uses(SharesSupplierPdo::class)`. + +**Goal:** Собрать фичу «Автоподбор конкурентов» целиком (экраны, фоновые платные прогоны, сохранение, дедуп, создание проектов) на заглушке движка, оставив «розетку» для будущего реального ИИ-агента. + +**Architecture:** Vue-вкладка → `AutopodborController` → `AutopodborRunService` ставит фоновую джобу → джоба зовёт `CompetitorAgent` (интерфейс; v1 = `FakeCompetitorAgent`), сохраняет результат и при успехе списывает деньги (`AutopodborChargeService`, идемпотентно по run_id). Создание проектов переиспользует существующий `ProjectService`. Цена/вкл-выкл — через `system_settings`. + +**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16 (RLS), Redis-очереди, Pest 4, Vue 3 + Vuetify 3, Vitest. + +**Источник истины:** дизайн-документ `docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-design.md` + прототип `…-prototype.html`. + +--- + +## КОНВЕНЦИИ (сверены с кодом 2026-06-28 — соблюдать во ВСЕХ задачах) + +1. **RLS-GUC = `app.current_tenant_id`** (НЕ `app.tenant_id`). Политики: + `USING (tenant_id = current_setting('app.current_tenant_id')::bigint)`. +2. **Tenant-контекст в джобах/сервисах с записью под RLS:** оборачивать в + `DB::transaction(function(){ DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); ... })` + — как в `app/app/Jobs/RouteSupplierLeadJob.php` и middleware `SetTenantContext.php`. + **Нет** класса `TenantContext::set()` — не использовать. +3. **Тестовая БД:** отдельного соединения `pgsql_testing` НЕТ. Тесты сами идут на + `liderra_testing` через `phpunit.xml`. Применить новые миграции к тестовой БД: + `DB_DATABASE=liderra_testing php artisan migrate` (из `app/`). Боевую `liderra` НЕ трогать. +4. **Tenant/User в тестах:** `Tenant::factory()->create()` + `User::factory()->create(['tenant_id'=>$tenant->id])`. + HTTP — `$this->actingAs($user)->postJson(...)`. Прямые model-тесты — mirror + `tests/Feature/Billing/ProjectPreflightTest.php` (`set_config('app.current_tenant_id', ...)` в `beforeEach`). + Хелпера `setTenant()` НЕТ. +5. **Ledger-образец:** мирорить `app/app/Services/Billing/LedgerService.php` + тест + `tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php` (bcmath, `balance_rub`, `balance_rub_after`). +6. **RefreshDatabase глобально ВЫКЛЮЧЕН.** Файлам с записью добавлять `uses(RefreshDatabase::class)` + или mirror соседний тест. Cross-connection запись — `uses(\Tests\Concerns\SharesSupplierPdo::class)`. +7. **Bootstrap worktree уже сделан контроллером** (vendor/composer, `.env`, `storage/framework/*`, + `liderra_testing` смигрирована до main). Субагенту vendor ставить НЕ нужно; нужно лишь + применять СВОИ миграции к `liderra_testing` (п.3) перед своими тестами. + +--- + +## Карта файлов (что создаём / трогаем) + +**Backend — создаём:** +- `app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php` +- `app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php` +- `app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php` +- `app/app/Models/AutopodborRun.php`, `AutopodborCompetitor.php`, `AutopodborSource.php` +- `app/app/Services/Autopodbor/Agent/CompetitorAgent.php` (interface) +- `app/app/Services/Autopodbor/Agent/FakeCompetitorAgent.php` +- `app/app/Services/Autopodbor/Agent/Dto/*.php` (6 DTO) +- `app/app/Services/Autopodbor/AutopodborNormalizer.php` +- `app/app/Services/Autopodbor/AutopodborDedup.php` +- `app/app/Services/Autopodbor/AutopodborRunService.php` +- `app/app/Services/Autopodbor/AutopodborChargeService.php` +- `app/app/Services/Autopodbor/AutopodborProjectCreator.php` +- `app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php` +- `app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php` +- `app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php` +- `app/app/Http/Controllers/Api/AutopodborController.php` +- `app/app/Http/Resources/Autopodbor/*.php` (run/competitor/source resources) +- `app/app/Providers/AutopodborServiceProvider.php` (binding интерфейса) + +**Backend — модифицируем:** +- `app/app/Models/BalanceTransaction.php` (+ константа типа) +- `app/routes/api.php` (роуты autopodbor) +- `db/schema.sql` + `db/CHANGELOG_schema.md` (3 таблицы, новый тип проводки) +- `app/config/app.php` или `bootstrap/providers.php` (регистрация провайдера) + +**Frontend — создаём:** +- `app/resources/js/views/autopodbor/AutopodborView.vue` (контейнер + роутинг экранов) +- `app/resources/js/views/autopodbor/screens/*.vue` (Entry, AutoForm, ManualForm, ResolveConfirm, Loading, CompetitorList, SourceList, CreateProjects, Empty, Done) +- `app/resources/js/api/autopodbor.ts` +- `app/resources/js/stores/autopodborStore.ts` + +**Frontend — модифицируем:** +- роутер (`app/resources/js/router/*`) — маршрут вкладки +- левое меню (компонент сайдбара) — пункт «Автоподбор конкурентов» NEW + +--- + +# ФАЗА 1 — Данные + +### Task 1: Миграция `autopodbor_runs` + +**Files:** +- Create: `app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php` +- Test: `app/tests/Feature/Autopodbor/AutopodborRunsSchemaTest.php` + +- [ ] **Step 1: Написать падающий тест** + +```php +hasTable('autopodbor_runs'))->toBeTrue(); + expect(DB::getSchemaBuilder()->hasColumns('autopodbor_runs', [ + 'id', 'tenant_id', 'kind', 'status', 'region_code', 'params', + 'competitor_id', 'price_rub_charged', 'balance_transaction_id', + 'error_code', 'created_at', 'started_at', 'finished_at', + ]))->toBeTrue(); +}); +``` + +- [ ] **Step 2: Запустить — убедиться, что падает** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Autopodbor/AutopodborRunsSchemaTest.php` +Expected: FAIL — таблицы нет. + +- [ ] **Step 3: Написать миграцию** + +```php +bigIncrements('id'); + $table->unsignedBigInteger('tenant_id'); + $table->string('kind', 16); // search | study | resolve + $table->string('status', 16)->default('queued'); // queued|running|done|empty|failed + $table->smallInteger('region_code')->nullable(); + $table->jsonb('params')->default(DB::raw("'{}'::jsonb")); + $table->unsignedBigInteger('competitor_id')->nullable(); + $table->decimal('price_rub_charged', 12, 2)->nullable(); + $table->unsignedBigInteger('balance_transaction_id')->nullable(); + $table->string('error_code', 64)->nullable(); + $table->timestampTz('created_at')->useCurrent(); + $table->timestampTz('started_at')->nullable(); + $table->timestampTz('finished_at')->nullable(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'kind', 'status']); + }); + + DB::statement('ALTER TABLE autopodbor_runs ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE autopodbor_runs FORCE ROW LEVEL SECURITY'); + DB::statement("CREATE POLICY tenant_isolation ON autopodbor_runs USING (tenant_id = current_setting('app.current_tenant_id')::bigint)"); + } + + public function down(): void + { + Schema::dropIfExists('autopodbor_runs'); + } +}; +``` + +- [ ] **Step 4: Прогнать миграции тестовой БД и тест** + +Run: `cd app && DB_DATABASE=liderra_testing php artisan migrate && ./vendor/bin/pest tests/Feature/Autopodbor/AutopodborRunsSchemaTest.php` +Expected: PASS. + +- [ ] **Step 5: Записать в `db/CHANGELOG_schema.md`** новую таблицу `autopodbor_runs` (версия схемы +1, дата, RLS-политика). Обновить header-метрики `db/schema.sql` (число таблиц/политик — канон в header). + +- [ ] **Step 6: Commit** (после «эскейп») + +```bash +git add app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php app/tests/Feature/Autopodbor/AutopodborRunsSchemaTest.php db/CHANGELOG_schema.md +git commit -m "feat(автоподбор): таблица autopodbor_runs + RLS" +``` + +### Task 2: Миграция `autopodbor_competitors` + +**Files:** +- Create: `app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php` +- Test: дополнить `AutopodborRunsSchemaTest.php` (или новый `…CompetitorsSchemaTest.php`) + +- [ ] **Step 1: Падающий тест** + +```php +it('создаёт autopodbor_competitors', function () { + expect(DB::getSchemaBuilder()->hasColumns('autopodbor_competitors', [ + 'id','tenant_id','search_run_id','name','description','is_federal', + 'relevance_pct','origin','site_url','directory_urls','provenance', + 'dedup_key','study_run_id','studied_at','created_at', + ]))->toBeTrue(); +}); +``` + +- [ ] **Step 2: Запустить — FAIL.** + +- [ ] **Step 3: Миграция** + +```php +bigIncrements('id'); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('search_run_id')->nullable(); + $table->string('name', 255); + $table->text('description')->nullable(); + $table->boolean('is_federal')->default(false); + $table->smallInteger('relevance_pct')->nullable(); + $table->string('origin', 16)->default('auto'); // auto|manual|resolve + $table->string('site_url', 255)->nullable(); + $table->jsonb('directory_urls')->default(DB::raw("'[]'::jsonb")); + $table->jsonb('provenance')->default(DB::raw("'{}'::jsonb")); + $table->string('dedup_key', 255); + $table->unsignedBigInteger('study_run_id')->nullable(); + $table->timestampTz('studied_at')->nullable(); + $table->timestampTz('created_at')->useCurrent(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('search_run_id')->references('id')->on('autopodbor_runs')->nullOnDelete(); + $table->foreign('study_run_id')->references('id')->on('autopodbor_runs')->nullOnDelete(); + $table->index(['tenant_id', 'search_run_id']); + // дедуп конкурентов в рамках подбора (search_run_id NULL → пер-tenant для «своих») + $table->unique(['tenant_id', 'search_run_id', 'dedup_key'], 'autopodbor_competitor_dedup'); + }); + + DB::statement('ALTER TABLE autopodbor_competitors ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE autopodbor_competitors FORCE ROW LEVEL SECURITY'); + DB::statement("CREATE POLICY tenant_isolation ON autopodbor_competitors USING (tenant_id = current_setting('app.current_tenant_id')::bigint)"); + } + + public function down(): void + { + Schema::dropIfExists('autopodbor_competitors'); + } +}; +``` + +> NB: UNIQUE с NULL в `search_run_id` под Postgres по умолчанию NOT DISTINCT не сработает для нескольких NULL. Для «своих конкурентов» (search_run_id NULL) дедуп обеспечиваем в коде (`AutopodborDedup`, Task 9) — индекс ловит только авто-подбор. Это осознанно. + +- [ ] **Step 4: Миграция + тест → PASS.** + +- [ ] **Step 5: CHANGELOG_schema.md + header schema.sql.** + +- [ ] **Step 6: Commit** «feat(автоподбор): таблица autopodbor_competitors + RLS». + +### Task 3: Миграция `autopodbor_sources` + +**Files:** +- Create: `app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php` +- Test: `…SourcesSchemaTest.php` + +- [ ] **Step 1: Падающий тест** на колонки `id,tenant_id,competitor_id,study_run_id,signal_type,identifier,phone_kind,provenance_url,provenance_label,dedup_key,created_project_id,created_at`. + +- [ ] **Step 2: FAIL.** + +- [ ] **Step 3: Миграция** + +```php +bigIncrements('id'); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('competitor_id'); + $table->unsignedBigInteger('study_run_id'); + $table->string('signal_type', 8); // site | call + $table->string('identifier', 255); // голова домена / 7xxxxxxxxxx + $table->string('phone_kind', 12)->nullable(); // real | substitute | null(site) + $table->string('provenance_url', 500)->nullable(); + $table->string('provenance_label', 255)->nullable(); + $table->string('dedup_key', 255); + $table->unsignedBigInteger('created_project_id')->nullable(); + $table->timestampTz('created_at')->useCurrent(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('competitor_id')->references('id')->on('autopodbor_competitors')->cascadeOnDelete(); + $table->foreign('study_run_id')->references('id')->on('autopodbor_runs')->cascadeOnDelete(); + $table->foreign('created_project_id')->references('id')->on('projects')->nullOnDelete(); + $table->unique(['competitor_id', 'dedup_key'], 'autopodbor_source_dedup'); + $table->index(['tenant_id', 'competitor_id']); + }); + + DB::statement('ALTER TABLE autopodbor_sources ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE autopodbor_sources FORCE ROW LEVEL SECURITY'); + DB::statement("CREATE POLICY tenant_isolation ON autopodbor_sources USING (tenant_id = current_setting('app.current_tenant_id')::bigint)"); + } + + public function down(): void + { + Schema::dropIfExists('autopodbor_sources'); + } +}; +``` + +- [ ] **Step 4: Миграция + тест → PASS.** +- [ ] **Step 5: CHANGELOG_schema.md + header.** +- [ ] **Step 6: Commit** + прогнать `rls-reviewer` по трём таблицам. + +### Task 4: Eloquent-модели + +**Files:** +- Create: `app/app/Models/AutopodborRun.php`, `AutopodborCompetitor.php`, `AutopodborSource.php` +- Test: `app/tests/Feature/Autopodbor/AutopodborModelsTest.php` + +- [ ] **Step 1: Падающий тест связей** + +```php +create(); + TenantContext::set($tenant->id); + + $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'done','region_code'=>16,'params'=>[]]); + $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'name'=>'Окна Комфорт','dedup_key'=>'okna-komfort','relevance_pct'=>100]); + $src = AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>$run->id,'signal_type'=>'site','identifier'=>'okna-komfort.ru','dedup_key'=>'site:okna-komfort.ru']); + + expect($comp->sources()->count())->toBe(1) + ->and($comp->searchRun->id)->toBe($run->id) + ->and($src->competitor->id)->toBe($comp->id); +})->uses(\Tests\Concerns\SharesSupplierPdo::class); +``` + +> Проверить точное имя помощника установки tenant_id (поиск по коду: `current_setting('app.tenant_id')` / `set_config`). Если в проекте это `TenantContext::set` отсутствует — использовать существующий способ из тестов проектов. + +- [ ] **Step 2: FAIL.** + +- [ ] **Step 3: Модели** + +```php +// AutopodborRun.php +'array','price_rub_charged'=>'decimal:2','started_at'=>'datetime','finished_at'=>'datetime','created_at'=>'datetime']; + public function competitors(){ return $this->hasMany(AutopodborCompetitor::class,'search_run_id'); } +} +``` + +```php +// AutopodborCompetitor.php +'bool','directory_urls'=>'array','provenance'=>'array','studied_at'=>'datetime','created_at'=>'datetime']; + public function sources(){ return $this->hasMany(AutopodborSource::class,'competitor_id'); } + public function searchRun(){ return $this->belongsTo(AutopodborRun::class,'search_run_id'); } + public function studyRun(){ return $this->belongsTo(AutopodborRun::class,'study_run_id'); } +} +``` + +```php +// AutopodborSource.php +'datetime']; + public function competitor(){ return $this->belongsTo(AutopodborCompetitor::class,'competitor_id'); } + public function project(){ return $this->belongsTo(Project::class,'created_project_id'); } +} +``` + +- [ ] **Step 4: Тест → PASS.** +- [ ] **Step 5: Commit** «feat(автоподбор): Eloquent-модели run/competitor/source». + +### Task 5: Тип проводки + ключи настроек + +**Files:** +- Modify: `app/app/Models/BalanceTransaction.php` +- Test: `app/tests/Unit/Autopodbor/BalanceTransactionTypeTest.php` + +- [ ] **Step 1: Падающий тест** + +```php +it('имеет тип autopodbor_charge', function () { + expect(\App\Models\BalanceTransaction::TYPE_AUTOPODBOR_CHARGE)->toBe('autopodbor_charge'); +}); +``` + +- [ ] **Step 2: FAIL.** + +- [ ] **Step 3:** добавить в `BalanceTransaction.php` рядом с остальными константами: + +```php +public const TYPE_AUTOPODBOR_CHARGE = 'autopodbor_charge'; +``` + +Если в модели есть валидирующий список типов (массив `TYPES`/правило `in:`) — дописать туда же. + +- [ ] **Step 4: Тест → PASS.** + +- [ ] **Step 5: Сидер ключей `system_settings`** — миграция-вставка (или сидер по образцу существующих флагов): + - `autopodbor_enabled` (type=bool, default `0`, description «Автоподбор конкурентов: вкл/выкл вкладку») + - `autopodbor_price_search_rub` (type=decimal, value пусто/`0`, description «Цена подбора конкурентов, ₽») + - `autopodbor_price_study_rub` (type=decimal, description «Цена изучения конкурента, ₽») + - `autopodbor_max_competitors` (type=int, default `15`) + +- [ ] **Step 6: Commit** «feat(автоподбор): тип проводки + ключи настроек». + +--- + +# ФАЗА 2 — Розетка движка + +### Task 6: DTO контракта агента + +**Files:** +- Create: `app/app/Services/Autopodbor/Agent/Dto/FindCompetitorsRequest.php`, `FindCompetitorsResult.php`, `StudyCompetitorRequest.php`, `StudyCompetitorResult.php`, `ResolveByNameRequest.php`, `ResolveByNameResult.php` +- Test: `app/tests/Unit/Autopodbor/AgentDtoTest.php` + +- [ ] **Step 1: Падающий тест** + +```php +use App\Services\Autopodbor\Agent\Dto\{FindCompetitorsRequest, FindCompetitorsResult}; + +it('конструирует FindCompetitorsRequest/Result', function () { + $req = new FindCompetitorsRequest(regionCode:16, examples:['okna.ru'], aboutSelf:['description'=>'окна'], includeFederal:true, maxCompetitors:15); + expect($req->regionCode)->toBe(16)->and($req->examples)->toContain('okna.ru'); + + $res = new FindCompetitorsResult(competitors:[[ + 'name'=>'Окна Комфорт','description'=>'окна','is_federal'=>false,'relevance_pct'=>100, + 'site_url'=>'okna-komfort.ru','directory_urls'=>['2gis'],'provenance'=>['src'=>'2gis'], + ]]); + expect($res->competitors[0]['name'])->toBe('Окна Комфорт'); +}); +``` + +- [ ] **Step 2: FAIL.** + +- [ ] **Step 3:** реализовать 6 DTO как readonly-классы. Пример двух (остальные по аналогии — поля из §7.2 дизайна): + +```php +// FindCompetitorsRequest.php +[], 'directories'=>[], 'description'=>''] + public readonly bool $includeFederal, + public readonly int $maxCompetitors, + ) {} +} +``` + +```php +// FindCompetitorsResult.php + $competitors */ + public function __construct(public readonly array $competitors) {} +} +``` + +Аналогично: `StudyCompetitorRequest(competitor:array, regionCode:int)`, +`StudyCompetitorResult(sources:array)` где source = `{signal_type,identifier,phone_kind?,provenance_url,provenance_label}`, +`ResolveByNameRequest(name:string, regionCode:int)`, +`ResolveByNameResult(candidates:array)`. + +- [ ] **Step 4: Тест → PASS.** +- [ ] **Step 5: Commit** «feat(автоподбор): DTO контракта движка». + +### Task 7: Интерфейс `CompetitorAgent` + `FakeCompetitorAgent` + binding + +**Files:** +- Create: `app/app/Services/Autopodbor/Agent/CompetitorAgent.php`, `FakeCompetitorAgent.php`, `app/app/Providers/AutopodborServiceProvider.php` +- Modify: `app/bootstrap/providers.php` (регистрация) +- Test: `app/tests/Unit/Autopodbor/FakeCompetitorAgentTest.php` + +- [ ] **Step 1: Падающий тест** + +```php +use App\Services\Autopodbor\Agent\CompetitorAgent; +use App\Services\Autopodbor\Agent\Dto\{FindCompetitorsRequest, StudyCompetitorRequest, ResolveByNameRequest}; + +it('заглушка отдаёт конкурентов, источники и кандидатов', function () { + $agent = app(CompetitorAgent::class); + $found = $agent->findCompetitors(new FindCompetitorsRequest(16, ['okna.ru'], [], true, 15)); + expect(count($found->competitors))->toBeGreaterThan(0) + ->and($found->competitors[0])->toHaveKeys(['name','relevance_pct','site_url']); + + $study = $agent->studyCompetitor(new StudyCompetitorRequest(['name'=>'Окна Комфорт','site_url'=>'okna-komfort.ru'], 16)); + expect($study->sources[0])->toHaveKeys(['signal_type','identifier','provenance_url']); + + $resolve = $agent->resolveByName(new ResolveByNameRequest('Окна Комфорт', 16)); + expect($resolve->candidates)->not->toBeEmpty(); +}); +``` + +- [ ] **Step 2: FAIL** (binding нет). + +- [ ] **Step 3: Интерфейс** + +```php +// CompetitorAgent.php +'Окна Комфорт','description'=>'Пластиковые окна и остекление балконов под ключ.','is_federal'=>false,'relevance_pct'=>100,'site_url'=>'okna-komfort-kzn.ru','directory_urls'=>['https://2gis.ru/…','https://yandex.ru/maps/…'],'provenance'=>['via'=>'similar-pages']], + ['name'=>'Пластика Окон','description'=>'Окна ПВХ, лоджии, входные группы.','is_federal'=>false,'relevance_pct'=>96,'site_url'=>'plastika-okon-kzn.ru','directory_urls'=>['https://2gis.ru/…'],'provenance'=>['via'=>'similar-pages']], + ['name'=>'Фабрика Окон','description'=>'Федеральная сеть окон ПВХ, филиал в регионе.','is_federal'=>true,'relevance_pct'=>84,'site_url'=>'fabrika-okon.ru/kazan','directory_urls'=>['https://2gis.ru/…'],'provenance'=>['via'=>'similar-pages']], + ['name'=>'Балкон-Сервис 16','description'=>'Остекление балконов; окна частично.','is_federal'=>false,'relevance_pct'=>61,'site_url'=>null,'directory_urls'=>['https://yandex.ru/maps/…','https://2gis.ru/…'],'provenance'=>['via'=>'similar-pages']], + ]); + } + public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult { + return new StudyCompetitorResult([ + ['signal_type'=>'site','identifier'=>'okna-komfort-kzn.ru','phone_kind'=>null,'provenance_url'=>'https://2gis.ru/…','provenance_label'=>'2ГИС — карточка компании'], + ['signal_type'=>'site','identifier'=>'okna-komfort.pro','phone_kind'=>null,'provenance_url'=>'https://yandex.ru/maps/…','provenance_label'=>'Яндекс.Карты — сайт в контактах'], + ['signal_type'=>'call','identifier'=>'78432001122','phone_kind'=>'real','provenance_url'=>'https://2gis.ru/…','provenance_label'=>'2ГИС — карточка компании'], + ['signal_type'=>'call','identifier'=>'78432009988','phone_kind'=>'substitute','provenance_url'=>'https://okna-komfort-kzn.ru','provenance_label'=>'номер в шапке (коллтрекинг)'], + ['signal_type'=>'call','identifier'=>'79172001122','phone_kind'=>'real','provenance_url'=>'https://yandex.ru/maps/…','provenance_label'=>'Яндекс.Карты — карточка компании'], + ]); + } + public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult { + return new ResolveByNameResult([ + ['name'=>$r->name,'description'=>'Найдено по названию (заглушка).','site_url'=>'okna-komfort-kzn.ru','directory_urls'=>['https://2gis.ru/…'],'provenance'=>['via'=>'name-search']], + ]); + } +} +``` + +- [ ] **Step 5: Провайдер + регистрация** + +```php +// AutopodborServiceProvider.php +app->bind(CompetitorAgent::class, FakeCompetitorAgent::class); + } +} +``` + +Добавить класс в `app/bootstrap/providers.php`. + +- [ ] **Step 6: Тест → PASS.** Commit «feat(автоподбор): интерфейс движка + заглушка + binding». + +--- + +# ФАЗА 3 — Нормализация и дедуп + +### Task 8: `AutopodborNormalizer` + +**Files:** +- Create: `app/app/Services/Autopodbor/AutopodborNormalizer.php` +- Test: `app/tests/Unit/Autopodbor/AutopodborNormalizerTest.php` + +- [ ] **Step 1: Падающий тест** + +```php +use App\Services\Autopodbor\AutopodborNormalizer; + +it('нормализует домен в голову', function () { + $n = new AutopodborNormalizer(); + expect($n->domainHead('https://www.Okna-Komfort.RU/contacts'))->toBe('okna-komfort.ru') + ->and($n->domainHead('okna.pro'))->toBe('okna.pro'); +}); + +it('нормализует телефон в 7xxxxxxxxxx', function () { + $n = new AutopodborNormalizer(); + expect($n->phone('+7 (843) 200-11-22'))->toBe('78432001122') + ->and($n->phone('8 843 2001122'))->toBe('78432001122'); +}); + +it('строит dedup_key источника', function () { + $n = new AutopodborNormalizer(); + expect($n->sourceKey('site','https://OKNA.ru/'))->toBe('site:okna.ru') + ->and($n->sourceKey('call','8-843-200-11-22'))->toBe('call:78432001122'); +}); +``` + +- [ ] **Step 2: FAIL.** + +- [ ] **Step 3: Реализация.** Для телефона переиспользовать существующий `PhoneNormalizer` (используется в `StoreProjectRequest`); для домена — срезать схему/www/путь/порт, lower-case. + +```php +phone($identifier) : $this->domainHead($identifier); + return $type.':'.$id; + } + public function competitorKey(string $name, ?string $siteUrl): string { + if ($siteUrl) return 'site:'.$this->domainHead($siteUrl); + return 'name:'.preg_replace('#\s+#',' ', trim(mb_strtolower($name))); + } +} +``` + +> Проверить точную сигнатуру `PhoneNormalizer::normalize` (статический ли). Если телефон уже `7xxxxxxxxxx` без `+` — формат совпадает с требованием BG. + +- [ ] **Step 4: Тест → PASS.** Commit «feat(автоподбор): нормализация домена/телефона». + +### Task 9: `AutopodborDedup` + +**Files:** +- Create: `app/app/Services/Autopodbor/AutopodborDedup.php` +- Test: `app/tests/Feature/Autopodbor/AutopodborDedupTest.php` + +- [ ] **Step 1: Падающие тесты** (3 уровня дедупа): + +```php +use App\Models\{AutopodborCompetitor, AutopodborSource, Project, Tenant}; +use App\Services\Autopodbor\AutopodborDedup; + +it('помечает источник как уже существующий проект', function () { + $tenant = Tenant::factory()->create(); setTenant($tenant->id); // helper из проекта + Project::factory()->create(['tenant_id'=>$tenant->id,'signal_type'=>'site','signal_identifier'=>'okna.ru']); + $dedup = app(AutopodborDedup::class); + expect($dedup->existingProjectId($tenant->id,'site','okna.ru'))->not->toBeNull() + ->and($dedup->existingProjectId($tenant->id,'site','drugoy.ru'))->toBeNull(); +})->uses(\Tests\Concerns\SharesSupplierPdo::class); + +it('дедупит источники внутри списка', function () { + $dedup = app(AutopodborDedup::class); + $unique = $dedup->dedupSources([ + ['signal_type'=>'call','identifier'=>'+7 843 200-11-22'], + ['signal_type'=>'call','identifier'=>'88432001122'], // тот же номер + ['signal_type'=>'site','identifier'=>'www.okna.ru'], + ]); + expect($unique)->toHaveCount(2); +}); +``` + +- [ ] **Step 2: FAIL.** + +- [ ] **Step 3: Реализация** (зависит от `AutopodborNormalizer`): + +```php +norm->phone($identifier) : $this->norm->domainHead($identifier); + return Project::query() + ->where('tenant_id', $tenantId) + ->where('signal_type', $signalType) + ->where('signal_identifier', $needle) + ->value('id'); + } + + /** @return array уникальные источники с проставленным dedup_key */ + public function dedupSources(array $sources): array { + $seen = []; $out = []; + foreach ($sources as $s) { + $key = $this->norm->sourceKey($s['signal_type'], $s['identifier']); + if (isset($seen[$key])) continue; + $seen[$key] = true; + $s['dedup_key'] = $key; + $out[] = $s; + } + return $out; + } + + /** @return array уникальные конкуренты */ + public function dedupCompetitors(array $competitors): array { + $seen = []; $out = []; + foreach ($competitors as $c) { + $key = $this->norm->competitorKey($c['name'], $c['site_url'] ?? null); + if (isset($seen[$key])) continue; + $seen[$key] = true; + $c['dedup_key'] = $key; + $out[] = $c; + } + return $out; + } +} +``` + +- [ ] **Step 4: Тест → PASS.** Commit «feat(автоподбор): дедуп конкурентов/источников/проектов». + +--- + +# ФАЗА 4 — Деньги + +### Task 10: `AutopodborChargeService` (идемпотентное списание только за успех) + +**Files:** +- Create: `app/app/Services/Autopodbor/AutopodborChargeService.php` +- Test: `app/tests/Feature/Autopodbor/AutopodborChargeServiceTest.php` + +- [ ] **Step 1: Падающие тесты** (money-инварианты): + +```php +use App\Models\{AutopodborRun, BalanceTransaction, Tenant}; +use App\Services\Autopodbor\AutopodborChargeService; + +it('списывает один раз и идемпотентно по run_id', function () { + $tenant = Tenant::factory()->create(['balance_rub'=>1000]); setTenant($tenant->id); + $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'running','params'=>[]]); + $svc = app(AutopodborChargeService::class); + + $svc->chargeForRun($run, '300.00'); + $svc->chargeForRun($run->fresh(), '300.00'); // повтор не должен списать второй раз + + $tenant->refresh(); $run->refresh(); + expect((string)$tenant->balance_rub)->toBe('700.00') + ->and($run->price_rub_charged)->not->toBeNull() + ->and(BalanceTransaction::where('type','autopodbor_charge')->where('related_id',$run->id)->count())->toBe(1); +})->uses(\Tests\Concerns\SharesSupplierPdo::class); + +it('не списывает при нехватке баланса (бросает)', function () { + $tenant = Tenant::factory()->create(['balance_rub'=>100]); setTenant($tenant->id); + $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'running','params'=>[]]); + expect(fn() => app(AutopodborChargeService::class)->chargeForRun($run, '300.00')) + ->toThrow(\App\Exceptions\InsufficientBalanceException::class); + expect((string)$tenant->fresh()->balance_rub)->toBe('100.00'); +})->uses(\Tests\Concerns\SharesSupplierPdo::class); +``` + +- [ ] **Step 2: FAIL.** + +- [ ] **Step 3: Реализация** — по образцу `LedgerService::chargeForDelivery`: транзакция + `lockForUpdate` по tenant, bcmath, идемпотентность по `balance_transaction_id`. + +```php +id)->lockForUpdate()->first(); + if ($locked->balance_transaction_id !== null) { + return; // уже списано + } + $tenant = Tenant::whereKey($locked->tenant_id)->lockForUpdate()->first(); + if (bccomp((string)$tenant->balance_rub, $priceRub, 2) < 0) { + throw new InsufficientBalanceException(); + } + $newBalance = bcsub((string)$tenant->balance_rub, $priceRub, 2); + $tenant->balance_rub = $newBalance; + $tenant->save(); + + $tx = BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_AUTOPODBOR_CHARGE, + 'amount_rub' => '-'.$priceRub, + 'balance_rub_after' => $newBalance, + 'related_type' => AutopodborRun::class, + 'related_id' => $locked->id, + ]); + $locked->balance_transaction_id = $tx->id; + $locked->price_rub_charged = $priceRub; + $locked->save(); + }); + } +} +``` + +> Сверить точные имена колонок `tenants.balance_rub` и поля `BalanceTransaction` (`amount_rub`, `balance_rub_after`, `related_type`, `related_id`) по моделям. Сверить класс `InsufficientBalanceException` (используется `LedgerService`). + +- [ ] **Step 4: Тесты → PASS.** +- [ ] **Step 5: billing-audit** по этому сервису (money-инвариант: сумма проводок = изменение баланса; идемпотентность; bcmath, без float). Зафиксировать вывод в конце. +- [ ] **Step 6: Commit** «feat(автоподбор): идемпотентное списание за прогон». + +--- + +# ФАЗА 5 — Сервис прогонов и джобы + +### Task 11: `AutopodborRunService` (старт, гейт баланса, один in-flight) + +**Files:** +- Create: `app/app/Services/Autopodbor/AutopodborRunService.php` +- Test: `app/tests/Feature/Autopodbor/AutopodborRunServiceTest.php` + +- [ ] **Step 1: Падающие тесты** + +```php +use App\Models\{AutopodborRun, Tenant}; +use App\Services\Autopodbor\AutopodborRunService; +use App\Jobs\Autopodbor\RunAutopodborSearchJob; +use Illuminate\Support\Facades\Queue; + +it('стартует search и ставит джобу', function () { + Queue::fake(); + $tenant = Tenant::factory()->create(['balance_rub'=>100000]); setTenant($tenant->id); + $run = app(AutopodborRunService::class)->startSearch($tenant->id, 16, ['okna.ru'], [], true); + expect($run->kind)->toBe('search')->and($run->status)->toBe('queued'); + Queue::assertPushed(RunAutopodborSearchJob::class); +})->uses(\Tests\Concerns\SharesSupplierPdo::class); + +it('не стартует второй in-flight search того же tenant', function () { + Queue::fake(); + $tenant = Tenant::factory()->create(['balance_rub'=>100000]); setTenant($tenant->id); + $svc = app(AutopodborRunService::class); + $svc->startSearch($tenant->id, 16, ['okna.ru'], [], true); + expect(fn() => $svc->startSearch($tenant->id, 16, ['okna.ru'], [], true)) + ->toThrow(\App\Exceptions\Autopodbor\RunInFlightException::class); +})->uses(\Tests\Concerns\SharesSupplierPdo::class); + +it('гейтит по балансу до старта (не хватает на цену)', function () { + Queue::fake(); + \App\Support\SystemSettings::shouldReceiveOrSet('autopodbor_price_search_rub','500'); // см. helper ниже + $tenant = Tenant::factory()->create(['balance_rub'=>100]); setTenant($tenant->id); + expect(fn() => app(AutopodborRunService::class)->startSearch($tenant->id,16,['okna.ru'],[],true)) + ->toThrow(\App\Exceptions\InsufficientBalanceException::class); +})->uses(\Tests\Concerns\SharesSupplierPdo::class); +``` + +> Цену в тестах ставить через реальную запись в `system_settings` (не мок), если в проекте нет удобного фейка: `SystemSetting::updateOrCreate(['key'=>'autopodbor_price_search_rub'],['value'=>'500','type'=>'decimal'])`. Заменить псевдо-`shouldReceiveOrSet` на это. + +- [ ] **Step 2: FAIL.** + +- [ ] **Step 3: Исключения** — создать `app/app/Exceptions/Autopodbor/RunInFlightException.php` (наследник базового), переиспользовать существующий `InsufficientBalanceException`. + +- [ ] **Step 4: Реализация** + +```php +where('kind',$kind) + ->whereIn('status',['queued','running'])->exists(); + if ($exists) throw new RunInFlightException(); + } + private function priceGate(int $tenantId, string $key): string { + $price = (string)(SystemSettings::get($key) ?? '0'); + $balance = (string)Tenant::whereKey($tenantId)->value('balance_rub'); + if (bccomp($balance, $price, 2) < 0) throw new InsufficientBalanceException(); + return $price; + } + public function startSearch(int $tenantId, int $regionCode, array $examples, array $aboutSelf, bool $includeFederal): AutopodborRun { + $this->assertNoInFlight($tenantId,'search'); + $this->priceGate($tenantId,'autopodbor_price_search_rub'); + $run = AutopodborRun::create(['tenant_id'=>$tenantId,'kind'=>'search','status'=>'queued','region_code'=>$regionCode, + 'params'=>['examples'=>$examples,'about_self'=>$aboutSelf,'include_federal'=>$includeFederal]]); + RunAutopodborSearchJob::dispatch($run->id); + return $run; + } + public function startStudy(int $tenantId, int $competitorId): AutopodborRun { + $comp = AutopodborCompetitor::where('tenant_id',$tenantId)->findOrFail($competitorId); + if ($comp->studied_at !== null) return $comp->studyRun; // уже изучен — бесплатно + $this->assertNoInFlight($tenantId,'study'); + $this->priceGate($tenantId,'autopodbor_price_study_rub'); + $run = AutopodborRun::create(['tenant_id'=>$tenantId,'kind'=>'study','status'=>'queued', + 'region_code'=>$comp->searchRun->region_code ?? null,'competitor_id'=>$comp->id,'params'=>[]]); + RunAutopodborStudyJob::dispatch($run->id); + return $run; + } + public function startResolve(int $tenantId, string $name, int $regionCode): AutopodborRun { + $this->assertNoInFlight($tenantId,'resolve'); + // resolve бесплатный — без priceGate + $run = AutopodborRun::create(['tenant_id'=>$tenantId,'kind'=>'resolve','status'=>'queued', + 'region_code'=>$regionCode,'params'=>['name'=>$name]]); + RunAutopodborResolveJob::dispatch($run->id); + return $run; + } +} +``` + +- [ ] **Step 5: Тесты → PASS.** Commit «feat(автоподбор): RunService — старт, гейт баланса, один in-flight». + +### Task 12: `RunAutopodborSearchJob` + +**Files:** +- Create: `app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php` +- Test: `app/tests/Feature/Autopodbor/RunAutopodborSearchJobTest.php` + +- [ ] **Step 1: Падающие тесты** + +```php +use App\Models\{AutopodborRun, AutopodborCompetitor, BalanceTransaction, Tenant}; +use App\Jobs\Autopodbor\RunAutopodborSearchJob; + +it('сохраняет конкурентов, ставит done и списывает', function () { + $tenant = Tenant::factory()->create(['balance_rub'=>100000]); setTenant($tenant->id); + \App\Models\SystemSetting::updateOrCreate(['key'=>'autopodbor_price_search_rub'],['value'=>'500','type'=>'decimal']); + $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'queued','region_code'=>16,'params'=>['examples'=>['okna.ru'],'about_self'=>[],'include_federal'=>true]]); + + (new RunAutopodborSearchJob($run->id))->handle(app(\App\Services\Autopodbor\CompetitorAgentRunner::class)); // см. ниже про DI + + $run->refresh(); + expect($run->status)->toBe('done')->and($run->price_rub_charged)->toBe('500.00') + ->and(AutopodborCompetitor::where('search_run_id',$run->id)->count())->toBeGreaterThan(0) + ->and(AutopodborCompetitor::where('search_run_id',$run->id)->orderByDesc('relevance_pct')->first()->relevance_pct)->toBe(100); +})->uses(\Tests\Concerns\SharesSupplierPdo::class); + +it('пустой результат → empty, без списания', function () { + $tenant = Tenant::factory()->create(['balance_rub'=>100000]); setTenant($tenant->id); + \App\Models\SystemSetting::updateOrCreate(['key'=>'autopodbor_price_search_rub'],['value'=>'500','type'=>'decimal']); + $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'queued','region_code'=>16,'params'=>['examples'=>[],'about_self'=>[],'include_federal'=>false]]); + // подменяем агента на пустого + app()->bind(\App\Services\Autopodbor\Agent\CompetitorAgent::class, \Tests\Doubles\EmptyCompetitorAgent::class); + + (new RunAutopodborSearchJob($run->id))->handle(app(\App\Services\Autopodbor\Agent\CompetitorAgent::class), app(\App\Services\Autopodbor\AutopodborDedup::class), app(\App\Services\Autopodbor\AutopodborChargeService::class), new \App\Support\SystemSettings()); + + $run->refresh(); + expect($run->status)->toBe('empty')->and($run->price_rub_charged)->toBeNull() + ->and((string)$tenant->fresh()->balance_rub)->toBe('100000.00'); +})->uses(\Tests\Concerns\SharesSupplierPdo::class); +``` + +> Создать тест-дубль `tests/Doubles/EmptyCompetitorAgent.php` (реализует интерфейс, отдаёт пустые массивы). Привести сигнатуру `handle()` к единой DI-форме (см. Step 3) и использовать её в обоих тестах. + +- [ ] **Step 2: FAIL.** + +- [ ] **Step 3: Реализация** + +```php +runId); + TenantContext::set($run->tenant_id); + $run->update(['status'=>'running','started_at'=>now()]); + try { + $p = $run->params; + $max = (int)($settings->get('autopodbor_max_competitors') ?? 15); + $res = $agent->findCompetitors(new FindCompetitorsRequest( + $run->region_code, $p['examples'] ?? [], $p['about_self'] ?? [], (bool)($p['include_federal'] ?? false), $max)); + $unique = $dedup->dedupCompetitors($res->competitors); + if (empty($unique)) { $run->update(['status'=>'empty','finished_at'=>now()]); return; } + foreach (array_slice($unique, 0, $max) as $c) { + AutopodborCompetitor::create([ + 'tenant_id'=>$run->tenant_id,'search_run_id'=>$run->id,'name'=>$c['name'], + 'description'=>$c['description'] ?? null,'is_federal'=>(bool)($c['is_federal'] ?? false), + 'relevance_pct'=>$c['relevance_pct'] ?? null,'origin'=>'auto','site_url'=>$c['site_url'] ?? null, + 'directory_urls'=>$c['directory_urls'] ?? [],'provenance'=>$c['provenance'] ?? [],'dedup_key'=>$c['dedup_key'], + ]); + } + $price = (string)($settings->get('autopodbor_price_search_rub') ?? '0'); + $charge->chargeForRun($run, $price); + $run->update(['status'=>'done','finished_at'=>now()]); + } catch (\Throwable $e) { + $run->update(['status'=>'failed','error_code'=>substr($e->getMessage(),0,64),'finished_at'=>now()]); + throw $e; // отдать на ретрай + } + } +} +``` + +> Сверить способ установки tenant в джобах по существующим (`SyncSupplierProjectsJob` / supplier-джобы): как именно ставят `app.tenant_id` (через `TenantContext`/`set_config`/`pgsql_supplier`). Привести к проектному паттерну. + +- [ ] **Step 4: Тесты → PASS.** Commit «feat(автоподбор): джоба шага 1 (подбор)». + +### Task 13: `RunAutopodborStudyJob` + +**Files:** +- Create: `app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php` +- Test: `app/tests/Feature/Autopodbor/RunAutopodborStudyJobTest.php` + +- [ ] **Step 1: Падающие тесты** + +```php +it('сохраняет источники, помечает конкурента изученным, списывает', function () { + $tenant = Tenant::factory()->create(['balance_rub'=>100000]); setTenant($tenant->id); + \App\Models\SystemSetting::updateOrCreate(['key'=>'autopodbor_price_study_rub'],['value'=>'900','type'=>'decimal']); + $searchRun = \App\Models\AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'done','region_code'=>16,'params'=>[]]); + $comp = \App\Models\AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$searchRun->id,'name'=>'Окна Комфорт','dedup_key'=>'okna','site_url'=>'okna-komfort.ru']); + $run = \App\Models\AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'queued','competitor_id'=>$comp->id,'region_code'=>16,'params'=>[]]); + + (new \App\Jobs\Autopodbor\RunAutopodborStudyJob($run->id))->handle(app(\App\Services\Autopodbor\Agent\CompetitorAgent::class), app(\App\Services\Autopodbor\AutopodborDedup::class), app(\App\Services\Autopodbor\AutopodborChargeService::class), new \App\Support\SystemSettings()); + + $run->refresh(); $comp->refresh(); + expect($run->status)->toBe('done')->and($run->price_rub_charged)->toBe('900.00') + ->and($comp->studied_at)->not->toBeNull() + ->and(\App\Models\AutopodborSource::where('competitor_id',$comp->id)->count())->toBeGreaterThan(0); +})->uses(\Tests\Concerns\SharesSupplierPdo::class); +``` + +- [ ] **Step 2: FAIL.** + +- [ ] **Step 3: Реализация** (аналогична Task 12, но `studyCompetitor`, источники, `dedupSources`, проставить `studied_at`/`study_run_id` конкуренту): + +```php +public function handle(CompetitorAgent $agent, AutopodborDedup $dedup, AutopodborChargeService $charge, SystemSettings $settings): void { + $run = AutopodborRun::findOrFail($this->runId); TenantContext::set($run->tenant_id); + $run->update(['status'=>'running','started_at'=>now()]); + try { + $comp = AutopodborCompetitor::findOrFail($run->competitor_id); + $res = $agent->studyCompetitor(new StudyCompetitorRequest( + ['name'=>$comp->name,'site_url'=>$comp->site_url,'directory_urls'=>$comp->directory_urls], $run->region_code)); + $unique = $dedup->dedupSources($res->sources); + if (empty($unique)) { $run->update(['status'=>'empty','finished_at'=>now()]); return; } + foreach ($unique as $s) { + AutopodborSource::create([ + 'tenant_id'=>$run->tenant_id,'competitor_id'=>$comp->id,'study_run_id'=>$run->id, + 'signal_type'=>$s['signal_type'],'identifier'=>$this->normIdentifier($s),'phone_kind'=>$s['phone_kind'] ?? null, + 'provenance_url'=>$s['provenance_url'] ?? null,'provenance_label'=>$s['provenance_label'] ?? null,'dedup_key'=>$s['dedup_key'], + ]); + } + $price = (string)($settings->get('autopodbor_price_study_rub') ?? '0'); + $charge->chargeForRun($run, $price); + $comp->update(['studied_at'=>now(),'study_run_id'=>$run->id]); + $run->update(['status'=>'done','finished_at'=>now()]); + } catch (\Throwable $e) { + $run->update(['status'=>'failed','error_code'=>substr($e->getMessage(),0,64),'finished_at'=>now()]); throw $e; + } +} +``` + +> `normIdentifier` — приватный хелпер: вызвать `AutopodborNormalizer` через DI (добавить в конструктор/`handle`) или нормализовать в `dedupSources` (он уже строит dedup_key; пусть кладёт ещё и нормализованный `identifier`). Привести к одному месту нормализации, чтобы `identifier` в БД был уже головой домена / `7xxxxxxxxxx`. + +- [ ] **Step 4: Тесты → PASS.** Commit «feat(автоподбор): джоба шага 2 (изучение)». + +### Task 14: `RunAutopodborResolveJob` + +**Files:** +- Create: `app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php` +- Test: `app/tests/Feature/Autopodbor/RunAutopodborResolveJobTest.php` + +- [ ] **Step 1: Падающий тест** — резолв по названию создаёт `autopodbor_competitors` с `origin='resolve'`, run `done`, БЕЗ списания (`price_rub_charged` null). Несколько кандидатов → несколько строк. + +```php +it('резолв по названию — кандидаты без списания', function () { + $tenant = Tenant::factory()->create(['balance_rub'=>100000]); setTenant($tenant->id); + $run = \App\Models\AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'resolve','status'=>'queued','region_code'=>16,'params'=>['name'=>'Окна Комфорт']]); + (new \App\Jobs\Autopodbor\RunAutopodborResolveJob($run->id))->handle(app(\App\Services\Autopodbor\Agent\CompetitorAgent::class), app(\App\Services\Autopodbor\AutopodborDedup::class), new \App\Support\SystemSettings()); + $run->refresh(); + expect($run->status)->toBe('done')->and($run->price_rub_charged)->toBeNull() + ->and(\App\Models\AutopodborCompetitor::where('search_run_id',$run->id)->where('origin','resolve')->count())->toBeGreaterThan(0); +})->uses(\Tests\Concerns\SharesSupplierPdo::class); +``` + +- [ ] **Step 2: FAIL.** +- [ ] **Step 3: Реализация** — как Task 12, но `resolveByName`, сохранять кандидатов с `origin='resolve'`, `search_run_id=run->id`, `relevance_pct=null`; БЕЗ `chargeForRun`. Пусто → `empty`. +- [ ] **Step 4: Тест → PASS.** Commit «feat(автоподбор): джоба резолва по названию». + +--- + +# ФАЗА 6 — Создание проектов из источников + +### Task 15: `AutopodborProjectCreator` (батч + уникальное имя + strip эмодзи) + +**Files:** +- Create: `app/app/Services/Autopodbor/AutopodborProjectCreator.php` +- Test: `app/tests/Feature/Autopodbor/AutopodborProjectCreatorTest.php` + +- [ ] **Step 1: Падающие тесты** + +```php +use App\Models\{AutopodborCompetitor, AutopodborSource, Project, Tenant}; +use App\Services\Autopodbor\AutopodborProjectCreator; + +it('создаёт проекты из выбранных источников с общими настройками', function () { + $tenant = Tenant::factory()->create(['balance_rub'=>500000]); setTenant($tenant->id); + $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'name'=>'Окна Комфорт','dedup_key'=>'okna']); + $s1 = AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>1,'signal_type'=>'site','identifier'=>'okna-komfort.ru','dedup_key'=>'site:okna-komfort.ru']); + $s2 = AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>1,'signal_type'=>'call','identifier'=>'78432001122','phone_kind'=>'real','dedup_key'=>'call:78432001122']); + + $creator = app(AutopodborProjectCreator::class); + $projects = $creator->createFromSources($tenant->id, [$s1->id, $s2->id], [ + 'regions'=>[16],'daily_limit_target'=>20,'delivery_days_mask'=>127, + ], launch:false); + + expect($projects)->toHaveCount(2) + ->and(Project::where('tenant_id',$tenant->id)->where('signal_identifier','okna-komfort.ru')->exists())->toBeTrue(); + expect($s1->fresh()->created_project_id)->not->toBeNull(); +})->uses(\Tests\Concerns\SharesSupplierPdo::class); + +it('разруливает коллизию одинаковых имён суффиксом', function () { + $tenant = Tenant::factory()->create(['balance_rub'=>500000]); setTenant($tenant->id); + $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'name'=>'Окна Комфорт','dedup_key'=>'okna']); + $s1 = AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>1,'signal_type'=>'site','identifier'=>'a.ru','dedup_key'=>'site:a.ru']); + $s2 = AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>1,'signal_type'=>'site','identifier'=>'b.ru','dedup_key'=>'site:b.ru']); + $projects = app(AutopodborProjectCreator::class)->createFromSources($tenant->id,[$s1->id,$s2->id],['regions'=>[16],'daily_limit_target'=>20,'delivery_days_mask'=>127], false); + $names = Project::where('tenant_id',$tenant->id)->pluck('name')->all(); + expect($names)->toContain('Окна Комфорт')->and(collect($names)->unique()->count())->toBe(2); // второе с суффиксом +})->uses(\Tests\Concerns\SharesSupplierPdo::class); +``` + +- [ ] **Step 2: FAIL.** + +- [ ] **Step 3: Реализация** — переиспользовать `ProjectService::create`; имя = имя конкурента + значок (✓ для real-phone, 🎭 для substitute, без значка для site); при коллизии `assertNameUnique` ловить и добавлять суффикс « 2», « 3». Привязать `created_project_id` к источнику. + +```php +whereIn('id',$sourceIds)->with('competitor')->get(); + $created = []; + foreach ($sources as $src) { + $base = $this->displayName($src); + $name = $this->uniqueName($tenantId, $base); + $project = $this->projects->create($tenant, [ + 'name'=>$name, + 'signal_type'=>$src->signal_type, + 'signal_identifier'=>$src->identifier, + 'daily_limit_target'=>$common['daily_limit_target'], + 'regions'=>$common['regions'], + 'delivery_days_mask'=>$common['delivery_days_mask'], + 'force_save_blocked'=> $launch ? false : true, // без запуска — допускаем блок-без-баланса? см. NB + ]); + $src->update(['created_project_id'=>$project->id]); + $created[] = $project; + } + return $created; + } + + private function displayName(AutopodborSource $s): string { + $n = $s->competitor->name; + if ($s->signal_type === 'call' && $s->phone_kind === 'real') return $n.' ✓'; + if ($s->signal_type === 'call' && $s->phone_kind === 'substitute') return $n.' 🎭'; + return $n; + } + + private function uniqueName(int $tenantId, string $base): string { + $name = $base; $i = 1; + while (Project::where('tenant_id',$tenantId)->where('name',$name)->exists()) { + $i++; $name = $base.' '.$i; + } + return $name; + } +} +``` + +> NB по `force_save_blocked`: «Создать без запуска» и «Создать и запустить» — разное поведение префлайта. Решить в Task 17 (контроллер): «запустить» гоняет префлайт и при нехватке отдаёт 409; «без запуска» создаёт (возможно с `preflight_blocked_at`). Не дублировать логику здесь — пробрасывать флаг. Сверить, как `ProjectService::create` трактует `force_save_blocked`. + +- [ ] **Step 4: Тесты → PASS.** Commit «feat(автоподбор): создание проектов из источников + уникальное имя». + +### Task 16: Strip эмодзи в DTO поставщику + +**Files:** +- Modify: `app/app/Services/Supplier/Dto/SupplierProjectDto.php` (или место сборки `name` для синка) +- Test: `app/tests/Unit/Autopodbor/SupplierNameStripTest.php` + +- [ ] **Step 1: Падающий тест** + +```php +it('очищает значок из имени для поставщика', function () { + $clean = \App\Services\Supplier\Dto\SupplierProjectDto::stripBadge('Окна Комфорт 🎭'); + expect($clean)->toBe('Окна Комфорт'); + expect(\App\Services\Supplier\Dto\SupplierProjectDto::stripBadge('Окна ✓'))->toBe('Окна'); +}); +``` + +- [ ] **Step 2: FAIL.** + +- [ ] **Step 3:** добавить статический `stripBadge(string): string` (срезать хвостовые ` ✓`/` 🎭` и любые эмодзи) и применить при сборке `name` для отправки поставщику (там, где `dto.name` формируется перед `manage-project.js`). + +```php +public static function stripBadge(string $name): string { + // убрать хвостовые значки и эмодзи, схлопнуть пробелы + $n = preg_replace('/[\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}\x{2705}]/u', '', $name); + return trim(preg_replace('/\s+/u', ' ', $n)); +} +``` + +- [ ] **Step 4: Тест → PASS.** +- [ ] **Step 5: Хвост §9 дизайна** — проверить вручную/в задаче, принимает ли BG юникод в имени; если да, strip можно ослабить. Пока strip по умолчанию. +- [ ] **Step 6: Commit** «feat(автоподбор): очистка значка имени для поставщика». + +--- + +# ФАЗА 7 — API + +### Task 17: `AutopodborController` + роуты + Resources + +**Files:** +- Create: `app/app/Http/Controllers/Api/AutopodborController.php`, `app/app/Http/Resources/Autopodbor/{RunResource,CompetitorResource,SourceResource}.php` +- Modify: `app/routes/api.php` +- Test: `app/tests/Feature/Autopodbor/AutopodborApiTest.php` + +- [ ] **Step 1: Падающие тесты** (через HTTP, аутентифицированный клиент): + +```php +it('GET /api/autopodbor/state отдаёт историю и доступность', function () { + $tenant = Tenant::factory()->create(); $user = userFor($tenant); // существующий helper логина + \App\Models\SystemSetting::updateOrCreate(['key'=>'autopodbor_enabled'],['value'=>'1','type'=>'bool']); + $this->actingAs($user)->getJson('/api/autopodbor/state') + ->assertOk()->assertJsonStructure(['enabled','runs','prices'=>['search','study']]); +})->uses(\Tests\Concerns\SharesSupplierPdo::class); + +it('POST /api/autopodbor/search стартует прогон', function () { + $tenant = Tenant::factory()->create(['balance_rub'=>100000]); $user = userFor($tenant); + \App\Models\SystemSetting::updateOrCreate(['key'=>'autopodbor_price_search_rub'],['value'=>'1','type'=>'decimal']); + \Illuminate\Support\Facades\Queue::fake(); + $this->actingAs($user)->postJson('/api/autopodbor/search',[ + 'region_code'=>16,'examples'=>['okna.ru'],'about_self'=>[],'include_federal'=>true, + ])->assertCreated()->assertJsonPath('data.kind','search'); +})->uses(\Tests\Concerns\SharesSupplierPdo::class); + +it('POST /api/autopodbor/projects создаёт проекты из источников', function () { + // подготовить competitor+sources, выбрать 2, ожидать 5xx? нет — 201 и проекты + // ... (по образцу Task 15) → assertCreated, projects exist +})->uses(\Tests\Concerns\SharesSupplierPdo::class); +``` + +> Узнать точные helper'ы тестов аутентификации клиента (как в `tests/Feature` для projects). Привести `userFor`/`actingAs` к проектному стилю. + +- [ ] **Step 2: FAIL.** + +- [ ] **Step 3: Роуты** в `routes/api.php` (под middleware tenant-аутентификации клиента, рядом с `projects`): + +```php +Route::prefix('autopodbor')->group(function () { + Route::get('state', [AutopodborController::class, 'state']); + Route::get('runs/{run}', [AutopodborController::class, 'run']); + Route::get('competitors/{competitor}', [AutopodborController::class, 'competitor']); // источники + живое «уже есть проект» + Route::post('search', [AutopodborController::class, 'search']); + Route::post('study', [AutopodborController::class, 'study']); // {competitor_id} + Route::post('resolve', [AutopodborController::class, 'resolve']); // {name, region_code} + Route::post('manual-study', [AutopodborController::class, 'manualStudy']); // {site_url|directory|competitor_id, region_code} + Route::post('sources/manual', [AutopodborController::class, 'addManualSource']); // {competitor_id, raw} + Route::post('projects', [AutopodborController::class, 'createProjects']); // {source_ids[], regions, daily_limit_target, delivery_days_mask, launch} +}); +``` + +- [ ] **Step 4: Контроллер** — тонкий: валидация FormRequest/inline, вызовы `AutopodborRunService` / `AutopodborProjectCreator`, маппинг исключений: + - `RunInFlightException` → 409 `run_in_flight`. + - `InsufficientBalanceException` (старт прогона) → 409 `balance_insufficient` (для прогона; в UI — «недостаточно средств»). + - создание «и запустить» с нехваткой лимита → существующий 409 `balance_insufficient` payload (переиспользовать `ProjectController::runPreflight` логику или вынести в общий сервис). + - `competitor`-эндпоинт отдаёт источники + по каждому `existing_project_id` (через `AutopodborDedup::existingProjectId`) для живого состояния. + +```php +// фрагмент метода competitor() +public function competitor(int $competitor, AutopodborDedup $dedup) { + $comp = AutopodborCompetitor::where('tenant_id', tenant_id())->with('sources')->findOrFail($competitor); + $sources = $comp->sources->map(function ($s) use ($dedup) { + $existing = $s->created_project_id ?? $dedup->existingProjectId($s->tenant_id, $s->signal_type, $s->identifier); + return (new SourceResource($s))->additional(['existing_project_id'=>$existing]); + }); + return CompetitorResource::make($comp)->additional(['sources'=>$sources]); +} +``` + +- [ ] **Step 5: Resources** — `RunResource` (id,kind,status,region_code,counts), `CompetitorResource` (поля + studied + relevance), `SourceResource` (signal_type,identifier,phone_kind,provenance_*,created_project_id). ПДн-маска: телефоны источников показываем целиком (это публичные номера конкурентов, не ПДн лидов) — подтвердить с pdn-152fz при ревью; имена/описания не маскируем. + +- [ ] **Step 6: Тесты → PASS.** Commit «feat(автоподбор): API контроллер + роуты + ресурсы». + +--- + +# ФАЗА 8 — Frontend + +> Экраны 1:1 по прототипу `…-prototype.html` в стиле Forest. Каждый экран — отдельный `.vue` в `views/autopodbor/screens/`. Состояние — Pinia store. После каждого экрана — vitest на ключевую логику (не пиксели). + +### Task 18: API-клиент + store + +**Files:** +- Create: `app/resources/js/api/autopodbor.ts`, `app/resources/js/stores/autopodborStore.ts` +- Test: `app/resources/js/__tests__/autopodborStore.spec.ts` + +- [ ] **Step 1: Падающий тест** (store: загрузка state, старт search переводит в loading, опрос до done). + +```ts +import { setActivePinia, createPinia } from 'pinia'; +import { useAutopodborStore } from '../stores/autopodborStore'; +import { vi, it, expect, beforeEach } from 'vitest'; +vi.mock('../api/autopodbor', () => ({ + fetchState: vi.fn().mockResolvedValue({ enabled:true, runs:[], prices:{search:'500',study:'900'} }), + startSearch: vi.fn().mockResolvedValue({ id:1, kind:'search', status:'queued' }), + fetchRun: vi.fn().mockResolvedValue({ id:1, status:'done' }), +})); +beforeEach(()=>setActivePinia(createPinia())); +it('loads state', async () => { + const s = useAutopodborStore(); await s.loadState(); + expect(s.enabled).toBe(true); expect(s.prices.search).toBe('500'); +}); +``` + +- [ ] **Step 2: FAIL.** +- [ ] **Step 3: Реализация** `autopodbor.ts` (обёртки axios над эндпоинтами Task 17) и `autopodborStore.ts` (state: enabled, prices, runs, currentRun, competitors, sources; actions: loadState, startSearch/Study/Resolve, pollRun(id), loadCompetitor(id), createProjects). Опрос — `setInterval` ~2500ms до терминального статуса. +- [ ] **Step 4: Тест → PASS.** Commit «feat(автоподбор): api-клиент + store». + +### Task 19: Контейнер `AutopodborView` + маршрут + пункт меню + +**Files:** +- Create: `app/resources/js/views/autopodbor/AutopodborView.vue` +- Modify: роутер, компонент сайдбара +- Test: smoke-рендер `app/resources/js/__tests__/AutopodborView.spec.ts` + +- [ ] **Step 1: Падающий smoke-тест** — монтируется, при `enabled=false` показывает «скоро»/прячет; при enabled показывает экран входа. +- [ ] **Step 2: FAIL.** +- [ ] **Step 3:** `AutopodborView` — держит текущий экран (`entry|autoform|manualform|resolve|loading|list|detail|create|empty|done`) и переключает по событиям store (зеркало `show()` из прототипа). Добавить маршрут `/autopodbor` и пункт меню «Автоподбор конкурентов» с меткой NEW в группе «Работа» (под «Проекты»), видимость = `enabled`. +- [ ] **Step 4: Тест → PASS.** Commit «feat(автоподбор): контейнер вкладки + маршрут + пункт меню». + +### Task 20: Экран входа (Entry) + «Продолжить начатое» + +**Files:** `screens/EntryScreen.vue` + тест. +- [ ] Падающий тест: при наличии `runs` рендерит блок «Продолжить начатое»; пусто — только две двери. Клик по двери эмитит переход. +- [ ] Реализация по `s-entry`. Commit. + +### Task 21: Форма авто-подбора (AutoForm) + +**Files:** `screens/AutoFormScreen.vue` + тест. +- [ ] Падающий тест: добавление строк конкурентов; обязательность региона; сабмит зовёт store.startSearch с собранным payload; перед стартом — окно подтверждения цены (из store.prices.search). +- [ ] Реализация по `s-autoform` (тон деловой, секции «О вашей компании», галка федеральных). Commit. + +### Task 22: Форма «свой конкурент» + подтверждение по названию (ManualForm + ResolveConfirm) + +**Files:** `screens/ManualFormScreen.vue`, `screens/ResolveConfirmScreen.vue` + тесты. +- [ ] Падающий тест: дан сайт → сразу manual-study (платно, после подтверждения цены); дано только имя → resolve (бесплатно) → экран ResolveConfirm со списком кандидатов → выбор → study (платно). «Не та» → назад без списания. +- [ ] Реализация по `s-manualform` + новый ResolveConfirm (🟡 §3.3 дизайна). Commit. + +### Task 23: Загрузка / Пусто / Готово (Loading, Empty, Done) + +**Files:** `screens/LoadingScreen.vue`, `screens/EmptyScreen.vue`, `screens/DoneScreen.vue` + тесты. +- [ ] Падающий тест: Loading опрашивает store.pollRun; на `done`→переход к результату, `empty/failed`→Empty. Empty показывает «деньги не списаны». Done показывает счётчик проектов. +- [ ] Реализация по `s-loading`/`s-done` + новый `s-empty` (🟡). Commit. + +### Task 24: Список конкурентов (CompetitorList) + +**Files:** `screens/CompetitorListScreen.vue` + тест. +- [ ] Падающий тест: сортировка по `relevance_pct` (100 первым); изученный → «Открыть источники» (бесплатно), неизученный → «Изучить подробнее» (платно, подтверждение цены); кнопка «указать вручную». +- [ ] Реализация по `s-list`. Commit. + +### Task 25: Источники конкурента (SourceList) + живое состояние + «Изменить проект» + +**Files:** `screens/SourceListScreen.vue` + тест. +- [ ] Падающий тест: источник с `existing_project_id` → заблокирован, метка «✓ проект создан», кнопка «Изменить проект»; секции Сайты/Телефоны; метки настоящий ✓/подменный 🎭; «добавить источник вручную»; нижняя панель «Выбрано X из K». +- [ ] Реализация по `s-detail`. «Изменить проект» открывает существующий `EditProjectDialog`/`NewProjectDialog` в режиме edit (переиспользовать) ИЛИ отдельный экран `s-editproject` через `PATCH /api/projects/{id}`. Предпочесть переиспользование `NewProjectDialog.vue` (mode='edit'). Commit. + +### Task 26: Создание проектов (CreateProjects) + +**Files:** `screens/CreateProjectsScreen.vue` + тест. +- [ ] Падающий тест: список «Будет создано N»; редактируемые имена (предзаполнены имя+значок); общие настройки (регион/лимит/дни); «Создать» vs «Создать и запустить»; при 409 `balance_insufficient` от запуска — переиспользовать `ProjectLimitOverloadDialog`. +- [ ] Реализация по `s-create`. Сабмит → `POST /api/autopodbor/projects` с `launch` флагом → Done. Commit. + +--- + +# ФАЗА 9 — Админка и интеграция + +### Task 27: Админ-настройки цены/вкл (переиспользование) + +**Files:** проверить, что ключи `autopodbor_*` видны и редактируемы в существующем `SystemSettingEditDialog.vue` / списке `AdminSystemSettingsController::index`. +- [ ] Если список настроек фильтрованный/whitelisted — добавить ключи `autopodbor_enabled`, `autopodbor_price_search_rub`, `autopodbor_price_study_rub`, `autopodbor_max_competitors` в whitelist/описания. Тест: PUT по ключу `autopodbor_price_search_rub` обновляет значение + пишет аудит. Commit. + +### Task 28: Сквозной UI-smoke на заглушке (Playwright) + +**Files:** `app/playwright/smoke-autopodbor.js` (или существующий формат smoke). +- [ ] Прокликать на заглушке: вход → авто-форма → loading → список → изучить → источники → создать 2 проекта → Done. Сверить, что проекты появились в «Проектах» и источники помечены «проект создан» при повторном заходе. Зафиксировать скриншоты. (Не коммитить скриншоты-LOCAL по правилу gitignore.) + +### Task 29: Финальная проверка и нормативка + +- [ ] `/regression full` — зелёный (Pest --parallel, Larastan, Vitest, build, lychee, gitleaks). +- [ ] `rls-reviewer` по трём таблицам — без находок. +- [ ] `billing-audit` по `AutopodborChargeService` — money-инвариант подтверждён. +- [ ] `db/CHANGELOG_schema.md` + header `db/schema.sql` обновлены (3 таблицы, тип проводки). +- [ ] Self-review (§8 CLAUDE.md): schema, narrative-версии, cross-ref. +- [ ] Память: записать прогресс фичи (coverage `direct:memory-sync`). +- [ ] Commit/мерж ветки — через `finishing-a-development-branch`, после «эскейп». + +--- + +## Self-review плана (выполнено при написании) + +- **Покрытие спеки:** §3 экраны → Tasks 18–26; §4 данные → Tasks 1–4,9; §4.4 дедуп → Task 9 + 17(competitor); §4.5 имя/эмодзи → Tasks 15,16; §5 деньги → Tasks 5,10,11; §5.4 настройки → Tasks 5,27; §6 объём → Task 5(ключ)+12; §7 розетка → Tasks 6,7; §8 швы → Tasks 11(in-flight),20(история),25(живое); §3.3 resolve → Tasks 14,22; §3.7 empty → Task 23; §10 тесты → во всех тасках + Task 29. Пробелов нет. +- **Плейсхолдеры:** конкретные суммы цены намеренно НЕ заданы (бизнес-решение владельца, задаются в админке) — это не плейсхолдер кода, а конфиг. Помечено явно. +- **Согласованность типов:** методы `findCompetitors/studyCompetitor/resolveByName`, `chargeForRun`, `existingProjectId/dedupSources/dedupCompetitors`, `createFromSources`, `stripBadge` — имена едины между тасками. +- **Зоны проверки руками** (помечены «Сверить…» в шагах): точные namespace `PhoneNormalizer`, способ установки `app.tenant_id` в джобах, helper'ы тестовой аутентификации/tenant, имена колонок `tenants.balance_rub`/`BalanceTransaction`, трактовка `force_save_blocked` в `ProjectService::create`. Исполнитель сверяет по коду перед реализацией шага. diff --git a/docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-design.md b/docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-design.md new file mode 100644 index 00000000..2bba711b --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-design.md @@ -0,0 +1,421 @@ +# Автоподбор конкурентов — дизайн-документ + +> **Дата:** 2026-06-28. **Статус:** черновик на ревью владельца. +> **Первоисточники брейншторма** (не пересказ — читать вместе с этим документом): +> - `2026-06-28-autopodbor-konkurentov-brainstorm-notes.md` — все решения и хвосты. +> - `2026-06-28-autopodbor-konkurentov-prototype.html` — согласованный UX «под ключ». +> - `2026-06-28-autopodbor-konkurentov-NEXT-SESSION-PROMPT.md` — сжатый контекст. +> +> **Граница этой работы (решение владельца 2026-06-28):** сам ИИ-движок (как реально +> ходим в интернет) делается в ОТДЕЛЬНОЙ сессии. Здесь проектируется и собирается +> ВСЁ остальное так, чтобы готовый движок вставился в одну заранее описанную «розетку» +> (§7) без переделки экранов, данных и денег. На v1 за движок работает заглушка. + +--- + +## 0. Для владельца (без программистских слов) + +Что строим: новую вкладку «Автоподбор конкурентов» в кабинете клиента. Клиент даёт +несколько своих конкурентов и регион — система находит похожих, по выбранным +вытаскивает их источники (сайты и телефоны), а клиент одной кнопкой заводит из них +проекты. Обе тяжёлые операции (подбор и изучение) — платные, деньги списываем +**только за успешный результат**, всё считается в фоне, результаты сохраняются, за уже +оплаченное второй раз не берём. + +Что в этой работе НЕ делаем: «мозг», который реально ищет в интернете. Его точите +отдельно. Мы готовим всё вокруг и оставляем «розетку», куда мозг потом вставится. +Пока розетка занята заглушкой (выдаёт заготовленные данные), чтобы экраны, деньги и +сохранение можно было собрать и проверить целиком уже сейчас. + +**Что я решил по умолчанию за тебя** (помечено ниже значком 🟡 — поправь на ревью): +- 🟡 Режим «свой конкурент по названию» — добавлен экран подтверждения «мы нашли вот + эту компанию — она?» ДО списания денег (§3.3). +- 🟡 Пустой результат — экран «ничего не нашли», деньги не списываем (§3.7, §5). +- 🟡 Источники — только сайт + телефон, SMS не трогаем (§4.2). +- 🟡 Объём: шаг 1 — до 15 конкурентов, шаг 2 — без потолка но с дедупом; всё + настраивается в админке (§6). +- 🟡 Цена — задаётся в админке, пока не задана и фича не включена — вкладка скрыта. + Я не выдумываю сумму (§5.4). + +--- + +## 1. Объём (scope) и принципы + +**Входит в v1:** +1. Вкладка «Автоподбор конкурентов» (пункт меню в группе «Работа», метка NEW). +2. Экран входа с блоком «Продолжить начатое» + две двери (авто-подбор / свой конкурент). +3. Форма авто-подбора (примеры конкурентов, «о себе», регион, федеральные). +4. Форма «свой конкурент» (+ экран подтверждения найденной по названию компании). +5. Фоновые платные прогоны: «подбор» (шаг 1) и «изучить» (шаг 2), с сохранением + состояния, идемпотентностью и списанием «только за успех». +6. Экран списка конкурентов с % похожести и поштучным прогрессом. +7. Экран источников конкурента (живое состояние, дедуп, «проект создан», «Изменить проект»). +8. Экран создания проектов (общие настройки + префлайт баланса) и экран «Готово». +9. Админ-настройки: вкл/выкл фичи и цены за оба шага. +10. «Розетка» движка: интерфейс агента + заглушка (`Fake`) на время отсутствия мозга. + +**НЕ входит в v1 (явно):** +- Реальный ИИ-движок с выходом в интернет (отдельная сессия). +- Уведомления по почте (только в портале — значок/тост; решение владельца). +- Рекомендатель источников поставщика BG (владелец велел не использовать). +- SMS как тип источника фичи (только сайт/звонок). +- Черновик частично заполненной формы создания (источники не теряются — этого хватает). + +**Сквозные принципы:** +- Деньги — чувствительная зона: списываем атомарно, идемпотентно, только за успех + (§5). Любая правда о деньгах сверяется billing-audit. +- Никаких дублей на трёх уровнях: конкуренты, источники, проекты клиента (§4.4). +- Все результаты агента — кандидаты; клиент проверяет по ссылкам-провенансам (§7.4). +- RLS: все новые таблицы — tenant-isolated (§4.1). + +--- + +## 2. Архитектура (карта частей) + +``` +Кабинет клиента (Vue) Backend (Laravel) Движок (розетка) +───────────────────── ───────────────── ──────────────── +AutopodborView.vue ───POST───► AutopodborController + ├ вход + история ├ старт прогона ──► AutopodborRunService + ├ формы (2 режима) │ (создаёт run, ставит job) + ├ список конкурентов │ + ├ источники ├ чтение run/result (опрос статуса) + └ создание проектов ──POST──► └ создание проектов ──► ProjectService (как сейчас) + + Очередь (Redis) CompetitorAgent (interface) ← §7 + │ ├ FakeCompetitorAgent (v1) + RunAutopodborSearchJob ──вызывает──► └ RealCompetitorAgent (потом) + RunAutopodborStudyJob ──вызывает──► + │ + пишет результат + при успехе списывает деньги (LedgerService-путь, §5) +``` + +**Единицы и их ответственность (для изоляции и тестируемости):** + +| Единица | Что делает | Зависит от | +|---|---|---| +| `AutopodborController` | HTTP: старт прогонов, чтение состояния, запуск создания проектов | RunService, ProjectService | +| `AutopodborRunService` | жизненный цикл прогона: создать run, гейт баланса, поставить job, идемпотентность (один in-flight на tenant) | models, BalancePreflightService | +| `RunAutopodborSearchJob` | фоновый шаг 1: дернуть агента, сохранить конкурентов, при успехе списать | CompetitorAgent, ChargeService | +| `RunAutopodborStudyJob` | фоновый шаг 2 (и manual-study): дернуть агента, сохранить источники, при успехе списать | CompetitorAgent, ChargeService | +| `CompetitorAgent` (iface) | «розетка»: поиск конкурентов + изучение одного + резолв по названию | — | +| `FakeCompetitorAgent` | v1-заглушка: заготовленные данные, без интернета | — | +| `AutopodborChargeService` | списание за прогон: атомарно, идемпотентно по run_id, новый тип проводки | LedgerService-паттерн | +| `AutopodborDedup` | нормализация и дедуп конкурентов/источников; «уже есть проект» | ProjectService::assertSourceUnique-логика | +| `ProjectService` (есть) | создание проектов из источников — БЕЗ изменений в ядре | — | + +Каждую единицу можно понять и протестировать отдельно; контракт между ними — +типизированные DTO (§7.2). + +--- + +## 3. Экраны и потоки (по прототипу) + +Нумерация экранов = id из прототипа. UX согласован; ниже — поведение и привязка к данным. + +### 3.1. Вход (`s-entry`) +- Блок **«Продолжить начатое»** — если есть незавершённые/оплаченные прогоны (см. §4.1 + `autopodbor_runs`). Каждая строка: регион, дата, найдено N, изучено M, проектов K, + «оплачено». Клик «Открыть» → в нужную точку (список или источники), БЕЗ повторной оплаты. + Если истории нет — блок не показываем. +- Две двери: «Подобрать конкурентов» (→ `s-autoform`), «Указать своего конкурента» + (→ `s-manualform`). Обе — первоклассные, не «запасной выход». + +### 3.2. Форма авто-подбора (`s-autoform`) +- **Ваши конкуренты** (обязательно, ≥1; подсказка «чем больше — тем точнее»): строки + «сайт или ссылка на справочник», кнопка «+ добавить конкурента». +- **О вашей компании** (по желанию): несколько сайтов, несколько справочников, описание + деятельности (textarea с подсказкой что описывать). Тон — деловой, уважительный. +- **Регион** — обязателен, ровно один (не мультивыбор). +- Галка **«включать федеральных игроков»** (работают в регионе клиента И в других). +- Кнопка «Подобрать конкурентов» → окно подтверждения цены (§5.3) → фоновый шаг 1 + (`s-loading` → `s-list`). + +### 3.3. Форма «свой конкурент» (`s-manualform`) + 🟡 подтверждение по названию +- Ввод: сайт/ссылка на справочник ИЛИ только название; регион (один, обязателен). +- Кнопка «Собрать источники» → подтверждение цены (§5.3). +- **Развилка (🟡 новое, дыра прототипа):** + - дан сайт/справочник → агент копает сразу → `s-loading` → `s-detail`. + - дано только название → СНАЧАЛА дешёвый резолв (`CompetitorAgent::resolveByName`, + бесплатно/в рамках того же прогона) → экран **«Мы нашли вот эту компанию — она?»** + (название, описание, сайт/справочник, провенанс) → клиент жмёт «Да, изучить» → ТОЛЬКО + тогда списываем за тяжёлый шаг и копаем источники. «Не та» → назад, без списания. + - Если по названию найдено несколько кандидатов — показываем список на выбор. + - Если не найдено никого — экран «не нашли» (§3.7), без списания. + +### 3.4. Список конкурентов (`s-list`) +- Заголовок «Найдено N конкурентов» + recap-пиллы (регион, федеральные, по скольким примерам). +- Сортировка: 100% → ниже (релевантность = похожесть на ПРИМЕРЫ клиента, не на регион). +- Карточка: название, метки регион/«федеральный», описание, ссылки (Сайт + Справочники), + число % похожести. + - **Изученный** конкурент → метка «✓ изучен» + «Открыть источники» (БЕСПЛАТНО). + - **Неизученный** → «Изучить подробнее» (платно) → подтверждение цены → шаг 2. +- Внизу: «Не нашли нужного? Указать вручную» → `s-manualform`. + +### 3.5. Источники конкурента (`s-detail`) +- Шапка: имя + метка региона + % похожести; «Изучено DD.MM · найдено K источников». +- Пояснение про настоящий/подменный + что состояние живое. +- Секции: **🌐 Сайты** (только головы доменов) и **📞 Телефоны** (метка + `настоящий` ✓ / `подменный · с сайта` 🎭). У каждого источника — чекбокс + провенанс-ссылка + «Где нашли». +- **Живое состояние (§4.4):** источник, по которому уже есть проект клиента → строка + заблокирована, метка «✓ проект создан», кнопка «Изменить проект» (→ `s-editproject`). +- «Чего-то не хватает? Добавить источник вручную» (сайт/номер). +- Нижняя панель: «Выбрано X из K», «Создать проекты →» (→ `s-create`). + +### 3.6. Создание проектов (`s-create`) и «Изменить проект» (`s-editproject`) +- Список «Будет создано N проектов»: слева тип+идентификатор источника (со значком ✓/🎭 + у телефонов), справа редактируемое поле **Название проекта** (автоимя = имя конкурента, + значок запекается в текст — см. §4.5). +- Блок «Настройки проектов»: общий регион (из подбора), общий лимит/день, дни приёма. + Применяются ко всем; после создания каждый правится в «Проектах». +- Прикидка стоимости запуска + строка баланса «хватает ✓». +- Кнопки: «Создать (без запуска)» и «Создать и запустить →» (с префлайтом баланса, §5.5). +- `s-editproject`: правка одного существующего проекта (имя/регион/лимит/дни) прямо из + источников, через тот же `PATCH /api/projects/{id}`. + +### 3.7. Состояния-исходы +- **Загрузка** (`s-loading`): спиннер + «можно закрыть вкладку, сохраним результат, + деньги — только за успех». Опрос статуса прогона по `GET` (§4.3). +- **Готово** (`s-done`): «N проектов создано/запущено», ссылки в «Проекты» и «В начало». +- 🟡 **Пусто/ошибка** (новый экран `s-empty`): «Ничего не нашли по этому запросу. Деньги + не списаны». Кнопки «Изменить запрос» / «В начало». Показывается, когда прогон + завершился `empty` или `failed` (§5.2). + +--- + +## 4. Данные + +### 4.1. Новые таблицы (все RLS tenant-isolated; миграции + запись в `db/CHANGELOG_schema.md`) + +**`autopodbor_runs`** — один платный прогон (или резолв по названию). +| колонка | тип | смысл | +|---|---|---| +| id | bigint PK | | +| tenant_id | bigint FK | RLS | +| kind | enum(`search`,`study`,`resolve`) | шаг 1 / шаг 2 / резолв по названию | +| status | enum(`queued`,`running`,`done`,`empty`,`failed`) | жизненный цикл | +| region_code | int (1..89) | регион подбора | +| params | jsonb | вход: примеры, «о себе», federal, имя/сайт конкурента | +| competitor_id | bigint FK null | для `study` — какого конкурента изучаем | +| price_rub_charged | numeric(12,2) null | сколько реально списали (только при `done`) | +| balance_transaction_id | bigint FK null | проводка списания (§5) | +| error_code | varchar null | для `failed` | +| created_at / started_at / finished_at | timestamptz | | + +**`autopodbor_competitors`** — конкуренты из прогона (авто-подбор + manual + resolve). +| колонка | тип | смысл | +|---|---|---| +| id, tenant_id | | RLS | +| search_run_id | bigint FK null | из какого подбора (null для «свой конкурент») | +| name | varchar | | +| description | text | | +| is_federal | bool | метка «федеральный» | +| relevance_pct | smallint null | похожесть на примеры (null для manual/resolve) | +| origin | enum(`auto`,`manual`,`resolve`) | как попал | +| site_url | varchar null | голова домена | +| directory_urls | jsonb | ссылки на 2ГИС/Яндекс.Карты | +| provenance | jsonb | где нашли (на уровне конкурента) | +| dedup_key | varchar | нормализованный ключ (для дедупа конкурентов) | +| study_run_id | bigint FK null | прогон изучения (если изучен) | +| studied_at | timestamptz null | изучен → открывается бесплатно | +| created_at | | | + +**`autopodbor_sources`** — источники конкурента (результат шага 2). +| колонка | тип | смысл | +|---|---|---| +| id, tenant_id | | RLS | +| competitor_id | bigint FK | | +| study_run_id | bigint FK | | +| signal_type | enum(`site`,`call`) | SMS не используется | +| identifier | varchar | нормализован: голова домена / `7xxxxxxxxxx` | +| phone_kind | enum(`real`,`substitute`) null | для телефона; null для сайта | +| provenance_url | varchar | ссылка «где нашли» | +| provenance_label | varchar | человекочитаемо («2ГИС — карточка», «футер сайта») | +| dedup_key | varchar | `signal_type` + нормализованный `identifier` | +| created_project_id | bigint FK null | если по источнику создан проект (живое состояние) | +| created_at | | | + +Дедуп внутри прогона — UNIQUE `(competitor_id, dedup_key)`; конкуренты — +UNIQUE `(tenant_id, COALESCE(search_run_id,0), dedup_key)`. + +### 4.2. Источники = только сайт + телефон (🟡) +SMS как тип источника фичи не поддерживаем (notes 6,7). `signal_type` источника ∈ +{`site`,`call`}. На создании проектов это маппится на существующие site/call. + +### 4.3. Опрос статуса +`GET /api/autopodbor/runs/{id}` → `{status, ...}`. Фронт на `s-loading` опрашивает раз в +~2–3 c до `done`/`empty`/`failed`. Возврат на вкладку позже — состояние из БД, не из памяти. + +### 4.4. Дедуп на трёх уровнях (жёсткое требование, заострено дважды) +1. **Конкуренты:** `AutopodborDedup` нормализует имя/домен → `dedup_key`; не показываем + дубль и не показываем уже изученного/заведённого повторно. +2. **Источники:** нормализация (голова домена; телефон `7xxxxxxxxxx`) → дедуп внутри + конкурента и между конкурентами одного прогона. +3. **Проекты клиента (живая проверка при КАЖДОМ показе `s-detail`):** для каждого источника + запрос к `projects` по `(tenant_id, signal_type, signal_identifier)` — та же логика, + что в `ProjectService::assertSourceUnique()`. Есть проект → метка «✓ проект создан», + выбор заблокирован, кнопка «Изменить проект». Это не stale-снимок: проверяется на лету. + +### 4.5. Имя проекта и значок (notes 6k) +- Автоимя проекта = **имя конкурента** (без источника в названии). +- Значок типа телефона запекается в текст имени: «Окна Комфорт ✓» / «Окна Комфорт 🎭». + Для сайта значка нет. +- **Уникальность имени** (`assertNameUnique`, 422 при дубле): несколько источников одного + конкурента дадут одинаковые имена. Решение: при создании пачки добавлять тихий + различитель — числовой суффикс « 2», « 3»… по факту коллизии (проверять перед вставкой, + не ломая UX). Клиент потом переименует. +- **Эмодзи поставщику:** имя реально уходит в `SupplierProjectDto.name` → + `manage-project.js` (field `name`). Эмодзи может не пройти кодировку BG. Поэтому: + значок держим ТОЛЬКО на стороне Лидерры (показ в «Проектах»), а в DTO имени — + `strip` эмодзи перед отправкой. Хвост-проверка: принимает ли BG юникод (§9). + +--- + +## 5. Деньги и биллинг (зона billing-audit) + +### 5.1. Модель «деньги только за успех» +Платные прогоны — только `search` (шаг 1) и `study` (шаг 2/manual-study). Прогон +`resolve` (резолв конкурента по названию) — **бесплатный** (`price_rub_charged` всегда +null); деньги за изучение берём отдельным `study`-прогоном уже после подтверждения +найденной компании (§3.3). В коде НЕТ механизма резерва/hold (есть только прямое +списание `LedgerService::chargeForDelivery`). Поэтому для платных прогонов: +- При старте прогона деньги **НЕ списываются**. Прогон создаётся в статусе + `queued`→`running`; для клиента это «в работе» (UX-«резерв»). +- **Гейт перед стартом:** проверяем, что баланса хватает на цену шага (read-only). Не + хватает → не стартуем, показываем «недостаточно средств» (без списания). +- **Списание — атомарно и только при `done`** (есть непустой результат): внутри + `DB::transaction` с `lockForUpdate` по tenant — по образцу `LedgerService`: + пишем `BalanceTransaction` нового типа + проставляем `autopodbor_runs.price_rub_charged` + и `balance_transaction_id`. +- `empty`/`failed`/timeout → **ничего не списываем** (UX-«резерв» снят). Без «возвратов». + +### 5.2. Идемпотентность (защита от двойного списания) +- **Один in-flight прогон на tenant** (notes 6b.3): `AutopodborRunService` не стартует + второй `queued/running` прогон того же `kind` для tenant; кнопка на фронте заблокирована; + refresh не плодит второй прогон. +- Списание идемпотентно **по `run_id`**: если у прогона уже есть `balance_transaction_id` + — повторное списание невозможно (проверка внутри транзакции). Это исключает гонку и + двойной debit при ретрае джобы. +- Поскольку in-flight один и debit — в конце, риск конкурентного овердрафта ограничен + единственным прогоном. (Альтернатива — настоящий hold-резерв в леджере — отложена; + при росте нагрузки вернуться. Помечено для billing-audit.) + +### 5.3. Новый тип проводки +`BalanceTransaction` получает тип `TYPE_AUTOPODBOR_CHARGE = 'autopodbor_charge'` +(`amount_rub` отрицательный, `related_type`/`related_id` → `autopodbor_runs`). Добавить в +константы модели + в любые отчёты/фильтры по типам. Запись в `db/CHANGELOG_schema.md`, +ревью billing-audit на money-инварианты (сумма проводок = изменение баланса). + +### 5.4. Цена и вкл/выкл — через `system_settings` (🟡) +Паттерн как у `billing_yookassa_enabled`. Ключи: +- `autopodbor_enabled` (bool, default `false`) — пока выключено, вкладка/пункт меню скрыты. +- `autopodbor_price_search_rub` (decimal) — цена шага 1. +- `autopodbor_price_study_rub` (decimal) — цена шага 2 (и manual-study). +Чтение — `SystemSettings::get/bool`. Редактирование — существующий +`AdminSystemSettingsController` + `SystemSettingEditDialog.vue` (с reason ≥30 симв. и +аудит-логом). Конкретные суммы задаёт владелец в админке — в документе НЕ выдумываем. +Окно подтверждения цены (§3.2/§3.3) показывает актуальную цену из настроек. + +### 5.5. Создание проектов и префлайт +Создание самих проектов — **бесплатно** (как сейчас). «Создать и запустить» гоняет +существующий `BalancePreflightService`; при нехватке — 409 `balance_insufficient` и +существующий `ProjectLimitOverloadDialog` (переиспользуем из `NewProjectDialog.vue`). + +--- + +## 6. Объём и настройки агента (🟡) +- Шаг 1: до **15** конкурентов на выдаче (настраиваемо: `autopodbor_max_competitors`). +- Шаг 2: без жёсткого потолка источников, но с дедупом; разумный технический предел в + реализации движка. +- Эти числа — параметры запроса к агенту (§7.2), не зашиты в UI. + +--- + +## 7. «Розетка» движка (контракт для будущего мозга) + +### 7.1. Интерфейс +```php +interface CompetitorAgent +{ + public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult; // шаг 1 + public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult; // шаг 2 + public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult; // manual по названию +} +``` +Привязка в контейнере: v1 → `FakeCompetitorAgent`, потом → `RealCompetitorAgent` +(HTTP API + ключ). Джобы и сервисы зависят ТОЛЬКО от интерфейса — смена движка не трогает +экраны, данные и деньги. + +### 7.2. DTO (контракт вход/выход) — кратко +- `FindCompetitorsRequest`: `region_code`, `examples[]` (сайт/справочник), + `about_self{sites[],directories[],description}`, `include_federal`, `max_competitors`. +- `FindCompetitorsResult`: `competitors[]{name, description, is_federal, relevance_pct, + site_url, directory_urls[], provenance}`. +- `StudyCompetitorRequest`: `competitor{name, site_url?, directory_urls[]?}`, `region_code`. +- `StudyCompetitorResult`: `sources[]{signal_type, identifier, phone_kind?, provenance_url, + provenance_label}`. +- `ResolveByNameRequest`: `name`, `region_code`. +- `ResolveByNameResult`: `candidates[]{name, description, site_url?, directory_urls[]?, + provenance}` (0, 1 или несколько). + +Нормализация (голова домена, телефон `7xxxxxxxxxx`, различение настоящий/подменный по +методу из notes 6f) — на стороне backend (`AutopodborDedup`/нормалайзер), НЕ внутри +движка, чтобы заглушка и реальный движок были взаимозаменяемы и единообразны. + +### 7.3. Заглушка `FakeCompetitorAgent` (v1) +Возвращает заготовленные данные (как в прототипе: Окна Комфорт 100%, Пластика Окон 96% и +т.д.), с искусственной задержкой, чтобы прогнать весь поток, деньги, сохранение и UI +по-настоящему. Под фиче-флагом/конфигом выбора реализации. + +### 7.4. Доверие и риск «выдумал» +Результаты — кандидаты. Защита: каждый конкурент и источник несёт провенанс-ссылку +«где нашли»; клиент проверяет и сам выбирает; «уже есть проект» помечается; ничего не +создаётся без явного выбора клиента. Никаких авто-действий от имени агента. + +--- + +## 8. Швы (отвлёкся / закрыл / вернулся) — по notes 6b/6i/6j +- Фоновый прогон не прерывается закрытием вкладки; возврат → «идёт…» или готовый результат. +- История в `autopodbor_runs`/`competitors`/`sources` → «Продолжить начатое» (§3.1). +- Поштучный прогресс: изученный конкурент открывается БЕЗ оплаты; неизученный — платно. +- Живое состояние источников при каждом заходе (§4.4); «Изменить проект» прямо оттуда. +- Уведомления — только в портале (значок/тост), без почты. + +--- + +## 9. Хвосты реализации (трекать в плане) +1. **Уникальность имени проекта** при пачке одинаковых имён → тихий числовой суффикс (§4.5). +2. **Эмодзи поставщику** → strip перед `SupplierProjectDto.name`; проверить, принимает ли + BG юникод (если принимает — можно не strip-ать). До проверки — strip по умолчанию. +3. **Реальный движок** — отдельная сессия; вставляется в розетку §7 без изменений вокруг. +4. **Настоящий hold-резерв в леджере** — отложен; вернуться при росте нагрузки (§5.2). +5. **`manage-project.js` GAPS** (workdays/regions игнор) — существующее ограничение синка, + фича его не усугубляет; учесть при ожиданиях «дни/регионы у поставщика». + +--- + +## 10. Тестирование +- **Backend (Pest):** RunService (идемпотентность — один in-flight, нет двойного debit); + ChargeService (списание только при `done`, атомарность, новый тип проводки, money-инвариант); + дедуп (конкуренты/источники/«уже есть проект»); нормализация домена/телефона; маппинг + источник→ProjectService::create; суффикс уникального имени; strip эмодзи в DTO. + Все тесты с записью через supplier-PDO — `uses(SharesSupplierPdo::class)` (memory-правило). +- **Контракт движка:** `FakeCompetitorAgent` + тесты, что поток целиком работает на заглушке. +- **RLS:** новые таблицы — `rls-reviewer` + проверки cross-tenant изоляции. +- **Frontend (Vitest):** состояния `s-loading`/`empty`/`done`; блокировка «уже есть проект»; + подтверждение по названию (🟡); подтверждение цены; префлайт-409 переиспользование. +- **Деньги:** прогон billing-audit перед мержем. +- **UI-smoke:** прокликать поток на заглушке (Playwright), сверить с прототипом. + +--- + +## 11. Открытые вопросы к владельцу (помечены 🟡 в тексте) +1. Подтверждение найденной по названию компании ДО оплаты (§3.3) — ок? +2. Экран «ничего не нашли», деньги не списываем (§3.7) — ок? +3. Источники только сайт+телефон, без SMS (§4.2) — ок? +4. Объём шага 1 = до 15 конкурентов, настраиваемо (§6) — ок? +5. Цена/вкл-выкл через админку, без выдуманных сумм (§5.4) — ок? + +После «ок» по этим пятёрке и по документу в целом — перехожу к skill `writing-plans` +(план реализации). Код до утверждённого плана не пишу. From 977abf71f9ea0b5fe3e9bce0d0da8d58a6bd2013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 12:22:07 +0300 Subject: [PATCH 006/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20autopodbor=5Fruns=20+=20RLS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26_06_28_100000_create_autopodbor_runs.php | 40 +++++++++++++++++++ .../Autopodbor/AutopodborRunsSchemaTest.php | 12 ++++++ 2 files changed, 52 insertions(+) create mode 100644 app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborRunsSchemaTest.php diff --git a/app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php b/app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php new file mode 100644 index 00000000..6cc8bd6b --- /dev/null +++ b/app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php @@ -0,0 +1,40 @@ +bigIncrements('id'); + $table->unsignedBigInteger('tenant_id'); + $table->string('kind', 16); // search | study | resolve + $table->string('status', 16)->default('queued'); // queued|running|done|empty|failed + $table->smallInteger('region_code')->nullable(); + $table->jsonb('params')->default(DB::raw("'{}'::jsonb")); + $table->unsignedBigInteger('competitor_id')->nullable(); + $table->decimal('price_rub_charged', 12, 2)->nullable(); + $table->unsignedBigInteger('balance_transaction_id')->nullable(); + $table->string('error_code', 64)->nullable(); + $table->timestampTz('created_at')->useCurrent(); + $table->timestampTz('started_at')->nullable(); + $table->timestampTz('finished_at')->nullable(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'kind', 'status']); + }); + + DB::statement('ALTER TABLE autopodbor_runs ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE autopodbor_runs FORCE ROW LEVEL SECURITY'); + DB::statement("CREATE POLICY tenant_isolation ON autopodbor_runs USING (tenant_id = current_setting('app.current_tenant_id')::bigint)"); + } + + public function down(): void + { + Schema::dropIfExists('autopodbor_runs'); + } +}; diff --git a/app/tests/Feature/Autopodbor/AutopodborRunsSchemaTest.php b/app/tests/Feature/Autopodbor/AutopodborRunsSchemaTest.php new file mode 100644 index 00000000..9dd23e63 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborRunsSchemaTest.php @@ -0,0 +1,12 @@ +hasTable('autopodbor_runs'))->toBeTrue(); + expect(DB::getSchemaBuilder()->hasColumns('autopodbor_runs', [ + 'id', 'tenant_id', 'kind', 'status', 'region_code', 'params', + 'competitor_id', 'price_rub_charged', 'balance_transaction_id', + 'error_code', 'created_at', 'started_at', 'finished_at', + ]))->toBeTrue(); +}); From f8ced2e902d088ce744232c56ff2e490d2d4f3ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 12:49:52 +0300 Subject: [PATCH 007/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20autopodbor=5Fcompetitors=20+=20RLS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...8_100100_create_autopodbor_competitors.php | 43 +++++++++++++++++++ .../AutopodborCompetitorsSchemaTest.php | 11 +++++ 2 files changed, 54 insertions(+) create mode 100644 app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborCompetitorsSchemaTest.php diff --git a/app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php b/app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php new file mode 100644 index 00000000..610df0ed --- /dev/null +++ b/app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php @@ -0,0 +1,43 @@ +bigIncrements('id'); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('search_run_id')->nullable(); + $table->string('name', 255); + $table->text('description')->nullable(); + $table->boolean('is_federal')->default(false); + $table->smallInteger('relevance_pct')->nullable(); + $table->string('origin', 16)->default('auto'); // auto|manual|resolve + $table->string('site_url', 255)->nullable(); + $table->jsonb('directory_urls')->default(DB::raw("'[]'::jsonb")); + $table->jsonb('provenance')->default(DB::raw("'{}'::jsonb")); + $table->string('dedup_key', 255); + $table->unsignedBigInteger('study_run_id')->nullable(); + $table->timestampTz('studied_at')->nullable(); + $table->timestampTz('created_at')->useCurrent(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('search_run_id')->references('id')->on('autopodbor_runs')->nullOnDelete(); + $table->foreign('study_run_id')->references('id')->on('autopodbor_runs')->nullOnDelete(); + $table->index(['tenant_id', 'search_run_id']); + $table->unique(['tenant_id', 'search_run_id', 'dedup_key'], 'autopodbor_competitor_dedup'); + }); + + DB::statement('ALTER TABLE autopodbor_competitors ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE autopodbor_competitors FORCE ROW LEVEL SECURITY'); + DB::statement("CREATE POLICY tenant_isolation ON autopodbor_competitors USING (tenant_id = current_setting('app.current_tenant_id')::bigint)"); + } + + public function down(): void + { + Schema::dropIfExists('autopodbor_competitors'); + } +}; diff --git a/app/tests/Feature/Autopodbor/AutopodborCompetitorsSchemaTest.php b/app/tests/Feature/Autopodbor/AutopodborCompetitorsSchemaTest.php new file mode 100644 index 00000000..6b82a156 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborCompetitorsSchemaTest.php @@ -0,0 +1,11 @@ +hasTable('autopodbor_competitors'))->toBeTrue(); + expect(DB::getSchemaBuilder()->hasColumns('autopodbor_competitors', [ + 'id','tenant_id','search_run_id','name','description','is_federal', + 'relevance_pct','origin','site_url','directory_urls','provenance', + 'dedup_key','study_run_id','studied_at','created_at', + ]))->toBeTrue(); +}); From b09b95dd658343bfbc046e3f279c228ae44e4c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 12:52:37 +0300 Subject: [PATCH 008/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20autopodbor=5Fsources=20+=20RLS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...06_28_100200_create_autopodbor_sources.php | 41 +++++++++++++++++++ .../AutopodborSourcesSchemaTest.php | 10 +++++ 2 files changed, 51 insertions(+) create mode 100644 app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborSourcesSchemaTest.php diff --git a/app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php b/app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php new file mode 100644 index 00000000..f5108260 --- /dev/null +++ b/app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php @@ -0,0 +1,41 @@ +bigIncrements('id'); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('competitor_id'); + $table->unsignedBigInteger('study_run_id'); + $table->string('signal_type', 8); // site | call + $table->string('identifier', 255); // голова домена / 7xxxxxxxxxx + $table->string('phone_kind', 12)->nullable(); // real | substitute | null(site) + $table->string('provenance_url', 500)->nullable(); + $table->string('provenance_label', 255)->nullable(); + $table->string('dedup_key', 255); + $table->unsignedBigInteger('created_project_id')->nullable(); + $table->timestampTz('created_at')->useCurrent(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('competitor_id')->references('id')->on('autopodbor_competitors')->cascadeOnDelete(); + $table->foreign('study_run_id')->references('id')->on('autopodbor_runs')->cascadeOnDelete(); + $table->foreign('created_project_id')->references('id')->on('projects')->nullOnDelete(); + $table->unique(['competitor_id', 'dedup_key'], 'autopodbor_source_dedup'); + $table->index(['tenant_id', 'competitor_id']); + }); + + DB::statement('ALTER TABLE autopodbor_sources ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE autopodbor_sources FORCE ROW LEVEL SECURITY'); + DB::statement("CREATE POLICY tenant_isolation ON autopodbor_sources USING (tenant_id = current_setting('app.current_tenant_id')::bigint)"); + } + + public function down(): void + { + Schema::dropIfExists('autopodbor_sources'); + } +}; diff --git a/app/tests/Feature/Autopodbor/AutopodborSourcesSchemaTest.php b/app/tests/Feature/Autopodbor/AutopodborSourcesSchemaTest.php new file mode 100644 index 00000000..b427bccf --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborSourcesSchemaTest.php @@ -0,0 +1,10 @@ +hasTable('autopodbor_sources'))->toBeTrue(); + expect(DB::getSchemaBuilder()->hasColumns('autopodbor_sources', [ + 'id','tenant_id','competitor_id','study_run_id','signal_type','identifier', + 'phone_kind','provenance_url','provenance_label','dedup_key','created_project_id','created_at', + ]))->toBeTrue(); +}); From 94043ea6ed7ad34ae254156adc4707b5f6e5ea11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 12:58:19 +0300 Subject: [PATCH 009/417] =?UTF-8?q?test(=D1=84=D1=80=D0=BE=D0=BD=D1=82):?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=D0=B2=D1=91=D0=BB=20=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=20=D0=B2=20=D0=B7=D0=B5=D0=BB=D1=91=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=E2=80=94=2010=20=D0=BF=D1=80=D0=BE=D1=82=D1=83=D1=85?= =?UTF-8?q?=D1=88=D0=B8=D1=85=20=D1=81=D0=BF=D0=B5=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=20=D0=B0=D0=BA=D1=82=D1=83=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Все падения — устаревшие ожидания тестов (компоненты менялись намеренно): SettingsView (роутер+вкладка Реквизиты+события), LegalDoc (реальные доки под ЮKassa), ProjectsView (BulkActionsBar v-show→isVisible), ErrorView (убран фейк REQ/INC), PricingTiers (формат «500 ₽»), KanbanCard (costKopecks→«—»), ChangePassword (дата из API), DealDetail (русские ярлыки статусов), DealsView (RuDateField на v-menu), SupplierIntegration (window.confirm→v-dialog). Изменены ТОЛЬКО тесты, компоненты не тронуты. Полный прогон: 127 файлов / 992 теста зелёные. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Frontend/AdminPricingTiersView.spec.ts | 5 ++-- ...pplierIntegrationView.manual-queue.spec.ts | 24 ++++++++++++++++--- app/tests/Frontend/ChangePasswordCard.spec.ts | 5 ++-- .../Frontend/DealDetailDrawerApi.spec.ts | 4 ++-- app/tests/Frontend/DealsView.spec.ts | 8 +++++-- app/tests/Frontend/ErrorView.spec.ts | 16 ++++++------- app/tests/Frontend/KanbanCard.spec.ts | 9 ++++++- app/tests/Frontend/LegalDocView.spec.ts | 24 +++++++++++-------- app/tests/Frontend/ProjectsView.spec.ts | 13 ++++++---- app/tests/Frontend/SettingsView.spec.ts | 18 ++++++++++---- docs/observer/STATUS.md | 8 +++---- 11 files changed, 89 insertions(+), 45 deletions(-) diff --git a/app/tests/Frontend/AdminPricingTiersView.spec.ts b/app/tests/Frontend/AdminPricingTiersView.spec.ts index 30fa9fa1..183ba5ff 100644 --- a/app/tests/Frontend/AdminPricingTiersView.spec.ts +++ b/app/tests/Frontend/AdminPricingTiersView.spec.ts @@ -51,8 +51,9 @@ describe('AdminPricingTiersView', () => { it('renders 7 tier rows from /api/admin/pricing-tiers', async () => { const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } }); await new Promise((r) => setTimeout(r, 50)); - expect(wrapper.text()).toContain('500.00'); - expect(wrapper.text()).toContain('250.00'); + // fmtRub форматирует как «500 ₽» (целое, без хвостовых нулей), не «500.00». + expect(wrapper.text()).toContain('500 ₽'); + expect(wrapper.text()).toContain('250 ₽'); }); it('shows "все свыше" for tier 7 with leads_in_tier=null', async () => { diff --git a/app/tests/Frontend/AdminSupplierIntegrationView.manual-queue.spec.ts b/app/tests/Frontend/AdminSupplierIntegrationView.manual-queue.spec.ts index d7b6265c..0efec2d6 100644 --- a/app/tests/Frontend/AdminSupplierIntegrationView.manual-queue.spec.ts +++ b/app/tests/Frontend/AdminSupplierIntegrationView.manual-queue.spec.ts @@ -44,18 +44,36 @@ describe('AdminSupplierIntegrationView — manual queue section', () => { expect(text).toContain('B1'); }); - it('clicking «Отметить выполнено» calls resolve endpoint', async () => { + it('clicking «Отметить выполнено» → подтверждение в диалоге → calls resolve endpoint', async () => { (axios.post as ReturnType).mockResolvedValue({ data: { resolved: true, external_id: 700123 }, }); - vi.spyOn(window, 'confirm').mockReturnValue(true); - const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } }); + // window.confirm заменён на v-dialog (UI-аудит). Стабим VDialog passthrough, + // чтобы контент диалога рендерился инлайн и кнопка «Подтверждаю» была кликабельна. + const wrapper = mount(AdminSupplierIntegrationView, { + global: { + plugins: [vuetify], + stubs: { + VDialog: { + template: '
', + props: ['modelValue'], + }, + }, + }, + }); await new Promise((r) => setTimeout(r, 50)); + // Клик по «Отметить выполнено» открывает диалог подтверждения (askResolve). const btn = wrapper.find('[data-testid="resolve-1"]'); expect(btn.exists()).toBe(true); await btn.trigger('click'); + await wrapper.vm.$nextTick(); + + // Подтверждаем в диалоге → doResolve → POST. + const confirmBtn = wrapper.findAll('button').find((b) => b.text().includes('Подтверждаю')); + expect(confirmBtn).toBeTruthy(); + await confirmBtn!.trigger('click'); expect(axios.post).toHaveBeenCalledWith(expect.stringContaining('/manual-queue/1/resolve')); }); diff --git a/app/tests/Frontend/ChangePasswordCard.spec.ts b/app/tests/Frontend/ChangePasswordCard.spec.ts index 8528ef80..f30b608d 100644 --- a/app/tests/Frontend/ChangePasswordCard.spec.ts +++ b/app/tests/Frontend/ChangePasswordCard.spec.ts @@ -15,9 +15,10 @@ describe('ChangePasswordCard (Q.DEFER.003 sub-B)', () => { }); it('shows last-change hint text', () => { + // Дата берётся из GET /api/account/security; без backend (в тесте) — честное + // «не менялся» (хардкод-демо «12.04.2026» убран намеренно, не показываем фейк). const wrapper = factory(); - expect(wrapper.text()).toContain('Последняя смена: 12.04.2026'); - expect(wrapper.text()).toContain('26 дней назад'); + expect(wrapper.text()).toContain('Последняя смена: не менялся'); }); it('renders «Сменить пароль» button with lock-reset icon', () => { diff --git a/app/tests/Frontend/DealDetailDrawerApi.spec.ts b/app/tests/Frontend/DealDetailDrawerApi.spec.ts index c1ce64a1..689f5433 100644 --- a/app/tests/Frontend/DealDetailDrawerApi.spec.ts +++ b/app/tests/Frontend/DealDetailDrawerApi.spec.ts @@ -88,8 +88,8 @@ describe('DealDetailBody ↔ GET /api/deals/{id} integration', () => { expect(dealsApi.getDeal).toHaveBeenCalledWith(MOCK_DEALS[0].id, 1); const items = wrapper.findAll('.timeline-item'); expect(items).toHaveLength(2); - // status_changed event имеет detail "new → won". - expect(wrapper.text()).toContain('new → won'); + // status_changed мапит слаги в русские ярлыки воронки: new→«Новая сделка», won→«Сделка». + expect(wrapper.text()).toContain('Новая сделка → Сделка'); }); it('getDeal reject → eventsFetchError=true, alert виден, events пуст (I3)', async () => { diff --git a/app/tests/Frontend/DealsView.spec.ts b/app/tests/Frontend/DealsView.spec.ts index a67613cc..45bf40d6 100644 --- a/app/tests/Frontend/DealsView.spec.ts +++ b/app/tests/Frontend/DealsView.spec.ts @@ -57,8 +57,12 @@ describe('DealsView.vue — реестр лидов', () => { it('панель экспорта: поля дат + кнопки Excel/CSV', async () => { const w = await mountDeals(); - expect(w.find('[data-testid="export-from"]').exists()).toBe(true); - expect(w.find('[data-testid="export-to"]').exists()).toBe(true); + const panel = w.find('.export-panel'); + expect(panel.exists()).toBe(true); + // 2 поля даты: RuDateField построен на v-menu, поэтому data-testid не доходит + // до DOM-элемента — проверяем по input'ам активаторов (v-text-field) внутри панели. + expect(panel.findAll('input').length).toBeGreaterThanOrEqual(2); + // Кнопки — v-btn, data-testid доходит до корня button. expect(w.find('[data-testid="export-xlsx-btn"]').exists()).toBe(true); expect(w.find('[data-testid="export-csv-btn"]').exists()).toBe(true); }); diff --git a/app/tests/Frontend/ErrorView.spec.ts b/app/tests/Frontend/ErrorView.spec.ts index 18b17059..5cc485a2 100644 --- a/app/tests/Frontend/ErrorView.spec.ts +++ b/app/tests/Frontend/ErrorView.spec.ts @@ -38,25 +38,23 @@ describe('ErrorView.vue', () => { expect(text).toContain('Все рабочие экраны Лидерра доступны через дашборд'); }); - it('errorCode=403 показывает «403 / У вас нет доступа» + RequestId', async () => { + it('errorCode=403 показывает «403 / У вас нет доступа» (фейк-RequestId убран)', async () => { const wrapper = await mountErrorView('403'); const text = wrapper.text(); expect(wrapper.find('.err-code').text()).toBe('403'); expect(text).toContain('У вас нет доступа'); - expect(text).toContain('REQ-3F8A2-0007'); - expect(text).toContain('Запрос'); + // Хардкод «REQ-3F8A2-0007» убран намеренно (не показываем фейк как настоящее). + expect(text).not.toContain('REQ-3F8A2-0007'); }); - it('errorCode=500 показывает «500 / Что-то пошло не так» + IncidentId + status-list', async () => { + it('errorCode=500 показывает «500 / Что-то пошло не так» (фейк-Incident/status-list убраны)', async () => { const wrapper = await mountErrorView('500'); const text = wrapper.text(); expect(wrapper.find('.err-code').text()).toBe('500'); expect(text).toContain('Что-то пошло не так'); - expect(text).toContain('INC-2026-0507-0034'); - expect(text).toContain('Инцидент'); - // status-list только на 500. - expect(text).toContain('API · OK'); - expect(text).toContain('Telegram · деградация'); + // Хардкод «INC-2026-0507-0034» + фейк-список статусов убраны намеренно. + expect(text).not.toContain('INC-2026-0507-0034'); + expect(text).not.toContain('Telegram · деградация'); }); it('404 содержит «На дашборд» primary + «Назад» secondary', async () => { diff --git a/app/tests/Frontend/KanbanCard.spec.ts b/app/tests/Frontend/KanbanCard.spec.ts index 5a12896a..bb25b1a5 100644 --- a/app/tests/Frontend/KanbanCard.spec.ts +++ b/app/tests/Frontend/KanbanCard.spec.ts @@ -12,7 +12,9 @@ describe('KanbanCard.vue', () => { }); it('рендерит имя, телефон, проект и стоимость', () => { - const wrapper = factory(MOCK_DEALS[0]); + // Карточка показывает ФАКТ списания (costKopecks), а не legacy cost; при отсутствии + // списания — «—». Формат проверяем на сделке с costKopecks=185000 → «1 850 ₽». + const wrapper = factory({ ...MOCK_DEALS[0], costKopecks: 185000 }); const text = wrapper.text(); expect(text).toContain('Анна Соколова'); expect(text).toContain('+7 (916) 871-23-45'); @@ -20,6 +22,11 @@ describe('KanbanCard.vue', () => { expect(text).toMatch(/1\s+850\s*₽/); }); + it('показывает «—» в стоимости когда списания ещё не было (costKopecks=null)', () => { + const wrapper = factory({ ...MOCK_DEALS[0], costKopecks: null }); + expect(wrapper.find('.card-cost').text()).toBe('—'); + }); + it('показывает initials менеджера', () => { const wrapper = factory(MOCK_DEALS[0]); expect(wrapper.text()).toContain('ИП'); diff --git a/app/tests/Frontend/LegalDocView.spec.ts b/app/tests/Frontend/LegalDocView.spec.ts index bc43914f..d5dc68b7 100644 --- a/app/tests/Frontend/LegalDocView.spec.ts +++ b/app/tests/Frontend/LegalDocView.spec.ts @@ -24,9 +24,9 @@ const mountAt = async (path: string) => { }; describe('LegalDocView.vue', () => { - it('рендерит «Договор-оферта» на /legal/offer', async () => { + it('рендерит «Публичная оферта» на /legal/offer', async () => { const wrapper = await mountAt('/legal/offer'); - expect(wrapper.text()).toContain('Договор-оферта'); + expect(wrapper.text()).toContain('Публичная оферта'); }); it('рендерит «Политика конфиденциальности» на /legal/privacy', async () => { @@ -34,17 +34,21 @@ describe('LegalDocView.vue', () => { expect(wrapper.text()).toContain('Политика конфиденциальности'); }); - it('показывает честную заглушку «документ готовится», а не фейк-текст', async () => { + it('показывает реальный текст оферты (рабочая редакция под ЮKassa), а не заглушку', async () => { const wrapper = await mountAt('/legal/offer'); - const notice = wrapper.find('[data-testid="legal-stub-notice"]'); - expect(notice.exists()).toBe(true); - expect(notice.text()).toContain('готовится'); + const text = wrapper.text(); + // Реальные разделы + дата редакции из content/legalDocs.ts. + expect(text).toContain('Предмет'); + expect(text).toContain('Реквизиты Исполнителя'); + expect(text).toContain('Редакция от 2026-06-24'); + expect(text).not.toContain('готовится'); }); - it('содержит ссылку возврата ко входу', async () => { + it('политика конфиденциальности содержит реальные разделы (оператор/права субъекта)', async () => { const wrapper = await mountAt('/legal/privacy'); - const back = wrapper.find('a.legal-back'); - expect(back.exists()).toBe(true); - expect(back.attributes('href')).toBe('/login'); + const text = wrapper.text(); + expect(text).toContain('Оператор'); + expect(text).toContain('Права субъекта'); + expect(text).toContain('Редакция от 2026-06-24'); }); }); diff --git a/app/tests/Frontend/ProjectsView.spec.ts b/app/tests/Frontend/ProjectsView.spec.ts index 101e30eb..c44da64c 100644 --- a/app/tests/Frontend/ProjectsView.spec.ts +++ b/app/tests/Frontend/ProjectsView.spec.ts @@ -104,7 +104,7 @@ describe('ProjectsView', () => { cards[0].vm.$emit('toggle-select', 1); cards[1].vm.$emit('toggle-select', 2); await wrapper.vm.$nextTick(); - expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(true); + expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(true); }); }); @@ -155,7 +155,8 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => { expect(drawer.exists()).toBe(true); expect(drawer.classes()).not.toContain('open'); // BulkActionsBar uses v-if; should not exist. - expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(false); + // BulkActionsBar теперь v-show (всегда смонтирован, скрыт display:none) → проверяем видимость. + expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(false); // .has-drawer class should not be on the view root. expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer'); }); @@ -173,7 +174,8 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => { expect(drawer.exists()).toBe(true); expect(drawer.classes()).toContain('open'); // BulkActionsBar should NOT exist (size < 2). - expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(false); + // BulkActionsBar теперь v-show (всегда смонтирован, скрыт display:none) → проверяем видимость. + expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(false); // .has-drawer class should be present. expect(wrapper.find('.projects-view').classes()).toContain('has-drawer'); }); @@ -192,7 +194,7 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => { expect(drawer.exists()).toBe(true); expect(drawer.classes()).not.toContain('open'); // BulkActionsBar should exist (size >= 2). - expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(true); + expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(true); // .has-drawer class should NOT be present. expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer'); }); @@ -215,7 +217,8 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => { // Both should be hidden now. const drawerAfter = wrapper.find('aside.project-details-drawer'); expect(drawerAfter.classes()).not.toContain('open'); - expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(false); + // BulkActionsBar теперь v-show (всегда смонтирован, скрыт display:none) → проверяем видимость. + expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(false); expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer'); }); diff --git a/app/tests/Frontend/SettingsView.spec.ts b/app/tests/Frontend/SettingsView.spec.ts index ebca153d..6f806920 100644 --- a/app/tests/Frontend/SettingsView.spec.ts +++ b/app/tests/Frontend/SettingsView.spec.ts @@ -2,12 +2,20 @@ import { describe, it, expect } from 'vitest'; import { mount } from '@vue/test-utils'; import { createPinia } from 'pinia'; import { createVuetify } from 'vuetify'; +import { createRouter, createMemoryHistory } from 'vue-router'; import SettingsView from '../../resources/js/views/SettingsView.vue'; +// SettingsView читает route.query.tab (deep-link вкладки) через useRoute(), +// поэтому компоненту нужен router-контекст в тестах. +const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/settings', name: 'settings', component: SettingsView }], +}); + describe('SettingsView.vue', () => { const factory = () => mount(SettingsView, { - global: { plugins: [createPinia(), createVuetify()] }, + global: { plugins: [createPinia(), createVuetify(), router] }, }); it('монтируется и содержит заголовок «Настройки»', () => { @@ -15,10 +23,10 @@ describe('SettingsView.vue', () => { expect(wrapper.find('h1').text()).toBe('Настройки'); }); - it('содержит ровно 4 nav-tabs (placeholder-вкладки убраны, audit D6/D7)', () => { + it('содержит ровно 5 nav-tabs (Профиль/Реквизиты/Безопасность/API/Уведомления)', () => { const wrapper = factory(); const items = wrapper.findAll('.tabs-rail .v-list-item'); - expect(items.length).toBe(4); + expect(items.length).toBe(5); }); it('содержит все 4 названия рабочих вкладок', () => { @@ -52,8 +60,8 @@ describe('SettingsView.vue', () => { await wrapper.vm.$nextTick(); const text = wrapper.text(); expect(text).toContain('События × каналы'); - // 8 типов событий из schema users.notification_preferences. - ['Новый лид', 'Напоминание', 'Низкий баланс', 'Нулевой баланс', 'Анонсы и промо'].forEach((e) => + // Типы событий из schema users.notification_preferences (актуальный набор). + ['Новый лид', 'Низкий баланс', 'Нулевой баланс', 'Пополнение успешно', 'Анонсы и промо'].forEach((e) => expect(text).toContain(e), ); }); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 5e99b459..7a08b8de 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-06-28T08:09:57.221Z +Last updated: 2026-06-28T09:05:52.697Z | Контролёр | Состояние | Детали | |---|---|---| @@ -125,9 +125,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3440 | MsMpEng | 17.61ч | NaNч | -| 21928 | Code | 7.87ч | 0.0ч | -| 1212 | svchost | 4.49ч | NaNч | +| 3440 | MsMpEng | 17.84ч | NaNч | +| 21928 | Code | 8.07ч | 0.0ч | +| 1212 | svchost | 4.54ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. From 3618ca22b7ce96ee55b5593d99bd4a577184506e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 13:02:40 +0300 Subject: [PATCH 010/417] =?UTF-8?q?fix(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20RLS=20NULLIF-=D1=85=D0=B0?= =?UTF-8?q?=D1=80=D0=B4=D0=BD=D0=B8=D0=BD=D0=B3=20(v8.57)=20+=20CHANGELOG?= =?UTF-8?q?=20v8.58=20=D0=B4=D0=BB=D1=8F=203=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Политики tenant_isolation приведены к каноничной харднинг-форме NULLIF(current_setting('app.current_tenant_id', true), '')::bigint (после переноса на gitea/main с v8.57). Запись CHANGELOG для всех 3 таблиц. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26_06_28_100000_create_autopodbor_runs.php | 2 +- ...8_100100_create_autopodbor_competitors.php | 2 +- ...06_28_100200_create_autopodbor_sources.php | 2 +- db/CHANGELOG_schema.md | 37 ++++++++++++++++++- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php b/app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php index 6cc8bd6b..3e8f28fe 100644 --- a/app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php +++ b/app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php @@ -30,7 +30,7 @@ return new class extends Migration { DB::statement('ALTER TABLE autopodbor_runs ENABLE ROW LEVEL SECURITY'); DB::statement('ALTER TABLE autopodbor_runs FORCE ROW LEVEL SECURITY'); - DB::statement("CREATE POLICY tenant_isolation ON autopodbor_runs USING (tenant_id = current_setting('app.current_tenant_id')::bigint)"); + DB::statement("CREATE POLICY tenant_isolation ON autopodbor_runs USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); } public function down(): void diff --git a/app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php b/app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php index 610df0ed..a6773711 100644 --- a/app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php +++ b/app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php @@ -33,7 +33,7 @@ return new class extends Migration { DB::statement('ALTER TABLE autopodbor_competitors ENABLE ROW LEVEL SECURITY'); DB::statement('ALTER TABLE autopodbor_competitors FORCE ROW LEVEL SECURITY'); - DB::statement("CREATE POLICY tenant_isolation ON autopodbor_competitors USING (tenant_id = current_setting('app.current_tenant_id')::bigint)"); + DB::statement("CREATE POLICY tenant_isolation ON autopodbor_competitors USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); } public function down(): void diff --git a/app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php b/app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php index f5108260..1cb84d83 100644 --- a/app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php +++ b/app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php @@ -31,7 +31,7 @@ return new class extends Migration { DB::statement('ALTER TABLE autopodbor_sources ENABLE ROW LEVEL SECURITY'); DB::statement('ALTER TABLE autopodbor_sources FORCE ROW LEVEL SECURITY'); - DB::statement("CREATE POLICY tenant_isolation ON autopodbor_sources USING (tenant_id = current_setting('app.current_tenant_id')::bigint)"); + DB::statement("CREATE POLICY tenant_isolation ON autopodbor_sources USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); } public function down(): void diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 914a1742..9899a337 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -2,7 +2,42 @@ **Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. -**Файл схемы:** `schema.sql` (текущая версия — v8.57, консолидированная — разворачивает БД с нуля). +**Файл схемы:** `schema.sql` (текущая версия — v8.58, консолидированная — разворачивает БД с нуля). + +## v8.58 (2026-06-28) — Автоподбор конкурентов: 3 таблицы (autopodbor_runs/competitors/sources) + +Фундамент фичи «Автоподбор конкурентов» (ИИ-агент находит клиенту конкурентов и их +источники). Три tenant-isolated таблицы. RLS-политики сразу в харднинг-форме v8.57 +(`NULLIF(current_setting('app.current_tenant_id', true), '')::bigint`). + +Миграции: `app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php`, +`…_100100_create_autopodbor_competitors.php`, `…_100200_create_autopodbor_sources.php`. + +**Добавлено:** + +- **`autopodbor_runs`** — платный «прогон» агента: `kind` (`search`/`study`/`resolve`), + `status` (`queued`/`running`/`done`/`empty`/`failed`), `region_code`, `params` (jsonb), + `competitor_id`, `price_rub_charged` (decimal 12,2), `balance_transaction_id`, `error_code`, + `created_at`/`started_at`/`finished_at`. FK `tenant_id`→tenants CASCADE. + Индексы `(tenant_id, status)`, `(tenant_id, kind, status)`. +- **`autopodbor_competitors`** — конкурент: `search_run_id` (FK→autopodbor_runs SET NULL), + `name`, `description`, `is_federal`, `relevance_pct`, `origin` (`auto`/`manual`/`resolve`), + `site_url`, `directory_urls` (jsonb), `provenance` (jsonb), `dedup_key`, + `study_run_id` (FK→autopodbor_runs SET NULL), `studied_at`, `created_at`. + UNIQUE `autopodbor_competitor_dedup` `(tenant_id, search_run_id, dedup_key)`. +- **`autopodbor_sources`** — источник конкурента: `competitor_id` (FK CASCADE), + `study_run_id` (FK→autopodbor_runs CASCADE), `signal_type` (`site`/`call`), `identifier` + (голова домена / `7xxxxxxxxxx`), `phone_kind` (`real`/`substitute`/null), `provenance_url`, + `provenance_label`, `dedup_key`, `created_project_id` (FK→projects SET NULL), `created_at`. + UNIQUE `autopodbor_source_dedup` `(competitor_id, dedup_key)`. +- **RLS:** все три — `ENABLE` + `FORCE ROW LEVEL SECURITY` + политика `tenant_isolation` + в форме `USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)`. + +**NB:** UNIQUE с NULL в `search_run_id` (Postgres допускает несколько NULL) → дедуп «своих +конкурентов» (search_run_id NULL) обеспечивается в коде (`AutopodborDedup`), не индексом. +Счётчик RLS-политик: 44 → 47 (+3). + +--- ## v8.57 (2026-06-26) — RLS GUC hardening: NULLIF во всех политиках tenant_isolation (инцидент входа) From 8c96c71280f4a43406addef4ce0b3412eab8df04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 13:08:59 +0300 Subject: [PATCH 011/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D0=B0=D1=8F?= =?UTF-8?q?=20=D0=91=D0=94=20liderra=5Ftesting=5Fapk=20+=20RLS-=D1=85?= =?UTF-8?q?=D0=B0=D1=80=D0=B4=D0=BD=D0=B8=D0=BD=D0=B3=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D0=B2=D0=B5=D0=BD=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-28-avtopodbor-konkurentov.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-06-28-avtopodbor-konkurentov.md b/docs/superpowers/plans/2026-06-28-avtopodbor-konkurentov.md index 8cd06df2..7ea6dc14 100644 --- a/docs/superpowers/plans/2026-06-28-avtopodbor-konkurentov.md +++ b/docs/superpowers/plans/2026-06-28-avtopodbor-konkurentov.md @@ -16,15 +16,17 @@ ## КОНВЕНЦИИ (сверены с кодом 2026-06-28 — соблюдать во ВСЕХ задачах) -1. **RLS-GUC = `app.current_tenant_id`** (НЕ `app.tenant_id`). Политики: - `USING (tenant_id = current_setting('app.current_tenant_id')::bigint)`. +1. **RLS-GUC = `app.current_tenant_id`** (НЕ `app.tenant_id`), форма ХАРДНИНГ v8.57: + `USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)`. 2. **Tenant-контекст в джобах/сервисах с записью под RLS:** оборачивать в `DB::transaction(function(){ DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); ... })` — как в `app/app/Jobs/RouteSupplierLeadJob.php` и middleware `SetTenantContext.php`. **Нет** класса `TenantContext::set()` — не использовать. -3. **Тестовая БД:** отдельного соединения `pgsql_testing` НЕТ. Тесты сами идут на - `liderra_testing` через `phpunit.xml`. Применить новые миграции к тестовой БД: - `DB_DATABASE=liderra_testing php artisan migrate` (из `app/`). Боевую `liderra` НЕ трогать. +3. **Тестовая БД = `liderra_testing_apk`** (ВЫДЕЛЕННАЯ для фичи, собрана из gitea v8.57; + общую `liderra_testing` и боевую `liderra` НЕ трогать). Соединения `pgsql_testing` НЕТ. + Команды ВСЕГДА с префиксом окружения (shell-`DB_DATABASE` побеждает phpunit.xml): + - тесты: `DB_DATABASE=liderra_testing_apk ./vendor/bin/pest <путь>` + - миграции: `DB_DATABASE=liderra_testing_apk php artisan migrate --force` 4. **Tenant/User в тестах:** `Tenant::factory()->create()` + `User::factory()->create(['tenant_id'=>$tenant->id])`. HTTP — `$this->actingAs($user)->postJson(...)`. Прямые model-тесты — mirror `tests/Feature/Billing/ProjectPreflightTest.php` (`set_config('app.current_tenant_id', ...)` в `beforeEach`). @@ -33,9 +35,10 @@ `tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php` (bcmath, `balance_rub`, `balance_rub_after`). 6. **RefreshDatabase глобально ВЫКЛЮЧЕН.** Файлам с записью добавлять `uses(RefreshDatabase::class)` или mirror соседний тест. Cross-connection запись — `uses(\Tests\Concerns\SharesSupplierPdo::class)`. -7. **Bootstrap worktree уже сделан контроллером** (vendor/composer, `.env`, `storage/framework/*`, - `liderra_testing` смигрирована до main). Субагенту vendor ставить НЕ нужно; нужно лишь - применять СВОИ миграции к `liderra_testing` (п.3) перед своими тестами. +7. **Bootstrap worktree уже сделан контроллером** (база на gitea/main; vendor/composer под gitea; + `.env`, `storage/framework/*`; выделенная `liderra_testing_apk` собрана из gitea v8.57 со + всеми миграциями включая 3 autopodbor). Субагенту vendor ставить НЕ нужно; миграции тоже уже + накатаны — нужно лишь гонять свои тесты с `DB_DATABASE=liderra_testing_apk` (п.3). --- From 8c7ed798baca0df6dbcec400a2e81372f40a66c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 13:12:35 +0300 Subject: [PATCH 012/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20Eloquent-=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=20run/competitor/source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/Models/AutopodborCompetitor.php | 53 +++++++++++++++++++ app/app/Models/AutopodborRun.php | 40 ++++++++++++++ app/app/Models/AutopodborSource.php | 40 ++++++++++++++ .../Autopodbor/AutopodborModelsTest.php | 45 ++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 app/app/Models/AutopodborCompetitor.php create mode 100644 app/app/Models/AutopodborRun.php create mode 100644 app/app/Models/AutopodborSource.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborModelsTest.php diff --git a/app/app/Models/AutopodborCompetitor.php b/app/app/Models/AutopodborCompetitor.php new file mode 100644 index 00000000..bc0488c0 --- /dev/null +++ b/app/app/Models/AutopodborCompetitor.php @@ -0,0 +1,53 @@ + 'bool', + 'directory_urls' => 'array', + 'provenance' => 'array', + 'studied_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + public function sources(): HasMany + { + return $this->hasMany(AutopodborSource::class, 'competitor_id'); + } + + public function searchRun(): BelongsTo + { + return $this->belongsTo(AutopodborRun::class, 'search_run_id'); + } + + public function studyRun(): BelongsTo + { + return $this->belongsTo(AutopodborRun::class, 'study_run_id'); + } +} diff --git a/app/app/Models/AutopodborRun.php b/app/app/Models/AutopodborRun.php new file mode 100644 index 00000000..48f159a8 --- /dev/null +++ b/app/app/Models/AutopodborRun.php @@ -0,0 +1,40 @@ + 'array', + 'price_rub_charged' => 'decimal:2', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + public function competitors(): HasMany + { + return $this->hasMany(AutopodborCompetitor::class, 'search_run_id'); + } +} diff --git a/app/app/Models/AutopodborSource.php b/app/app/Models/AutopodborSource.php new file mode 100644 index 00000000..2aedfddb --- /dev/null +++ b/app/app/Models/AutopodborSource.php @@ -0,0 +1,40 @@ + 'datetime', + ]; + + public function competitor(): BelongsTo + { + return $this->belongsTo(AutopodborCompetitor::class, 'competitor_id'); + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class, 'created_project_id'); + } +} diff --git a/app/tests/Feature/Autopodbor/AutopodborModelsTest.php b/app/tests/Feature/Autopodbor/AutopodborModelsTest.php new file mode 100644 index 00000000..474bf3cc --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborModelsTest.php @@ -0,0 +1,45 @@ +create(); + DB::statement("SET LOCAL app.current_tenant_id = " . $tenant->id); + + $run = AutopodborRun::create([ + 'tenant_id' => $tenant->id, + 'kind' => 'search', + 'status' => 'done', + 'region_code' => 16, + 'params' => [], + ]); + + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, + 'search_run_id' => $run->id, + 'name' => 'Окна Комфорт', + 'dedup_key' => 'okna-komfort', + 'relevance_pct' => 100, + ]); + + $src = AutopodborSource::create([ + 'tenant_id' => $tenant->id, + 'competitor_id' => $comp->id, + 'study_run_id' => $run->id, + 'signal_type' => 'site', + 'identifier' => 'okna-komfort.ru', + 'dedup_key' => 'site:okna-komfort.ru', + ]); + + expect($comp->sources()->count())->toBe(1) + ->and($comp->searchRun->id)->toBe($run->id) + ->and($src->competitor->id)->toBe($comp->id) + ->and($run->competitors()->count())->toBe(1); +}); From 7bc7a08f2feeb0d8192174ffa4c66671d4772c55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 13:16:59 +0300 Subject: [PATCH 013/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=82=D0=B8=D0=BF=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BE=D0=B4=D0=BA=D0=B8=20autopodbor=5Fcharg?= =?UTF-8?q?e=20+=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BalanceTransaction::TYPE_AUTOPODBOR_CHARGE = 'autopodbor_charge' - сид-миграция 4 ключей system_settings (idempotent): autopodbor_enabled (bool, 0), autopodbor_price_search_rub (decimal, 0), autopodbor_price_study_rub (decimal, 0), autopodbor_max_competitors (int, 15) - Unit + Feature тесты, оба PASS Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/Models/BalanceTransaction.php | 2 + ..._06_28_110000_seed_autopodbor_settings.php | 64 +++++++++++++++++++ .../Autopodbor/AutopodborSettingsSeedTest.php | 13 ++++ .../Autopodbor/BalanceTransactionTypeTest.php | 5 ++ 4 files changed, 84 insertions(+) create mode 100644 app/database/migrations/2026_06_28_110000_seed_autopodbor_settings.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborSettingsSeedTest.php create mode 100644 app/tests/Unit/Autopodbor/BalanceTransactionTypeTest.php diff --git a/app/app/Models/BalanceTransaction.php b/app/app/Models/BalanceTransaction.php index bd082341..f949da2f 100644 --- a/app/app/Models/BalanceTransaction.php +++ b/app/app/Models/BalanceTransaction.php @@ -42,6 +42,8 @@ class BalanceTransaction extends Model public const TYPE_MIGRATION = 'migration'; + public const TYPE_AUTOPODBOR_CHARGE = 'autopodbor_charge'; + public $timestamps = false; protected $fillable = [ diff --git a/app/database/migrations/2026_06_28_110000_seed_autopodbor_settings.php b/app/database/migrations/2026_06_28_110000_seed_autopodbor_settings.php new file mode 100644 index 00000000..4c6ce224 --- /dev/null +++ b/app/database/migrations/2026_06_28_110000_seed_autopodbor_settings.php @@ -0,0 +1,64 @@ + 'autopodbor_enabled', + 'value' => '0', + 'type' => 'bool', + 'description' => 'Автоподбор конкурентов: вкл/выкл вкладку', + 'updated_at' => now(), + ], + [ + 'key' => 'autopodbor_price_search_rub', + 'value' => '0', + 'type' => 'decimal', + 'description' => 'Цена подбора конкурентов (шаг 1), ₽', + 'updated_at' => now(), + ], + [ + 'key' => 'autopodbor_price_study_rub', + 'value' => '0', + 'type' => 'decimal', + 'description' => 'Цена изучения конкурента (шаг 2), ₽', + 'updated_at' => now(), + ], + [ + 'key' => 'autopodbor_max_competitors', + 'value' => '15', + 'type' => 'int', + 'description' => 'Макс. число конкурентов на выдаче шага 1', + 'updated_at' => now(), + ], + ]; + + foreach ($rows as $row) { + $exists = DB::table('system_settings')->where('key', $row['key'])->exists(); + if (! $exists) { + DB::table('system_settings')->insert($row); + } + } + } + + public function down(): void + { + DB::table('system_settings')->whereIn('key', [ + 'autopodbor_enabled', + 'autopodbor_price_search_rub', + 'autopodbor_price_study_rub', + 'autopodbor_max_competitors', + ])->delete(); + } +}; diff --git a/app/tests/Feature/Autopodbor/AutopodborSettingsSeedTest.php b/app/tests/Feature/Autopodbor/AutopodborSettingsSeedTest.php new file mode 100644 index 00000000..369aada7 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborSettingsSeedTest.php @@ -0,0 +1,13 @@ +toBeFalse() // default OFF + ->and(SystemSettings::get('autopodbor_max_competitors'))->toBe('15') + ->and(\App\Models\SystemSetting::whereKey('autopodbor_price_search_rub')->exists())->toBeTrue() + ->and(\App\Models\SystemSetting::whereKey('autopodbor_price_study_rub')->exists())->toBeTrue(); +}); diff --git a/app/tests/Unit/Autopodbor/BalanceTransactionTypeTest.php b/app/tests/Unit/Autopodbor/BalanceTransactionTypeTest.php new file mode 100644 index 00000000..16b1cddb --- /dev/null +++ b/app/tests/Unit/Autopodbor/BalanceTransactionTypeTest.php @@ -0,0 +1,5 @@ +toBe('autopodbor_charge'); +}); From ec19a19132254968237cd4102429290d63a03f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 13:20:10 +0300 Subject: [PATCH 014/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20DTO=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BA=D1=82=D0=B0=20=D0=B4=D0=B2=D0=B8=D0=B6?= =?UTF-8?q?=D0=BA=D0=B0=20(6=20=D1=88=D1=82.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Agent/Dto/FindCompetitorsRequest.php | 14 +++++++++++ .../Agent/Dto/FindCompetitorsResult.php | 11 +++++++++ .../Agent/Dto/ResolveByNameRequest.php | 11 +++++++++ .../Agent/Dto/ResolveByNameResult.php | 11 +++++++++ .../Agent/Dto/StudyCompetitorRequest.php | 14 +++++++++++ .../Agent/Dto/StudyCompetitorResult.php | 11 +++++++++ app/tests/Unit/Autopodbor/AgentDtoTest.php | 23 +++++++++++++++++++ 7 files changed, 95 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Dto/FindCompetitorsRequest.php create mode 100644 app/app/Services/Autopodbor/Agent/Dto/FindCompetitorsResult.php create mode 100644 app/app/Services/Autopodbor/Agent/Dto/ResolveByNameRequest.php create mode 100644 app/app/Services/Autopodbor/Agent/Dto/ResolveByNameResult.php create mode 100644 app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorRequest.php create mode 100644 app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorResult.php create mode 100644 app/tests/Unit/Autopodbor/AgentDtoTest.php diff --git a/app/app/Services/Autopodbor/Agent/Dto/FindCompetitorsRequest.php b/app/app/Services/Autopodbor/Agent/Dto/FindCompetitorsRequest.php new file mode 100644 index 00000000..e92eee7d --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Dto/FindCompetitorsRequest.php @@ -0,0 +1,14 @@ + $competitors + */ + public function __construct(public readonly array $competitors) {} +} diff --git a/app/app/Services/Autopodbor/Agent/Dto/ResolveByNameRequest.php b/app/app/Services/Autopodbor/Agent/Dto/ResolveByNameRequest.php new file mode 100644 index 00000000..1b55159b --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Dto/ResolveByNameRequest.php @@ -0,0 +1,11 @@ + $candidates + */ + public function __construct(public readonly array $candidates) {} +} diff --git a/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorRequest.php b/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorRequest.php new file mode 100644 index 00000000..41335797 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorRequest.php @@ -0,0 +1,14 @@ + $sources + */ + public function __construct(public readonly array $sources) {} +} diff --git a/app/tests/Unit/Autopodbor/AgentDtoTest.php b/app/tests/Unit/Autopodbor/AgentDtoTest.php new file mode 100644 index 00000000..828d87e7 --- /dev/null +++ b/app/tests/Unit/Autopodbor/AgentDtoTest.php @@ -0,0 +1,23 @@ +'окна'], includeFederal:true, maxCompetitors:15); + expect($fReq->regionCode)->toBe(16)->and($fReq->examples)->toContain('okna.ru')->and($fReq->includeFederal)->toBeTrue(); + + $fRes = new FindCompetitorsResult(competitors:[['name'=>'Окна Комфорт','relevance_pct'=>100]]); + expect($fRes->competitors[0]['name'])->toBe('Окна Комфорт'); + + $sReq = new StudyCompetitorRequest(competitor:['name'=>'Окна Комфорт','site_url'=>'okna.ru'], regionCode:16); + expect($sReq->competitor['site_url'])->toBe('okna.ru')->and($sReq->regionCode)->toBe(16); + + $sRes = new StudyCompetitorResult(sources:[['signal_type'=>'site','identifier'=>'okna.ru']]); + expect($sRes->sources[0]['signal_type'])->toBe('site'); + + $rReq = new ResolveByNameRequest(name:'Окна Комфорт', regionCode:16); + expect($rReq->name)->toBe('Окна Комфорт'); + + $rRes = new ResolveByNameResult(candidates:[['name'=>'Окна Комфорт']]); + expect($rRes->candidates)->toHaveCount(1); +}); From cbae23ba8693637fef78bd5c7f276c2bc2fbc337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 13:28:56 +0300 Subject: [PATCH 015/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D1=84=D0=B5=D0=B9=D1=81=20=D0=B4=D0=B2=D0=B8=D0=B6=D0=BA?= =?UTF-8?q?=D0=B0=20CompetitorAgent=20+=20=D0=B7=D0=B0=D0=B3=D0=BB=D1=83?= =?UTF-8?q?=D1=88=D0=BA=D0=B0=20+=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CompetitorAgent interface: findCompetitors / studyCompetitor / resolveByName - FakeCompetitorAgent: 4 конкурента, 5 источников, 1 кандидат по имени - AutopodborServiceProvider: bind(CompetitorAgent → FakeCompetitorAgent) - Регистрация провайдера в bootstrap/providers.php (Laravel 11+) - Pest.php: extend TestCase для Unit/Autopodbor (контейнер в Unit-тестах) - Тест: 1/1 PASS, 10 assertions Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Providers/AutopodborServiceProvider.php | 16 +++++++++ .../Autopodbor/Agent/CompetitorAgent.php | 12 +++++++ .../Autopodbor/Agent/FakeCompetitorAgent.php | 36 +++++++++++++++++++ app/bootstrap/providers.php | 1 + app/tests/Pest.php | 4 +++ .../Autopodbor/FakeCompetitorAgentTest.php | 20 +++++++++++ 6 files changed, 89 insertions(+) create mode 100644 app/app/Providers/AutopodborServiceProvider.php create mode 100644 app/app/Services/Autopodbor/Agent/CompetitorAgent.php create mode 100644 app/app/Services/Autopodbor/Agent/FakeCompetitorAgent.php create mode 100644 app/tests/Unit/Autopodbor/FakeCompetitorAgentTest.php diff --git a/app/app/Providers/AutopodborServiceProvider.php b/app/app/Providers/AutopodborServiceProvider.php new file mode 100644 index 00000000..e8a347b1 --- /dev/null +++ b/app/app/Providers/AutopodborServiceProvider.php @@ -0,0 +1,16 @@ +app->bind(CompetitorAgent::class, FakeCompetitorAgent::class); + } +} diff --git a/app/app/Services/Autopodbor/Agent/CompetitorAgent.php b/app/app/Services/Autopodbor/Agent/CompetitorAgent.php new file mode 100644 index 00000000..79abf78b --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/CompetitorAgent.php @@ -0,0 +1,12 @@ + 'Окна Комфорт', 'description' => 'Пластиковые окна и остекление балконов под ключ.', 'is_federal' => false, 'relevance_pct' => 100, 'site_url' => 'okna-komfort-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/1'], 'provenance' => ['via' => 'similar-pages']], + ['name' => 'Пластика Окон', 'description' => 'Окна ПВХ, лоджии, входные группы.', 'is_federal' => false, 'relevance_pct' => 96, 'site_url' => 'plastika-okon-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/2'], 'provenance' => ['via' => 'similar-pages']], + ['name' => 'Фабрика Окон', 'description' => 'Федеральная сеть окон ПВХ, филиал в регионе.', 'is_federal' => true, 'relevance_pct' => 84, 'site_url' => 'fabrika-okon.ru', 'directory_urls' => ['https://2gis.ru/firm/3'], 'provenance' => ['via' => 'similar-pages']], + ['name' => 'Балкон-Сервис 16', 'description' => 'Остекление балконов; окна частично.', 'is_federal' => false, 'relevance_pct' => 61, 'site_url' => null, 'directory_urls' => ['https://yandex.ru/maps/4', 'https://2gis.ru/firm/4'], 'provenance' => ['via' => 'similar-pages']], + ]); + } + + public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult + { + return new StudyCompetitorResult([ + ['signal_type' => 'site', 'identifier' => 'okna-komfort-kzn.ru', 'phone_kind' => null, 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'], + ['signal_type' => 'site', 'identifier' => 'okna-komfort.pro', 'phone_kind' => null, 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — сайт в контактах'], + ['signal_type' => 'call', 'identifier' => '78432001122', 'phone_kind' => 'real', 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'], + ['signal_type' => 'call', 'identifier' => '78432009988', 'phone_kind' => 'substitute', 'provenance_url' => 'https://okna-komfort-kzn.ru', 'provenance_label' => 'номер в шапке (коллтрекинг)'], + ['signal_type' => 'call', 'identifier' => '79172001122', 'phone_kind' => 'real', 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — карточка компании'], + ]); + } + + public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult + { + return new ResolveByNameResult([ + ['name' => $r->name, 'description' => 'Найдено по названию (заглушка).', 'site_url' => 'okna-komfort-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/1'], 'provenance' => ['via' => 'name-search']], + ]); + } +} diff --git a/app/bootstrap/providers.php b/app/bootstrap/providers.php index fc94ae60..cd0a01ce 100644 --- a/app/bootstrap/providers.php +++ b/app/bootstrap/providers.php @@ -4,4 +4,5 @@ use App\Providers\AppServiceProvider; return [ AppServiceProvider::class, + App\Providers\AutopodborServiceProvider::class, ]; diff --git a/app/tests/Pest.php b/app/tests/Pest.php index cdbb96b7..d70d5289 100644 --- a/app/tests/Pest.php +++ b/app/tests/Pest.php @@ -30,6 +30,10 @@ uses(SharesAdminPdo::class)->in('Feature'); pest()->extend(TestCase::class)->in('Browser'); +// Unit/Autopodbor: часть тестов использует app() и requires Laravel-контейнер +// (в т.ч. FakeCompetitorAgentTest — резолвит CompetitorAgent через провайдер). +pest()->extend(TestCase::class)->in('Unit/Autopodbor'); + /* |-------------------------------------------------------------------------- | Expectations diff --git a/app/tests/Unit/Autopodbor/FakeCompetitorAgentTest.php b/app/tests/Unit/Autopodbor/FakeCompetitorAgentTest.php new file mode 100644 index 00000000..c957cc9f --- /dev/null +++ b/app/tests/Unit/Autopodbor/FakeCompetitorAgentTest.php @@ -0,0 +1,20 @@ +toBeInstanceOf(\App\Services\Autopodbor\Agent\FakeCompetitorAgent::class); + + $found = $agent->findCompetitors(new FindCompetitorsRequest(16, ['okna.ru'], [], true, 15)); + expect(count($found->competitors))->toBeGreaterThan(0) + ->and($found->competitors[0])->toHaveKeys(['name','relevance_pct','site_url']); + + $study = $agent->studyCompetitor(new StudyCompetitorRequest(['name'=>'Окна Комфорт','site_url'=>'okna-komfort-kzn.ru'], 16)); + expect($study->sources)->not->toBeEmpty() + ->and($study->sources[0])->toHaveKeys(['signal_type','identifier','provenance_url']); + + $resolve = $agent->resolveByName(new ResolveByNameRequest('Окна Комфорт', 16)); + expect($resolve->candidates)->not->toBeEmpty(); +}); From 108fc8765c0042e17a380bc60f90c8464d8bc62d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 13:32:23 +0300 Subject: [PATCH 016/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=BD=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=82=D0=BE=D1=80=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B0/=D1=82=D0=B5=D0=BB=D0=B5=D1=84=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=20+=20dedup-=D0=BA=D0=BB=D1=8E=D1=87=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AutopodborNormalizer: domainHead (схема/www/путь/порт → голова), phone (через PhoneNormalizer::normalize → 7xxxxxxxxxx без плюса), sourceKey и competitorKey для дедупликации конкурентов и источников. 4 теста, 9 assertions, все GREEN. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Autopodbor/AutopodborNormalizer.php | 89 +++++++++++++++++++ .../Autopodbor/AutopodborNormalizerTest.php | 28 ++++++ 2 files changed, 117 insertions(+) create mode 100644 app/app/Services/Autopodbor/AutopodborNormalizer.php create mode 100644 app/tests/Unit/Autopodbor/AutopodborNormalizerTest.php diff --git a/app/app/Services/Autopodbor/AutopodborNormalizer.php b/app/app/Services/Autopodbor/AutopodborNormalizer.php new file mode 100644 index 00000000..e5c761cd --- /dev/null +++ b/app/app/Services/Autopodbor/AutopodborNormalizer.php @@ -0,0 +1,89 @@ +phone($identifier) + : $this->domainHead($identifier); + + return $type . ':' . $id; + } + + /** + * Строит dedup-ключ для конкурента. + * Если есть сайт — «site:домен», иначе «name:имя_в_нижнем_регистре». + */ + public function competitorKey(string $name, ?string $siteUrl): string + { + if ($siteUrl !== null) { + return 'site:' . $this->domainHead($siteUrl); + } + + // Нижний регистр + схлопываем пробелы + $normalized = preg_replace('#\s+#u', ' ', trim(mb_strtolower($name))); + + return 'name:' . $normalized; + } +} diff --git a/app/tests/Unit/Autopodbor/AutopodborNormalizerTest.php b/app/tests/Unit/Autopodbor/AutopodborNormalizerTest.php new file mode 100644 index 00000000..c206fcae --- /dev/null +++ b/app/tests/Unit/Autopodbor/AutopodborNormalizerTest.php @@ -0,0 +1,28 @@ +domainHead('https://www.Okna-Komfort.RU/contacts'))->toBe('okna-komfort.ru') + ->and($n->domainHead('okna.pro'))->toBe('okna.pro') + ->and($n->domainHead('http://site.ru:8080/path?x=1'))->toBe('site.ru'); +}); + +it('нормализует телефон в 7xxxxxxxxxx', function () { + $n = new AutopodborNormalizer(); + expect($n->phone('+7 (843) 200-11-22'))->toBe('78432001122') + ->and($n->phone('8 843 2001122'))->toBe('78432001122'); +}); + +it('строит dedup_key источника', function () { + $n = new AutopodborNormalizer(); + expect($n->sourceKey('site', 'https://OKNA.ru/'))->toBe('site:okna.ru') + ->and($n->sourceKey('call', '8-843-200-11-22'))->toBe('call:78432001122'); +}); + +it('строит dedup_key конкурента', function () { + $n = new AutopodborNormalizer(); + expect($n->competitorKey('Окна Комфорт', 'https://www.okna-komfort.ru/'))->toBe('site:okna-komfort.ru') + ->and($n->competitorKey(' Окна Комфорт ', null))->toBe('name:окна комфорт'); +}); From 96c8d63f1dbe269c966989e3b952b1d6cc184229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 13:35:38 +0300 Subject: [PATCH 017/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B4=D0=B5=D0=B4=D1=83?= =?UTF-8?q?=D0=BF=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=83=D1=80=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2/=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2/=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Services/Autopodbor/AutopodborDedup.php | 79 +++++++++++++++++++ .../Autopodbor/AutopodborDedupTest.php | 43 ++++++++++ 2 files changed, 122 insertions(+) create mode 100644 app/app/Services/Autopodbor/AutopodborDedup.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborDedupTest.php diff --git a/app/app/Services/Autopodbor/AutopodborDedup.php b/app/app/Services/Autopodbor/AutopodborDedup.php new file mode 100644 index 00000000..ab58906d --- /dev/null +++ b/app/app/Services/Autopodbor/AutopodborDedup.php @@ -0,0 +1,79 @@ +norm->phone($identifier) + : $this->norm->domainHead($identifier); + + return Project::query() + ->where('tenant_id', $tenantId) + ->where('signal_type', $signalType) + ->where('signal_identifier', $needle) + ->value('id'); + } + + /** + * Дедупликация источников внутри переданного списка по нормализованному ключу. + * Возвращает уникальные элементы с добавленным полем dedup_key. + * + * @param array $sources + * @return array + */ + public function dedupSources(array $sources): array + { + $seen = []; + $out = []; + + foreach ($sources as $s) { + $key = $this->norm->sourceKey($s['signal_type'], $s['identifier']); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $s['dedup_key'] = $key; + $out[] = $s; + } + + return $out; + } + + /** + * Дедупликация конкурентов внутри переданного списка по нормализованному ключу. + * Возвращает уникальные элементы с добавленным полем dedup_key. + * + * @param array $competitors + * @return array + */ + public function dedupCompetitors(array $competitors): array + { + $seen = []; + $out = []; + + foreach ($competitors as $c) { + $key = $this->norm->competitorKey($c['name'], $c['site_url'] ?? null); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $c['dedup_key'] = $key; + $out[] = $c; + } + + return $out; + } +} diff --git a/app/tests/Feature/Autopodbor/AutopodborDedupTest.php b/app/tests/Feature/Autopodbor/AutopodborDedupTest.php new file mode 100644 index 00000000..997a9ae4 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborDedupTest.php @@ -0,0 +1,43 @@ +create(); + DB::statement("SET LOCAL app.current_tenant_id = ".$tenant->id); + Project::factory()->create(['tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'okna.ru']); + + $dedup = app(AutopodborDedup::class); + expect($dedup->existingProjectId($tenant->id, 'site', 'https://www.okna.ru/'))->not->toBeNull() // нормализуется к okna.ru + ->and($dedup->existingProjectId($tenant->id, 'site', 'drugoy.ru'))->toBeNull(); +}); + +it('дедупит источники внутри списка', function () { + $dedup = app(AutopodborDedup::class); + $unique = $dedup->dedupSources([ + ['signal_type' => 'call', 'identifier' => '+7 843 200-11-22'], + ['signal_type' => 'call', 'identifier' => '88432001122'], // тот же номер + ['signal_type' => 'site', 'identifier' => 'www.okna.ru'], + ]); + expect($unique)->toHaveCount(2); + expect($unique[0])->toHaveKey('dedup_key'); +}); + +it('дедупит конкурентов', function () { + $dedup = app(AutopodborDedup::class); + $unique = $dedup->dedupCompetitors([ + ['name' => 'Окна Комфорт', 'site_url' => 'https://okna-komfort.ru/'], + ['name' => 'Окна Комфорт', 'site_url' => 'okna-komfort.ru'], // тот же домен + ['name' => 'Пластика Окон', 'site_url' => 'plastika.ru'], + ]); + expect($unique)->toHaveCount(2); + expect($unique[0])->toHaveKey('dedup_key'); +}); From 0f4b985b18b910e33353858a972649fca6f556ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 13:41:06 +0300 Subject: [PATCH 018/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B8=D0=B4=D0=B5=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D0=BE=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20(bcmath,=20only-on-succe?= =?UTF-8?q?ss)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AutopodborChargeService::chargeForRun — DB::transaction + lockForUpdate на AutopodborRun (guard идемпотентности по balance_transaction_id) и Tenant; bcmath (bcsub/bccomp/bcmul), никаких float; throw InsufficientBalanceException до любых изменений баланса при нехватке средств. - Миграция 2026_06_28_110100: расширяет CHECK constraint balance_transactions_type_check — добавляет 'autopodbor_charge'. - Тест: 2 money-инварианта (идемпотентность + noop при нехватке). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Autopodbor/AutopodborChargeService.php | 78 +++++++++++++++++++ ...lance_transactions_type_for_autopodbor.php | 47 +++++++++++ .../AutopodborChargeServiceTest.php | 37 +++++++++ 3 files changed, 162 insertions(+) create mode 100644 app/app/Services/Autopodbor/AutopodborChargeService.php create mode 100644 app/database/migrations/2026_06_28_110100_extend_balance_transactions_type_for_autopodbor.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborChargeServiceTest.php diff --git a/app/app/Services/Autopodbor/AutopodborChargeService.php b/app/app/Services/Autopodbor/AutopodborChargeService.php new file mode 100644 index 00000000..3650f236 --- /dev/null +++ b/app/app/Services/Autopodbor/AutopodborChargeService.php @@ -0,0 +1,78 @@ +id)->lockForUpdate()->firstOrFail(); + + if ($locked->balance_transaction_id !== null) { + // Уже списано — идемпотентный возврат без второго списания + return; + } + + // Блокируем tenant для атомарного изменения баланса + /** @var Tenant $tenant */ + $tenant = Tenant::whereKey($locked->tenant_id)->lockForUpdate()->firstOrFail(); + + // bcmath: сравниваем с точностью 2 знака + if (bccomp((string) $tenant->balance_rub, $priceRub, 2) < 0) { + throw new InsufficientBalanceException( + priceKopecks: (int) bcmul($priceRub, '100', 0), + balanceRub: (string) $tenant->balance_rub, + ); + } + + $newBalance = bcsub((string) $tenant->balance_rub, $priceRub, 2); + + // Обновляем баланс через DB::table (как в LedgerService) — надёжнее при decimal + DB::table('tenants') + ->where('id', $tenant->id) + ->update(['balance_rub' => $newBalance]); + + // Записываем транзакцию + $tx = BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_AUTOPODBOR_CHARGE, + 'amount_rub' => '-'.$priceRub, + 'amount_leads' => null, + 'balance_rub_after' => $newBalance, + 'balance_leads_after' => null, + 'related_type' => AutopodborRun::class, + 'related_id' => $locked->id, + 'created_at' => now(), + ]); + + // Фиксируем на run идемпотентный маркер + $locked->balance_transaction_id = $tx->id; + $locked->price_rub_charged = $priceRub; + $locked->save(); + }); + } +} diff --git a/app/database/migrations/2026_06_28_110100_extend_balance_transactions_type_for_autopodbor.php b/app/database/migrations/2026_06_28_110100_extend_balance_transactions_type_for_autopodbor.php new file mode 100644 index 00000000..d09d8fd6 --- /dev/null +++ b/app/database/migrations/2026_06_28_110100_extend_balance_transactions_type_for_autopodbor.php @@ -0,0 +1,47 @@ +create(['balance_rub' => '1000.00']); + DB::statement("SET LOCAL app.current_tenant_id = ".$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'running', 'params' => []]); + + $svc = app(AutopodborChargeService::class); + $svc->chargeForRun($run, '300.00'); + $svc->chargeForRun($run->fresh(), '300.00'); // повтор НЕ должен списать второй раз + + expect((string) $tenant->fresh()->balance_rub)->toBe('700.00') + ->and($run->fresh()->price_rub_charged)->not->toBeNull() + ->and(BalanceTransaction::where('type', 'autopodbor_charge')->where('related_id', $run->id)->count())->toBe(1); +}); + +it('не списывает при нехватке баланса (бросает, баланс цел)', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '100.00']); + DB::statement("SET LOCAL app.current_tenant_id = ".$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'running', 'params' => []]); + + expect(fn () => app(AutopodborChargeService::class)->chargeForRun($run, '300.00')) + ->toThrow(\App\Exceptions\Billing\InsufficientBalanceException::class); + expect((string) $tenant->fresh()->balance_rub)->toBe('100.00') + ->and(BalanceTransaction::where('related_id', $run->id)->count())->toBe(0); +}); From 411a73b3f629b6b1e5e431aae3023d4f25aa05c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 13:46:42 +0300 Subject: [PATCH 019/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B4=D0=B6=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=20=D1=88=D0=B0=D0=B3=D0=B0=201=20(=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=83=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Autopodbor/RunAutopodborSearchJob.php | 88 +++++++++++++++++++ app/tests/Doubles/EmptyCompetitorAgent.php | 29 ++++++ .../Autopodbor/RunAutopodborSearchJobTest.php | 61 +++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php create mode 100644 app/tests/Doubles/EmptyCompetitorAgent.php create mode 100644 app/tests/Feature/Autopodbor/RunAutopodborSearchJobTest.php diff --git a/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php new file mode 100644 index 00000000..4aa0783a --- /dev/null +++ b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php @@ -0,0 +1,88 @@ +runId); + + // Выставляем tenant-контекст сессионно — все запросы (включая вложенные транзакции charge) видят GUC + DB::statement('SET app.current_tenant_id = '.$run->tenant_id); + + $run->update(['status' => 'running', 'started_at' => now()]); + + try { + $p = $run->params; + $max = (int) (SystemSettings::get('autopodbor_max_competitors') ?? 15); + + $res = $agent->findCompetitors(new FindCompetitorsRequest( + regionCode: (int) $run->region_code, + examples: $p['examples'] ?? [], + aboutSelf: $p['about_self'] ?? [], + includeFederal: (bool) ($p['include_federal'] ?? false), + maxCompetitors: $max, + )); + + $unique = $dedup->dedupCompetitors($res->competitors); + + if ($unique === []) { + $run->update(['status' => 'empty', 'finished_at' => now()]); + return; + } + + foreach (array_slice($unique, 0, $max) as $c) { + AutopodborCompetitor::create([ + 'tenant_id' => $run->tenant_id, + 'search_run_id' => $run->id, + 'name' => $c['name'], + 'description' => $c['description'] ?? null, + 'is_federal' => (bool) ($c['is_federal'] ?? false), + 'relevance_pct' => $c['relevance_pct'] ?? null, + 'origin' => 'auto', + 'site_url' => $c['site_url'] ?? null, + 'directory_urls' => $c['directory_urls'] ?? [], + 'provenance' => $c['provenance'] ?? [], + 'dedup_key' => $c['dedup_key'], + ]); + } + + $price = (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0'); + $charge->chargeForRun($run, $price); + + $run->update(['status' => 'done', 'finished_at' => now()]); + } catch (\Throwable $e) { + $run->update([ + 'status' => 'failed', + 'error_code' => substr($e->getMessage(), 0, 64), + 'finished_at' => now(), + ]); + throw $e; + } + } +} diff --git a/app/tests/Doubles/EmptyCompetitorAgent.php b/app/tests/Doubles/EmptyCompetitorAgent.php new file mode 100644 index 00000000..279ccfca --- /dev/null +++ b/app/tests/Doubles/EmptyCompetitorAgent.php @@ -0,0 +1,29 @@ +call([new RunAutopodborSearchJob($runId), 'handle']); +} + +it('успешный подбор: сохраняет конкурентов, списывает, status=done', 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' => '500', 'type' => 'decimal']); + SystemSetting::updateOrCreate(['key' => 'autopodbor_max_competitors'], ['value' => '15', 'type' => 'int']); + $run = AutopodborRun::create([ + 'tenant_id' => $tenant->id, + 'kind' => 'search', + 'status' => 'queued', + 'region_code' => 16, + 'params' => ['examples' => ['okna.ru'], 'about_self' => [], 'include_federal' => true], + ]); + + runSearchJob($run->id); + + expect($run->fresh()->status)->toBe('done') + ->and($run->fresh()->price_rub_charged)->toBe('500.00') + ->and(AutopodborCompetitor::where('search_run_id', $run->id)->count())->toBeGreaterThan(0) + ->and((string) $tenant->fresh()->balance_rub)->toBe('99500.00'); +}); + +it('пустой результат: status=empty, без списания', function () { + app()->bind(CompetitorAgent::class, \Tests\Doubles\EmptyCompetitorAgent::class); + $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' => '500', 'type' => 'decimal']); + $run = AutopodborRun::create([ + 'tenant_id' => $tenant->id, + 'kind' => 'search', + 'status' => 'queued', + 'region_code' => 16, + 'params' => ['examples' => [], 'about_self' => [], 'include_federal' => false], + ]); + + runSearchJob($run->id); + + expect($run->fresh()->status)->toBe('empty') + ->and($run->fresh()->price_rub_charged)->toBeNull() + ->and((string) $tenant->fresh()->balance_rub)->toBe('100000.00'); +}); From 036fffbefe6662125fb5711642636461c042cbfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 13:49:57 +0300 Subject: [PATCH 020/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B4=D0=B6=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=20=D1=88=D0=B0=D0=B3=D0=B0=202=20(=D0=B8=D0=B7=D1=83?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=83?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D1=82=D0=B0,=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B8=D0=BA=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Jobs/Autopodbor/RunAutopodborStudyJob.php | 99 +++++++++++++++++++ .../Autopodbor/RunAutopodborStudyJobTest.php | 50 ++++++++++ 2 files changed, 149 insertions(+) create mode 100644 app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php create mode 100644 app/tests/Feature/Autopodbor/RunAutopodborStudyJobTest.php diff --git a/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php b/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php new file mode 100644 index 00000000..950a7c6d --- /dev/null +++ b/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php @@ -0,0 +1,99 @@ +runId); + + // Выставляем tenant-контекст сессионно — все запросы (включая вложенные транзакции charge) видят GUC + DB::statement('SET app.current_tenant_id = '.$run->tenant_id); + + $run->update(['status' => 'running', 'started_at' => now()]); + + try { + $comp = AutopodborCompetitor::findOrFail($run->competitor_id); + + $res = $agent->studyCompetitor(new StudyCompetitorRequest( + competitor: [ + 'name' => $comp->name, + 'site_url' => $comp->site_url, + 'directory_urls' => $comp->directory_urls ?? [], + ], + regionCode: (int) $run->region_code, + )); + + $unique = $dedup->dedupSources($res->sources); + + if ($unique === []) { + $run->update(['status' => 'empty', 'finished_at' => now()]); + + return; + } + + foreach ($unique as $s) { + $identifier = $s['signal_type'] === 'call' + ? $norm->phone($s['identifier']) + : $norm->domainHead($s['identifier']); + + AutopodborSource::create([ + 'tenant_id' => $run->tenant_id, + 'competitor_id' => $comp->id, + 'study_run_id' => $run->id, + 'signal_type' => $s['signal_type'], + 'identifier' => $identifier, + 'phone_kind' => $s['phone_kind'] ?? null, + 'provenance_url' => $s['provenance_url'] ?? null, + 'provenance_label' => $s['provenance_label'] ?? null, + 'dedup_key' => $s['dedup_key'], + ]); + } + + $price = (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0'); + $charge->chargeForRun($run, $price); + + $comp->update(['studied_at' => now(), 'study_run_id' => $run->id]); + + $run->update(['status' => 'done', 'finished_at' => now()]); + } catch (\Throwable $e) { + $run->update([ + 'status' => 'failed', + 'error_code' => substr($e->getMessage(), 0, 64), + 'finished_at' => now(), + ]); + throw $e; + } + } +} diff --git a/app/tests/Feature/Autopodbor/RunAutopodborStudyJobTest.php b/app/tests/Feature/Autopodbor/RunAutopodborStudyJobTest.php new file mode 100644 index 00000000..497586c9 --- /dev/null +++ b/app/tests/Feature/Autopodbor/RunAutopodborStudyJobTest.php @@ -0,0 +1,50 @@ +create(['balance_rub' => '100000.00']); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '900', 'type' => 'decimal']); + $searchRun = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]); + $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $searchRun->id, 'name' => 'Окна Комфорт', 'dedup_key' => 'site:okna-komfort-kzn.ru', 'site_url' => 'okna-komfort-kzn.ru']); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'queued', 'region_code' => 16, 'competitor_id' => $comp->id, 'params' => []]); + + app()->call([new RunAutopodborStudyJob($run->id), 'handle']); + + expect($run->fresh()->status)->toBe('done') + ->and($run->fresh()->price_rub_charged)->toBe('900.00') + ->and($comp->fresh()->studied_at)->not->toBeNull() + ->and($comp->fresh()->study_run_id)->toBe($run->id) + ->and(AutopodborSource::where('competitor_id', $comp->id)->count())->toBeGreaterThan(0) + ->and((string) $tenant->fresh()->balance_rub)->toBe('99100.00'); + // источники нормализованы (телефоны 7xxxxxxxxxx) + $phone = AutopodborSource::where('competitor_id', $comp->id)->where('signal_type', 'call')->first(); + if ($phone) { + expect($phone->identifier)->toMatch('/^7\d{10}$/'); + } +}); + +it('пустой результат: status=empty, без списания', function () { + app()->bind(CompetitorAgent::class, \Tests\Doubles\EmptyCompetitorAgent::class); + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '900', 'type' => 'decimal']); + $searchRun = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]); + $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $searchRun->id, 'name' => 'Пусто', 'dedup_key' => 'site:empty.ru', 'site_url' => 'empty.ru']); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'queued', 'region_code' => 16, 'competitor_id' => $comp->id, 'params' => []]); + + app()->call([new RunAutopodborStudyJob($run->id), 'handle']); + + expect($run->fresh()->status)->toBe('empty') + ->and($run->fresh()->price_rub_charged)->toBeNull() + ->and((string) $tenant->fresh()->balance_rub)->toBe('100000.00'); +}); From bb3dbe76e8a1865d16e3bc391de00e9afce789f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 13:54:44 +0300 Subject: [PATCH 021/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B7=D0=B0=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B0=20(state=20+=20=D1=87=D1=82=D0=BE=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D0=B0=D1=82=D1=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-28-autopodbor-konkurentov-RESUME.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-RESUME.md diff --git a/docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-RESUME.md b/docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-RESUME.md new file mode 100644 index 00000000..5c98cf71 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-RESUME.md @@ -0,0 +1,117 @@ +# Автоподбор конкурентов — ЗАПИСКА ДЛЯ ПРОДОЛЖЕНИЯ (после компакта) + +> Дата: 2026-06-28. Это handoff-файл: прочитай его ПЕРВЫМ, затем по списку §«Что прочитать». +> Цель — продолжить реализацию фичи «Автоподбор конкурентов» с того места, где остановились. + +## 0. Что прочитать, чтобы восстановить контекст (в этом порядке) +1. Этот файл целиком. +2. План реализации (главный источник задач): `docs/superpowers/plans/2026-06-28-avtopodbor-konkurentov.md` + — в нём блок **КОНВЕНЦИИ** (обязательны) + 29 задач. ВНИМАНИЕ: код-блоки в плане для + некоторых задач писались против устаревшей базы; ниже §4 «Поправки» перечисляет, что + реально изменилось. Доверяй §4 и реальному коду, а не дословно плану. +3. Дизайн-документ (что и зачем): `docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-design.md` +4. Прототип UX (экраны фронта 1:1): `docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-prototype.html` +5. Заметки брейншторма (решения владельца): `docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-brainstorm-notes.md` +6. Один готовый бэкенд-пример для стиля джоб: `app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php` + +## 1. Где работаем (ОКРУЖЕНИЕ — критично) +- **Worktree:** `C:/моя/проекты/портал crm/Документация/.claude/worktrees/avtopodbor` (изолированный). +- **Ветка:** `worktree-avtopodbor`. РАБОТАТЬ ТОЛЬКО В НЕЙ. Без checkout/switch/merge/rebase. +- **База:** ветка стоит поверх `gitea/main` (реальный main!). `origin` = github = МЁРТВОЕ зеркало, + НЕ использовать как базу. Если надо обновить базу — `gitea/main`. +- **Окружение готово:** vendor под gitea установлен (composer install сделан), `.env` скопирован, + `storage/framework/*` созданы. Переустанавливать НЕ надо. +- **Тестовая БД = `liderra_testing_apk`** (ВЫДЕЛЕННАЯ, собрана из gitea v8.57 + все миграции вкл. наши). + - тесты: `cd app && DB_DATABASE=liderra_testing_apk ./vendor/bin/pest <путь>` + - миграции на тест: `cd app && DB_DATABASE=liderra_testing_apk php artisan migrate --force` + - Боевую `liderra` и общую `liderra_testing` НЕ ТРОГАТЬ. +- **Машина слабая:** мало RAM/диска, git worktree-создание ОOM-ило (решилось закрытием окон). + Тяжёлые операции (npm install) — осторожно, возможен OOM. +- **lefthook не в PATH** в worktree — pre-commit хуки не срабатывают, коммиты проходят (это норм здесь). + +## 2. Протокол работы (как продолжать) +- Метод: **subagent-driven** — на каждую задачу свежий субагент (Agent tool, general-purpose), + **модель sonnet** (задачи коммитят → §15.1 запрещает Haiku для git-задач). +- **Escape на коммиты ПОЛУЧЕН** от владельца (коммиты в рамках фичи; НЕ пушить, НЕ выкатывать). +- TDD: тест→fail→код→pass→коммит (явные пути, без `git add -A`, без пайпов в commit). +- Каждому субагенту в конце требовать блок `=== GIT REPORT ===` (cwd/branch/HEAD/HEAD^/status). +- После субагента — §B пост-проверка: HEAD сместился на 1 коммит, parent = pre-spawn HEAD, + branch=worktree-avtopodbor, файлы в scope. +- Коммит-сообщения на русском + `Co-Authored-By: Claude Opus 4.8 (1M context) `. +- Темп: владелец сказал **«до конца без остановок»** — продолжать автономно бэкенд→фронт→финал. + +## 3. Состояние: СДЕЛАНО (закоммичено, HEAD=78d19654 на момент записки) +Фундамент: rebase на gitea/main; RLS-харднинг v8.57; выделенная БД liderra_testing_apk. +Готовые задачи (все тесты зелёные на liderra_testing_apk): +- **Task 1-3**: миграции `autopodbor_runs`/`autopodbor_competitors`/`autopodbor_sources` + RLS (CHANGELOG v8.58). +- **Task 4**: модели `AutopodborRun`/`AutopodborCompetitor`/`AutopodborSource`. +- **Task 5**: `BalanceTransaction::TYPE_AUTOPODBOR_CHARGE='autopodbor_charge'` + сид-миграция 4 ключей `system_settings` + (`autopodbor_enabled`=0, `autopodbor_price_search_rub`=0, `autopodbor_price_study_rub`=0, `autopodbor_max_competitors`=15) + + миграция расширения CHECK-констрейнта `balance_transactions_type_check`. +- **Task 6**: 6 DTO в `app/app/Services/Autopodbor/Agent/Dto/`. +- **Task 7**: интерфейс `CompetitorAgent` + `FakeCompetitorAgent` + binding в `app/bootstrap/providers.php` + (`AutopodborServiceProvider`). NB: в `app/tests/Pest.php` добавлена строка + `pest()->extend(TestCase::class)->in('Unit/Autopodbor')` (нужно для unit-тестов с контейнером). +- **Task 8**: `AutopodborNormalizer` (domainHead/phone/sourceKey/competitorKey). +- **Task 9**: `AutopodborDedup` (existingProjectId/dedupSources/dedupCompetitors). +- **Task 10**: `AutopodborChargeService::chargeForRun` (идемпотентно по `balance_transaction_id`, bcmath, + только при успехе; bcmath проверено вручную; полный billing-audit — на финале Task 29). +- **Task 12**: `RunAutopodborSearchJob` (шаг 1: подбор → дедуп → сохранить → списать → done; пусто→empty без списания). +- **Task 13**: `RunAutopodborStudyJob` (шаг 2: изучение → источники нормализованные → списать → конкурент studied → done). + +## 4. ПОПРАВКИ к плану (узнано из реального кода — соблюдать!) +- RLS-GUC = **`app.current_tenant_id`** (НЕ `app.tenant_id`), форма ХАРДНИНГ: + `USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)`. +- Tenant-контекст в ДЖОБАХ: в начале `handle()` — `DB::statement("SET app.current_tenant_id = ".$run->tenant_id)` + (СЕССИОННО, не LOCAL — чтобы видели и status-update, и вложенная транзакция charge). Класса `TenantContext::set` НЕТ. +- Tenant-контекст в ТЕСТАХ с прямыми моделями: `DB::statement("SET LOCAL app.current_tenant_id = ".$tenant->id)` + после `Tenant::factory()->create()`, плюс `uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class)`. + HTTP-тесты — `$this->actingAs($user)->...` (middleware сам ставит GUC). `User::factory()->create(['tenant_id'=>$tenant->id])`. +- `App\Support\PhoneNormalizer::normalize()` возвращает **`+7XXXXXXXXXX`** → в нормализаторе срезаем `+` (получаем `7xxxxxxxxxx`). +- `App\Exceptions\Billing\InsufficientBalanceException` — конструктор `(int $priceKopecks, string $balanceRub)` (НЕ без аргументов). +- `BalanceTransaction`: `$timestamps=false` → нужен явный `created_at`; поля `amount_leads`/`balance_leads_after` = null (как в LedgerService). +- `BalanceTransaction` типы ограничены CHECK-констрейнтом `balance_transactions_type_check` — новый тип уже добавлен миграцией Task 10. +- `SystemSettings::get()/bool()` — СТАТИЧЕСКИЕ методы. Сид настроек — через `SystemSetting::updateOrCreate(['key'=>..],['value'=>..,'type'=>..])`. +- RefreshDatabase глобально ВЫКЛЮЧЕН (Pest.php). Для записи в БД — `uses(DatabaseTransactions::class)` (+SharesSupplierPdo если пишешь supplier-flow). +- Образец ledger: `app/app/Services/Billing/LedgerService.php`; образец money-теста: `tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php`. + +## 5. ОСТАЛОСЬ — порядок (ВАЖЕН reorder джоб перед RunService) +Бэкенд: +- **Task 14**: `RunAutopodborResolveJob` (резолв по названию → конкуренты origin='resolve', search_run_id=run.id, БЕЗ списания; пусто→empty). + Мирроль SearchJob, но `agent->resolveByName(ResolveByNameRequest(name,region))`, сохранять кандидатов как competitors, chargeForRun НЕ звать. +- **Task 11**: `AutopodborRunService` (`startSearch/startStudy/startResolve`) + исключение `App\Exceptions\Autopodbor\RunInFlightException`. + Гейт баланса перед стартом (SystemSettings::get цены vs tenants.balance_rub, bccomp); один in-flight на (tenant,kind); + диспатч соответствующей джобы. startStudy: если competitor.studied_at!=null → вернуть его studyRun без оплаты. + startResolve — без гейта (бесплатно). Тест с `Queue::fake()` + `Queue::assertPushed(...)` (классы джоб ТЕПЕРЬ существуют). +- **Task 15**: `AutopodborProjectCreator::createFromSources($tenantId,$sourceIds,$common,$launch)` — переиспользует + `App\Services\Project\ProjectService::create`. Имя проекта = имя конкурента + значок (call+real→' ✓', call+substitute→' 🎭', site→без значка); + коллизия имени (`assertNameUnique` бросает 422) → тихий числовой суффикс ' 2',' 3'. Привязать `source.created_project_id`. + СВЕРИТЬ сигнатуру `ProjectService::create(Tenant $tenant, array $data)` и трактовку `force_save_blocked` по реальному коду. +- **Task 16**: `SupplierProjectDto::stripBadge(string): string` — срезать хвостовой значок/эмодзи; применить там, где `dto.name` собирается для поставщика (синк). СВЕРИТЬ, где формируется name для `app/playwright/manage-project.js`. +- **Task 17**: `AutopodborController` + роуты `routes/api.php` (под клиентским auth-middleware, рядом с projects) + Resources. + Эндпоинты: GET `autopodbor/state` (enabled+runs+prices), GET `runs/{run}`, GET `competitors/{competitor}` (источники + живое `existing_project_id` через AutopodborDedup), + POST `search`/`study`/`resolve`/`manual-study`/`sources/manual`/`projects`. Мапить RunInFlightException→409, InsufficientBalanceException→409. + Образец API-теста: `tests/Feature/Plan5/Projects/ProjectsStoreTest.php` (actingAs+postJson). + +Фронтенд (ПЕРЕД ним: `cd app && npm install` — ТЯЖЕЛО, возможен OOM; если упадёт — сообщить владельцу): +- **Task 18**: `app/resources/js/api/autopodbor.ts` + `app/resources/js/stores/autopodborStore.ts` (+ vitest). +- **Task 19**: `AutopodborView.vue` + маршрут роутера + пункт меню «Автоподбор конкурентов» NEW (группа «Работа», видимость=enabled). +- **Task 20-26**: экраны Vue 1:1 по прототипу (Entry+«Продолжить начатое», AutoForm, ManualForm+ResolveConfirm, + Loading/Empty/Done, CompetitorList, SourceList+живое состояние+«Изменить проект», CreateProjects). Стиль Forest. + Переиспользовать `ProjectLimitOverloadDialog` для 409 при «Создать и запустить»; `NewProjectDialog.vue` (mode=edit) для «Изменить проект». +Финал: +- **Task 27**: проверить/добавить ключи `autopodbor_*` в админ-UI настроек (`SystemSettingEditDialog.vue` / `AdminSystemSettingsController`). +- **Task 28**: сквозной UI-smoke на заглушке (Playwright), скриншоты (НЕ коммитить *-LOCAL). +- **Task 29**: `/regression full`; `rls-reviewer` по 3 таблицам; **billing-audit** по `AutopodborChargeService`; + синхронизировать `db/schema.sql` (header-метрики + CREATE TABLE 3 таблиц) с CHANGELOG v8.58; self-review; память; finishing-a-development-branch (после «эскейп»). + +## 6. Хвосты/риски +- `db/schema.sql` (канон-дамп) ещё НЕ синхронизирован с 3 новыми таблицами — сделать на Task 29 (тест-БД строится из миграций, поэтому не блокирует, но канон должен сойтись; счётчик RLS-политик 44→47). +- Сид-миграция настроек и CHECK-констрейнт-миграция применены к liderra_testing_apk; на боевую НЕ катить (это отдельный выкат потом). +- Движок — заглушка `FakeCompetitorAgent`; реальный ИИ-движок делает отдельная сессия, подключается заменой binding в `AutopodborServiceProvider`. +- npm для фронта ещё НЕ ставился. + +## 7. Первое действие после компакта +1. Прочитать §0 список. +2. `git -C "" log --oneline gitea/main..HEAD` — убедиться HEAD=78d19654 (или новее) и дерево чистое. +3. Pre-spawn §A (branch/HEAD), затем дispatch субагента на **Task 14** (RunAutopodborResolveJob) по §5. From c9c42ef1c9961a9dd751abe4825fea393632ad4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 14:16:14 +0300 Subject: [PATCH 022/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B4=D0=B6=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=20=D1=80=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Autopodbor/RunAutopodborResolveJob.php | 79 +++++++++++++++++++ .../RunAutopodborResolveJobTest.php | 49 ++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php create mode 100644 app/tests/Feature/Autopodbor/RunAutopodborResolveJobTest.php diff --git a/app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php b/app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php new file mode 100644 index 00000000..d9cf8cd1 --- /dev/null +++ b/app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php @@ -0,0 +1,79 @@ +runId); + + // Выставляем tenant-контекст сессионно + DB::statement('SET app.current_tenant_id = '.$run->tenant_id); + + $run->update(['status' => 'running', 'started_at' => now()]); + + try { + $p = $run->params; + + $res = $agent->resolveByName(new ResolveByNameRequest( + name: $p['name'], + regionCode: (int) $run->region_code, + )); + + $unique = $dedup->dedupCompetitors($res->candidates); + + if ($unique === []) { + $run->update(['status' => 'empty', 'finished_at' => now()]); + return; + } + + foreach ($unique as $c) { + AutopodborCompetitor::create([ + 'tenant_id' => $run->tenant_id, + 'search_run_id' => $run->id, + 'name' => $c['name'], + 'description' => $c['description'] ?? null, + 'is_federal' => (bool) ($c['is_federal'] ?? false), + 'relevance_pct' => null, + 'origin' => 'resolve', + 'site_url' => $c['site_url'] ?? null, + 'directory_urls' => $c['directory_urls'] ?? [], + 'provenance' => $c['provenance'] ?? [], + 'dedup_key' => $c['dedup_key'], + ]); + } + + $run->update(['status' => 'done', 'finished_at' => now()]); + } catch (\Throwable $e) { + $run->update([ + 'status' => 'failed', + 'error_code' => substr($e->getMessage(), 0, 64), + 'finished_at' => now(), + ]); + throw $e; + } + } +} diff --git a/app/tests/Feature/Autopodbor/RunAutopodborResolveJobTest.php b/app/tests/Feature/Autopodbor/RunAutopodborResolveJobTest.php new file mode 100644 index 00000000..522f22aa --- /dev/null +++ b/app/tests/Feature/Autopodbor/RunAutopodborResolveJobTest.php @@ -0,0 +1,49 @@ +create(['balance_rub' => '100000.00']); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + $run = AutopodborRun::create([ + 'tenant_id' => $tenant->id, + 'kind' => 'resolve', + 'status' => 'queued', + 'region_code' => 16, + 'params' => ['name' => 'Окна Комфорт'], + ]); + + app()->call([new RunAutopodborResolveJob($run->id), 'handle']); + + expect($run->fresh()->status)->toBe('done') + ->and($run->fresh()->price_rub_charged)->toBeNull() + ->and(AutopodborCompetitor::where('search_run_id', $run->id)->where('origin', 'resolve')->count())->toBeGreaterThan(0) + ->and((string) $tenant->fresh()->balance_rub)->toBe('100000.00'); +}); + +it('пустой резолв: status=empty без списания', function () { + app()->bind(\App\Services\Autopodbor\Agent\CompetitorAgent::class, \Tests\Doubles\EmptyCompetitorAgent::class); + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + $run = AutopodborRun::create([ + 'tenant_id' => $tenant->id, + 'kind' => 'resolve', + 'status' => 'queued', + 'region_code' => 16, + 'params' => ['name' => 'Несуществующая Фирма XYZ'], + ]); + + app()->call([new RunAutopodborResolveJob($run->id), 'handle']); + + expect($run->fresh()->status)->toBe('empty') + ->and($run->fresh()->price_rub_charged)->toBeNull(); +}); From bfb7e5be31b94b514058b90df50d4ae58e0607d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 14:20:54 +0300 Subject: [PATCH 023/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20RunService=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D1=82,=20=D0=B3=D0=B5=D0=B9=D1=82=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=BB=D0=B0=D0=BD=D1=81=D0=B0,=20=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=20in-flight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Autopodbor/RunInFlightException.php | 7 ++ .../Autopodbor/AutopodborRunService.php | 115 ++++++++++++++++++ .../Autopodbor/AutopodborRunServiceTest.php | 62 ++++++++++ 3 files changed, 184 insertions(+) create mode 100644 app/app/Exceptions/Autopodbor/RunInFlightException.php create mode 100644 app/app/Services/Autopodbor/AutopodborRunService.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborRunServiceTest.php diff --git a/app/app/Exceptions/Autopodbor/RunInFlightException.php b/app/app/Exceptions/Autopodbor/RunInFlightException.php new file mode 100644 index 00000000..fc16af7e --- /dev/null +++ b/app/app/Exceptions/Autopodbor/RunInFlightException.php @@ -0,0 +1,7 @@ +where('kind', $kind) + ->whereIn('status', ['queued', 'running']) + ->exists(); + + if ($exists) { + throw new RunInFlightException(); + } + } + + private function priceGate(int $tenantId, string $key): string + { + $price = (string) (SystemSettings::get($key) ?? '0'); + $balance = (string) Tenant::whereKey($tenantId)->value('balance_rub'); + + if (bccomp($balance, $price, 2) < 0) { + throw new InsufficientBalanceException( + priceKopecks: (int) bcmul($price, '100', 0), + balanceRub: $balance, + ); + } + + return $price; + } + + public function startSearch( + int $tenantId, + int $regionCode, + array $examples, + array $aboutSelf, + bool $includeFederal, + ): AutopodborRun { + $this->assertNoInFlight($tenantId, 'search'); + $this->priceGate($tenantId, 'autopodbor_price_search_rub'); + + $run = AutopodborRun::create([ + 'tenant_id' => $tenantId, + 'kind' => 'search', + 'status' => 'queued', + 'region_code' => $regionCode, + 'params' => [ + 'examples' => $examples, + 'about_self' => $aboutSelf, + 'include_federal' => $includeFederal, + ], + ]); + + RunAutopodborSearchJob::dispatch($run->id); + + return $run; + } + + public function startStudy(int $tenantId, int $competitorId): AutopodborRun + { + $comp = AutopodborCompetitor::where('tenant_id', $tenantId)->findOrFail($competitorId); + + if ($comp->studied_at !== null) { + return $comp->studyRun; + } + + $this->assertNoInFlight($tenantId, 'study'); + $this->priceGate($tenantId, 'autopodbor_price_study_rub'); + + $run = AutopodborRun::create([ + 'tenant_id' => $tenantId, + 'kind' => 'study', + 'status' => 'queued', + 'region_code' => $comp->searchRun?->region_code, + 'competitor_id' => $comp->id, + 'params' => [], + ]); + + RunAutopodborStudyJob::dispatch($run->id); + + return $run; + } + + public function startResolve(int $tenantId, string $name, int $regionCode): AutopodborRun + { + $this->assertNoInFlight($tenantId, 'resolve'); + // resolve бесплатный — без priceGate + + $run = AutopodborRun::create([ + 'tenant_id' => $tenantId, + 'kind' => 'resolve', + 'status' => 'queued', + 'region_code' => $regionCode, + 'params' => ['name' => $name], + ]); + + RunAutopodborResolveJob::dispatch($run->id); + + return $run; + } +} diff --git a/app/tests/Feature/Autopodbor/AutopodborRunServiceTest.php b/app/tests/Feature/Autopodbor/AutopodborRunServiceTest.php new file mode 100644 index 00000000..b26ef6c9 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborRunServiceTest.php @@ -0,0 +1,62 @@ +create(['balance_rub' => '100000.00']); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']); + + $run = app(AutopodborRunService::class)->startSearch($tenant->id, 16, ['okna.ru'], [], true); + + expect($run->kind)->toBe('search')->and($run->status)->toBe('queued'); + Queue::assertPushed(RunAutopodborSearchJob::class); +}); + +it('не стартует второй in-flight search того же tenant', function () { + Queue::fake(); + $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' => '500', 'type' => 'decimal']); + $svc = app(AutopodborRunService::class); + $svc->startSearch($tenant->id, 16, ['okna.ru'], [], true); + + expect(fn () => $svc->startSearch($tenant->id, 16, ['okna.ru'], [], true)) + ->toThrow(\App\Exceptions\Autopodbor\RunInFlightException::class); +}); + +it('гейтит по балансу: нехватка на цену search → InsufficientBalanceException', function () { + Queue::fake(); + $tenant = Tenant::factory()->create(['balance_rub' => '100.00']); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']); + + expect(fn () => app(AutopodborRunService::class)->startSearch($tenant->id, 16, ['okna.ru'], [], true)) + ->toThrow(\App\Exceptions\Billing\InsufficientBalanceException::class); + Queue::assertNotPushed(RunAutopodborSearchJob::class); +}); + +it('startResolve бесплатный: стартует даже при нулевом балансе, ставит resolve-джобу', function () { + Queue::fake(); + $tenant = Tenant::factory()->create(['balance_rub' => '0.00']); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + + $run = app(AutopodborRunService::class)->startResolve($tenant->id, 'Окна Комфорт', 16); + + expect($run->kind)->toBe('resolve')->and($run->status)->toBe('queued'); + Queue::assertPushed(RunAutopodborResolveJob::class); +}); From 0599b67605d7dbc34de3049dbae5b3d151ba6d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 14:27:04 +0300 Subject: [PATCH 024/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=81=D0=BE=D0=B7=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D0=B8=D0=B7=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20(=D0=B8=D0=BC=D1=8F+?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D1=87=D0=BE=D0=BA+=D1=81=D1=83=D1=84=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Autopodbor/AutopodborProjectCreator.php | 73 +++++++++++++++++++ .../AutopodborProjectCreatorTest.php | 49 +++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 app/app/Services/Autopodbor/AutopodborProjectCreator.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborProjectCreatorTest.php diff --git a/app/app/Services/Autopodbor/AutopodborProjectCreator.php b/app/app/Services/Autopodbor/AutopodborProjectCreator.php new file mode 100644 index 00000000..e4f107f1 --- /dev/null +++ b/app/app/Services/Autopodbor/AutopodborProjectCreator.php @@ -0,0 +1,73 @@ +whereIn('id', $sourceIds)->with('competitor')->get(); + + $created = []; + foreach ($sources as $src) { + $name = $this->uniqueName($tenantId, $this->displayName($src)); + $project = $this->projects->create($tenant, [ + 'name' => $name, + 'signal_type' => $src->signal_type, + 'signal_identifier' => $src->identifier, + 'daily_limit_target' => $common['daily_limit_target'], + 'regions' => $common['regions'], + 'delivery_days_mask' => $common['delivery_days_mask'], + ]); + if (! $launch) { + $project->update(['is_active' => false, 'paused_at' => now()]); + $project = $project->fresh(); + } + $src->update(['created_project_id' => $project->id]); + $created[] = $project; + } + + return $created; + } + + private function displayName(AutopodborSource $s): string + { + $n = $s->competitor->name; + if ($s->signal_type === 'call' && $s->phone_kind === 'real') { + return $n.' ✓'; + } + if ($s->signal_type === 'call' && $s->phone_kind === 'substitute') { + return $n.' 🎭'; + } + + return $n; + } + + private function uniqueName(int $tenantId, string $base): string + { + $name = $base; + $i = 1; + while (Project::where('tenant_id', $tenantId)->where('name', $name)->exists()) { + $i++; + $name = $base.' '.$i; + } + + return $name; + } +} diff --git a/app/tests/Feature/Autopodbor/AutopodborProjectCreatorTest.php b/app/tests/Feature/Autopodbor/AutopodborProjectCreatorTest.php new file mode 100644 index 00000000..6c6d78d0 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborProjectCreatorTest.php @@ -0,0 +1,49 @@ +create(['balance_rub' => '500000.00']); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16]); + $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'Окна Комфорт', 'dedup_key' => 'okna']); + $s1 = AutopodborSource::create(['tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, 'signal_type' => 'site', 'identifier' => 'okna-komfort.ru', 'dedup_key' => 'site:okna-komfort.ru']); + $s2 = AutopodborSource::create(['tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, 'signal_type' => 'call', 'identifier' => '78432001122', 'phone_kind' => 'real', 'dedup_key' => 'call:78432001122']); + + $projects = app(AutopodborProjectCreator::class)->createFromSources($tenant->id, [$s1->id, $s2->id], [ + 'regions' => [16], 'daily_limit_target' => 20, 'delivery_days_mask' => 127, + ], launch: false); + + expect($projects)->toHaveCount(2) + ->and(Project::where('tenant_id', $tenant->id)->where('signal_identifier', 'okna-komfort.ru')->exists())->toBeTrue() + ->and($s1->fresh()->created_project_id)->not->toBeNull(); +}); + +it('разруливает коллизию одинаковых имён суффиксом', function () { + Queue::fake(); + $tenant = Tenant::factory()->create(['balance_rub' => '500000.00']); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16]); + $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'Окна Комфорт', 'dedup_key' => 'okna2']); + $s1 = AutopodborSource::create(['tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, 'signal_type' => 'site', 'identifier' => 'a.ru', 'dedup_key' => 'site:a.ru']); + $s2 = AutopodborSource::create(['tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, 'signal_type' => 'site', 'identifier' => 'b.ru', 'dedup_key' => 'site:b.ru']); + + app(AutopodborProjectCreator::class)->createFromSources($tenant->id, [$s1->id, $s2->id], ['regions' => [16], 'daily_limit_target' => 20, 'delivery_days_mask' => 127], false); + + $names = Project::where('tenant_id', $tenant->id)->pluck('name')->all(); + expect($names)->toContain('Окна Комфорт') + ->and(collect($names)->unique()->count())->toBe(2); +}); From ed900aba556e59ae10831869ed71bff0a5c819fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 14:33:11 +0300 Subject: [PATCH 025/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20stripBadge=20=E2=80=94=20?= =?UTF-8?q?=D1=87=D0=B8=D1=81=D1=82=D0=BE=D0=B5=20=D0=B8=D0=BC=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D0=BA=D1=83=D1=80=D0=B5=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=B7=D0=BD=D0=B0=D1=87=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Autopodbor/AutopodborNormalizer.php | 16 ++++++++++ .../AutopodborNormalizerStripBadgeTest.php | 30 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 app/tests/Unit/Autopodbor/AutopodborNormalizerStripBadgeTest.php diff --git a/app/app/Services/Autopodbor/AutopodborNormalizer.php b/app/app/Services/Autopodbor/AutopodborNormalizer.php index e5c761cd..7d9cb9e4 100644 --- a/app/app/Services/Autopodbor/AutopodborNormalizer.php +++ b/app/app/Services/Autopodbor/AutopodborNormalizer.php @@ -71,6 +71,22 @@ final class AutopodborNormalizer return $type . ':' . $id; } + /** + * Срезает хвостовой значок (✓ или 🎭) вместе с пробелами перед ним. + * Если значка нет — строка возвращается без изменений. + * Примеры: + * 'Окна Комфорт ✓' → 'Окна Комфорт' + * 'Окна Комфорт 🎭' → 'Окна Комфорт' + * 'Окна Комфорт' → 'Окна Комфорт' + * 'Балкон-Сервис 16' → 'Балкон-Сервис 16' + */ + public function stripBadge(string $name): string + { + // Срезаем ровно один хвостовой значок (✓ или 🎭) вместе с пробелами перед ним. + // Используем mb-безопасный regex с флагом u (эмодзи 🎭 — 4-байтный). + return preg_replace('/\s*(?:\x{2713}|\x{1F3AD})\s*$/u', '', $name) ?? $name; + } + /** * Строит dedup-ключ для конкурента. * Если есть сайт — «site:домен», иначе «name:имя_в_нижнем_регистре». diff --git a/app/tests/Unit/Autopodbor/AutopodborNormalizerStripBadgeTest.php b/app/tests/Unit/Autopodbor/AutopodborNormalizerStripBadgeTest.php new file mode 100644 index 00000000..ae818c01 --- /dev/null +++ b/app/tests/Unit/Autopodbor/AutopodborNormalizerStripBadgeTest.php @@ -0,0 +1,30 @@ +stripBadge('Окна Комфорт ✓'))->toBe('Окна Комфорт'); +}); + +it('срезает значок подменного номера', function () { + $n = new AutopodborNormalizer(); + expect($n->stripBadge('Окна Комфорт 🎭'))->toBe('Окна Комфорт'); +}); + +it('имя без значка не меняется', function () { + $n = new AutopodborNormalizer(); + expect($n->stripBadge('Окна Комфорт'))->toBe('Окна Комфорт'); +}); + +it('имя сайта (без значка) с внутренними пробелами сохраняется', function () { + $n = new AutopodborNormalizer(); + expect($n->stripBadge('Балкон-Сервис 16'))->toBe('Балкон-Сервис 16'); +}); + +it('числовой суффикс не трогается (значка нет)', function () { + $n = new AutopodborNormalizer(); + expect($n->stripBadge('Окна Комфорт 2'))->toBe('Окна Комфорт 2'); +}); From 2ef24de3f9fa03ee05a030ec37f2c18723915825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 14:42:41 +0300 Subject: [PATCH 026/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20API=20=D1=8F=D0=B4=D1=80?= =?UTF-8?q?=D0=BE=20=E2=80=94=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D0=BB=D0=B5=D1=80,=20=D1=80=D0=BE=D1=83=D1=82=D1=8B,=20=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=83=D1=80=D1=81=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/Api/AutopodborController.php | 238 ++++++++++++++++++ .../Autopodbor/CompetitorResource.php | 28 +++ .../Http/Resources/Autopodbor/RunResource.php | 31 +++ .../Resources/Autopodbor/SourceResource.php | 25 ++ app/routes/web.php | 11 + .../Feature/Autopodbor/AutopodborApiTest.php | 66 +++++ 6 files changed, 399 insertions(+) create mode 100644 app/app/Http/Controllers/Api/AutopodborController.php create mode 100644 app/app/Http/Resources/Autopodbor/CompetitorResource.php create mode 100644 app/app/Http/Resources/Autopodbor/RunResource.php create mode 100644 app/app/Http/Resources/Autopodbor/SourceResource.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborApiTest.php diff --git a/app/app/Http/Controllers/Api/AutopodborController.php b/app/app/Http/Controllers/Api/AutopodborController.php new file mode 100644 index 00000000..34a16a91 --- /dev/null +++ b/app/app/Http/Controllers/Api/AutopodborController.php @@ -0,0 +1,238 @@ +user()->tenant_id; + + $runs = AutopodborRun::where('tenant_id', $tenantId) + ->orderByDesc('id') + ->limit(20) + ->get(); + + return response()->json([ + 'enabled' => SystemSettings::bool('autopodbor_enabled'), + 'runs' => RunResource::collection($runs), + 'prices' => [ + 'search' => (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0'), + 'study' => (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0'), + ], + ]); + } + + /** GET /api/autopodbor/runs/{run} */ + public function run(Request $request, int $run): JsonResponse + { + $r = AutopodborRun::where('tenant_id', $request->user()->tenant_id) + ->findOrFail($run); + + return response()->json(['data' => new RunResource($r)]); + } + + /** GET /api/autopodbor/competitors/{competitor} */ + public function competitor(Request $request, int $competitor, AutopodborDedup $dedup): JsonResponse + { + $tenantId = $request->user()->tenant_id; + + $comp = AutopodborCompetitor::where('tenant_id', $tenantId) + ->with('sources') + ->findOrFail($competitor); + + $sources = $comp->sources->map(function ($s) use ($dedup) { + $existingProjectId = $s->created_project_id + ?? $dedup->existingProjectId($s->tenant_id, $s->signal_type, $s->identifier); + + return array_merge( + (new SourceResource($s))->resolve(), + ['existing_project_id' => $existingProjectId] + ); + }); + + return response()->json([ + 'data' => new CompetitorResource($comp), + 'sources' => $sources, + ]); + } + + /** POST /api/autopodbor/search */ + public function search(Request $request, AutopodborRunService $svc): JsonResponse + { + $v = $request->validate([ + 'region_code' => 'required|integer', + 'examples' => 'array', + 'about_self' => 'array', + 'include_federal' => 'boolean', + ]); + + try { + $run = $svc->startSearch( + $request->user()->tenant_id, + (int) $v['region_code'], + $v['examples'] ?? [], + $v['about_self'] ?? [], + (bool) ($v['include_federal'] ?? false), + ); + + return response()->json(['data' => new RunResource($run)], 201); + } catch (RunInFlightException) { + return response()->json(['error' => 'run_in_flight'], 409); + } catch (InsufficientBalanceException) { + return response()->json(['error' => 'balance_insufficient'], 409); + } + } + + /** POST /api/autopodbor/study */ + public function study(Request $request, AutopodborRunService $svc): JsonResponse + { + $v = $request->validate([ + 'competitor_id' => 'required|integer', + ]); + + try { + $run = $svc->startStudy( + $request->user()->tenant_id, + (int) $v['competitor_id'], + ); + + return response()->json(['data' => new RunResource($run)], 201); + } catch (RunInFlightException) { + return response()->json(['error' => 'run_in_flight'], 409); + } catch (InsufficientBalanceException) { + return response()->json(['error' => 'balance_insufficient'], 409); + } + } + + /** POST /api/autopodbor/resolve */ + public function resolve(Request $request, AutopodborRunService $svc): JsonResponse + { + $v = $request->validate([ + 'name' => 'required|string', + 'region_code' => 'required|integer', + ]); + + try { + $run = $svc->startResolve( + $request->user()->tenant_id, + $v['name'], + (int) $v['region_code'], + ); + + return response()->json(['data' => new RunResource($run)], 201); + } catch (RunInFlightException) { + return response()->json(['error' => 'run_in_flight'], 409); + } + } + + /** POST /api/autopodbor/projects */ + public function createProjects(Request $request, AutopodborProjectCreator $creator): JsonResponse + { + $v = $request->validate([ + 'source_ids' => 'required|array', + 'source_ids.*' => 'integer', + 'regions' => 'array', + 'regions.*' => 'integer', + 'daily_limit_target' => 'required|integer', + 'delivery_days_mask' => 'required|integer', + 'launch' => 'boolean', + ]); + + $tenant = $request->user()->tenant; + $launch = (bool) ($v['launch'] ?? false); + + // Балансовый preflight при launch=true + if ($launch) { + $existingLimit = (int) Project::where('tenant_id', $tenant->id) + ->where('is_active', true) + ->whereNull('preflight_blocked_at') + ->sum('daily_limit_target'); + + $wouldBe = $existingLimit + count($v['source_ids']) * (int) $v['daily_limit_target']; + + $preflight = $this->runPreflight($tenant, $wouldBe); + + if (! $preflight['passes']) { + return response()->json([ + 'error' => 'balance_insufficient', + 'current_balance_rub' => (string) $tenant->balance_rub, + 'current_capacity_leads' => $preflight['capacity_leads'], + 'would_be_required_leads' => $wouldBe, + 'deficit_leads' => $preflight['deficit_leads'], + ], 409); + } + } + + $projects = $creator->createFromSources( + $tenant->id, + $v['source_ids'], + [ + 'regions' => $v['regions'] ?? [], + 'daily_limit_target' => (int) $v['daily_limit_target'], + 'delivery_days_mask' => (int) $v['delivery_days_mask'], + ], + $launch, + ); + + return response()->json([ + 'data' => collect($projects)->map(fn ($p) => ['id' => $p->id, 'name' => $p->name])->all(), + ], 201); + } + + /** + * Копия helper'а из ProjectController — балансовый preflight. + * + * @return array{passes: bool, capacity_leads: int, deficit_leads: int} + */ + private function runPreflight(\App\Models\Tenant $tenant, int $requiredLeads): array + { + $tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow')); + + // Safe fallback: без активных pricing_tiers биллинг не настроен — + // preflight пропускаем (legacy-окружения / тесты). + if ($tiers->isEmpty()) { + return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0]; + } + + $result = (new BalancePreflightService)->evaluate( + balanceRub: (string) $tenant->balance_rub, + deliveredInMonth: (int) $tenant->delivered_in_month, + requiredLeads: $requiredLeads, + tiers: $tiers, + ); + + return [ + 'passes' => $result->passes, + 'capacity_leads' => $result->capacityLeads, + 'deficit_leads' => $result->deficitLeads, + ]; + } +} diff --git a/app/app/Http/Resources/Autopodbor/CompetitorResource.php b/app/app/Http/Resources/Autopodbor/CompetitorResource.php new file mode 100644 index 00000000..50214f05 --- /dev/null +++ b/app/app/Http/Resources/Autopodbor/CompetitorResource.php @@ -0,0 +1,28 @@ + $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'is_federal' => $this->is_federal, + 'relevance_pct' => $this->relevance_pct, + 'origin' => $this->origin, + 'site_url' => $this->site_url, + 'directory_urls' => $this->directory_urls, + 'studied_at' => $this->studied_at?->toIso8601String(), + 'study_run_id' => $this->study_run_id, + 'search_run_id' => $this->search_run_id, + ]; + } +} diff --git a/app/app/Http/Resources/Autopodbor/RunResource.php b/app/app/Http/Resources/Autopodbor/RunResource.php new file mode 100644 index 00000000..29f504a5 --- /dev/null +++ b/app/app/Http/Resources/Autopodbor/RunResource.php @@ -0,0 +1,31 @@ + $this->id, + 'kind' => $this->kind, + 'status' => $this->status, + 'region_code' => $this->region_code, + 'params' => $this->params, + 'price_rub_charged' => $this->price_rub_charged, + 'error_code' => $this->error_code, + 'competitors_count' => AutopodborCompetitor::where('search_run_id', $this->id)->count(), + 'sources_count' => AutopodborSource::where('study_run_id', $this->id)->count(), + 'started_at' => $this->started_at?->toIso8601String(), + 'finished_at' => $this->finished_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/app/Http/Resources/Autopodbor/SourceResource.php b/app/app/Http/Resources/Autopodbor/SourceResource.php new file mode 100644 index 00000000..387a531b --- /dev/null +++ b/app/app/Http/Resources/Autopodbor/SourceResource.php @@ -0,0 +1,25 @@ + $this->id, + 'competitor_id' => $this->competitor_id, + 'signal_type' => $this->signal_type, + 'identifier' => $this->identifier, + 'phone_kind' => $this->phone_kind, + 'provenance_url' => $this->provenance_url, + 'provenance_label' => $this->provenance_label, + 'created_project_id' => $this->created_project_id, + ]; + } +} diff --git a/app/routes/web.php b/app/routes/web.php index d0e092f4..dab5e965 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -330,6 +330,17 @@ Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/projec Route::patch('/{id}/toggle-active', 'App\Http\Controllers\Api\ProjectController@toggleActive')->name('projects.toggle')->where('id', '[0-9]+'); }); +// Автоподбор конкурентов — клиентский API (Task 17a). +Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/autopodbor')->group(function () { + Route::get('/state', 'App\Http\Controllers\Api\AutopodborController@state'); + Route::get('/runs/{run}', 'App\Http\Controllers\Api\AutopodborController@run')->where('run', '[0-9]+'); + Route::get('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@competitor')->where('competitor', '[0-9]+'); + Route::post('/search', 'App\Http\Controllers\Api\AutopodborController@search'); + Route::post('/study', 'App\Http\Controllers\Api\AutopodborController@study'); + Route::post('/resolve', 'App\Http\Controllers\Api\AutopodborController@resolve'); + Route::post('/projects', 'App\Http\Controllers\Api\AutopodborController@createProjects'); +}); + // Supplier-integration webhook (Plan 2/5, spec §5.1). // Platform-wide endpoint: единый {secret} в URL для всех лидов от crm.bp-gr.ru. // Auth: secret (system_settings.supplier_webhook_secret) + IP allowlist diff --git a/app/tests/Feature/Autopodbor/AutopodborApiTest.php b/app/tests/Feature/Autopodbor/AutopodborApiTest.php new file mode 100644 index 00000000..fb4a11e5 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborApiTest.php @@ -0,0 +1,66 @@ + Queue::fake()); + +it('GET /api/autopodbor/state — доступность, прогоны, цены', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + SystemSetting::updateOrCreate(['key' => 'autopodbor_enabled'], ['value' => '1', 'type' => 'bool']); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']); + + $this->actingAs($user)->getJson('/api/autopodbor/state') + ->assertOk() + ->assertJsonStructure(['enabled', 'runs', 'prices' => ['search', 'study']]); +}); + +it('POST /api/autopodbor/search — стартует прогон (201)', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '1', 'type' => 'decimal']); + + $this->actingAs($user)->postJson('/api/autopodbor/search', [ + 'region_code' => 16, 'examples' => ['okna.ru'], 'about_self' => [], 'include_federal' => true, + ])->assertCreated()->assertJsonPath('data.kind', 'search'); +}); + +it('GET /api/autopodbor/competitors/{id} — источники с existing_project_id', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'done','region_code'=>16,'params'=>[]]); + $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'name'=>'Окна Комфорт','dedup_key'=>'okna']); + AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>$run->id,'signal_type'=>'site','identifier'=>'okna-komfort.ru','dedup_key'=>'site:okna-komfort.ru']); + + $this->actingAs($user)->getJson("/api/autopodbor/competitors/{$comp->id}") + ->assertOk() + ->assertJsonStructure(['data'=>['id','name'], 'sources'=>[['id','signal_type','identifier','existing_project_id']]]); +}); + +it('POST /api/autopodbor/projects — создаёт проекты из источников (201)', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '500000.00']); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'done','region_code'=>16,'params'=>[]]); + $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'name'=>'Окна Комфорт','dedup_key'=>'okna']); + $s1 = AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>$run->id,'signal_type'=>'site','identifier'=>'okna-komfort.ru','dedup_key'=>'site:okna-komfort.ru']); + + $this->actingAs($user)->postJson('/api/autopodbor/projects', [ + 'source_ids'=>[$s1->id], 'regions'=>[16], 'daily_limit_target'=>20, 'delivery_days_mask'=>127, 'launch'=>false, + ])->assertCreated(); + expect(Project::where('tenant_id',$tenant->id)->where('signal_identifier','okna-komfort.ru')->exists())->toBeTrue(); +}); From 45fea2a9889e1fa247c30073fc2f2163ab2794f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 14:50:37 +0300 Subject: [PATCH 027/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=80=D1=83=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D1=8B=20=E2=80=94=20manual-study=20=D0=B8=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/Api/AutopodborController.php | 74 +++++++++++++++++++ .../Autopodbor/AutopodborRunService.php | 40 ++++++++++ app/routes/web.php | 2 + .../Autopodbor/AutopodborManualApiTest.php | 64 ++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 app/tests/Feature/Autopodbor/AutopodborManualApiTest.php diff --git a/app/app/Http/Controllers/Api/AutopodborController.php b/app/app/Http/Controllers/Api/AutopodborController.php index 34a16a91..a32e8aed 100644 --- a/app/app/Http/Controllers/Api/AutopodborController.php +++ b/app/app/Http/Controllers/Api/AutopodborController.php @@ -12,9 +12,11 @@ use App\Http\Resources\Autopodbor\RunResource; use App\Http\Resources\Autopodbor\SourceResource; use App\Models\AutopodborCompetitor; use App\Models\AutopodborRun; +use App\Models\AutopodborSource; use App\Models\Project; use App\Repositories\PricingTierRepository; use App\Services\Autopodbor\AutopodborDedup; +use App\Services\Autopodbor\AutopodborNormalizer; use App\Services\Autopodbor\AutopodborProjectCreator; use App\Services\Autopodbor\AutopodborRunService; use App\Services\Billing\BalancePreflightService; @@ -153,6 +155,78 @@ class AutopodborController extends Controller } } + /** POST /api/autopodbor/manual-study */ + public function manualStudy(Request $request, AutopodborRunService $svc, AutopodborNormalizer $norm): JsonResponse + { + $v = $request->validate([ + 'competitor_id' => ['nullable', 'integer'], + 'name' => ['nullable', 'string', 'max:255'], + 'site_url' => ['nullable', 'string', 'max:500'], + 'directory' => ['nullable', 'string', 'max:500'], + 'region_code' => ['required', 'integer'], + ]); + $uid = $request->user()->tenant_id; + + try { + if (! empty($v['competitor_id'])) { + $run = $svc->startStudy($uid, (int) $v['competitor_id']); + } else { + $site = ! empty($v['site_url']) ? $norm->domainHead($v['site_url']) : null; + $name = ! empty($v['name']) ? $v['name'] : ($site ?? 'Конкурент'); + if (empty($v['name']) && $site === null) { + return response()->json(['error' => 'name_or_site_required'], 422); + } + $run = $svc->startManualStudy($uid, [ + 'name' => $name, + 'site_url' => $site, + 'directory_urls' => ! empty($v['directory']) ? [$v['directory']] : [], + ], (int) $v['region_code']); + } + } catch (RunInFlightException) { + return response()->json(['error' => 'run_in_flight'], 409); + } catch (InsufficientBalanceException) { + return response()->json(['error' => 'balance_insufficient'], 409); + } + + return response()->json(['data' => new RunResource($run)], 201); + } + + /** POST /api/autopodbor/sources/manual */ + public function addManualSource(Request $request, AutopodborNormalizer $norm): JsonResponse + { + $v = $request->validate([ + 'competitor_id' => ['required', 'integer'], + 'raw' => ['required', 'string', 'max:500'], + ]); + $uid = $request->user()->tenant_id; + + $comp = AutopodborCompetitor::where('tenant_id', $uid)->findOrFail((int) $v['competitor_id']); + if ($comp->study_run_id === null) { + return response()->json(['error' => 'not_studied'], 422); + } + + $raw = trim($v['raw']); + $digits = preg_replace('/\D+/', '', $raw) ?? ''; + $isCall = strlen($digits) >= 10; + $signalType = $isCall ? 'call' : 'site'; + $identifier = $isCall ? $norm->phone($raw) : $norm->domainHead($raw); + + $source = AutopodborSource::updateOrCreate( + ['competitor_id' => $comp->id, 'dedup_key' => $norm->sourceKey($signalType, $raw)], + [ + 'tenant_id' => $uid, + 'study_run_id' => $comp->study_run_id, + 'signal_type' => $signalType, + 'identifier' => $identifier, + 'phone_kind' => $isCall ? 'real' : null, + 'provenance_url' => null, + 'provenance_label' => 'Добавлено вручную', + ], + ); + + return response()->json(['data' => new SourceResource($source)], 201); + } + /** POST /api/autopodbor/projects */ public function createProjects(Request $request, AutopodborProjectCreator $creator): JsonResponse { diff --git a/app/app/Services/Autopodbor/AutopodborRunService.php b/app/app/Services/Autopodbor/AutopodborRunService.php index ff513dc4..ee599f02 100644 --- a/app/app/Services/Autopodbor/AutopodborRunService.php +++ b/app/app/Services/Autopodbor/AutopodborRunService.php @@ -16,6 +16,10 @@ use App\Support\SystemSettings; final class AutopodborRunService { + public function __construct( + private AutopodborNormalizer $normalizer = new AutopodborNormalizer(), + ) {} + private function assertNoInFlight(int $tenantId, string $kind): void { $exists = AutopodborRun::where('tenant_id', $tenantId) @@ -95,6 +99,42 @@ final class AutopodborRunService return $run; } + /** + * Ручное изучение: создаём конкурента origin='manual' и сразу ставим study-прогон + * с ЯВНЫМ регионом (у ручного конкурента нет searchRun, откуда взять регион). + * + * @param array{name:string, site_url:?string, directory_urls:array} $competitorData + */ + public function startManualStudy(int $tenantId, array $competitorData, int $regionCode): AutopodborRun + { + $this->assertNoInFlight($tenantId, 'study'); + $this->priceGate($tenantId, 'autopodbor_price_study_rub'); + + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $tenantId, + 'search_run_id' => null, + 'name' => $competitorData['name'], + 'origin' => 'manual', + 'relevance_pct' => null, + 'site_url' => $competitorData['site_url'] ?? null, + 'directory_urls' => $competitorData['directory_urls'] ?? [], + 'dedup_key' => $this->normalizer->competitorKey($competitorData['name'], $competitorData['site_url'] ?? null), + ]); + + $run = AutopodborRun::create([ + 'tenant_id' => $tenantId, + 'kind' => 'study', + 'status' => 'queued', + 'region_code' => $regionCode, + 'competitor_id' => $comp->id, + 'params' => [], + ]); + + RunAutopodborStudyJob::dispatch($run->id); + + return $run; + } + public function startResolve(int $tenantId, string $name, int $regionCode): AutopodborRun { $this->assertNoInFlight($tenantId, 'resolve'); diff --git a/app/routes/web.php b/app/routes/web.php index dab5e965..634c8c9a 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -339,6 +339,8 @@ Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/autopo Route::post('/study', 'App\Http\Controllers\Api\AutopodborController@study'); Route::post('/resolve', 'App\Http\Controllers\Api\AutopodborController@resolve'); Route::post('/projects', 'App\Http\Controllers\Api\AutopodborController@createProjects'); + Route::post('/manual-study', 'App\Http\Controllers\Api\AutopodborController@manualStudy'); + Route::post('/sources/manual', 'App\Http\Controllers\Api\AutopodborController@addManualSource'); }); // Supplier-integration webhook (Plan 2/5, spec §5.1). diff --git a/app/tests/Feature/Autopodbor/AutopodborManualApiTest.php b/app/tests/Feature/Autopodbor/AutopodborManualApiTest.php new file mode 100644 index 00000000..5986360f --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborManualApiTest.php @@ -0,0 +1,64 @@ + Queue::fake()); + +it('manual-study по сайту создаёт ручного конкурента и study-прогон', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '1', 'type' => 'decimal']); + + $this->actingAs($user)->postJson('/api/autopodbor/manual-study', [ + 'site_url' => 'https://okna-komfort-kzn.ru/contacts', 'region_code' => 16, + ])->assertCreated()->assertJsonPath('data.kind', 'study'); + + DB::statement("SET app.current_tenant_id = ".$tenant->id); + expect(AutopodborCompetitor::where('tenant_id', $tenant->id)->where('origin', 'manual')->exists())->toBeTrue(); +}); + +it('manual-study без названия и без сайта → 422', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '1', 'type' => 'decimal']); + + $this->actingAs($user)->postJson('/api/autopodbor/manual-study', ['region_code' => 16]) + ->assertStatus(422); +}); + +it('sources/manual добавляет источник изученному конкуренту', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'done','region_code'=>16,'params'=>[]]); + $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'study_run_id'=>$run->id,'studied_at'=>now(),'name'=>'Окна Комфорт','origin'=>'auto','dedup_key'=>'okna']); + + $this->actingAs($user)->postJson('/api/autopodbor/sources/manual', [ + 'competitor_id' => $comp->id, 'raw' => 'okna-komfort.ru', + ])->assertCreated()->assertJsonPath('data.signal_type', 'site'); + + expect(AutopodborSource::where('competitor_id', $comp->id)->where('identifier', 'okna-komfort.ru')->exists())->toBeTrue(); +}); + +it('sources/manual телефоном создаёт call-источник', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'done','region_code'=>16,'params'=>[]]); + $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'study_run_id'=>$run->id,'studied_at'=>now(),'name'=>'Окна','origin'=>'auto','dedup_key'=>'okna2']); + + $this->actingAs($user)->postJson('/api/autopodbor/sources/manual', [ + 'competitor_id' => $comp->id, 'raw' => '+7 (843) 200-11-22', + ])->assertCreated()->assertJsonPath('data.signal_type', 'call'); +}); From a984db6b12dcedd4eea05c5bdeb4cc49dbfdac8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 14:58:51 +0300 Subject: [PATCH 028/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=84=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=20=E2=80=94=20api-=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=20=D0=B8=20Pinia=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/resources/js/api/autopodbor.ts | 117 ++++++++++++++ app/resources/js/stores/autopodborStore.ts | 170 +++++++++++++++++++++ app/tests/Frontend/autopodborStore.spec.ts | 73 +++++++++ 3 files changed, 360 insertions(+) create mode 100644 app/resources/js/api/autopodbor.ts create mode 100644 app/resources/js/stores/autopodborStore.ts create mode 100644 app/tests/Frontend/autopodborStore.spec.ts diff --git a/app/resources/js/api/autopodbor.ts b/app/resources/js/api/autopodbor.ts new file mode 100644 index 00000000..7bb8e090 --- /dev/null +++ b/app/resources/js/api/autopodbor.ts @@ -0,0 +1,117 @@ +import { apiClient } from './client'; + +// ——— 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; + 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; +} + +export interface CompetitorDto { + id: number; + name: string; + description: string | null; + is_federal: boolean; + relevance_pct: number | null; + origin: 'auto' | 'manual' | 'resolve'; + site_url: string | null; + directory_urls: string[]; + studied_at: string | null; + study_run_id: number | null; + search_run_id: number | null; +} + +export interface SourceDto { + id: number; + competitor_id: number; + signal_type: 'site' | 'call'; + identifier: string; + phone_kind: 'real' | 'substitute' | null; + provenance_url: string | null; + provenance_label: string | null; + created_project_id: number | null; + existing_project_id?: number | null; +} + +export interface StateDto { + enabled: boolean; + runs: RunDto[]; + prices: { search: string; study: string }; +} + +// ——— API functions ——— + +export async function fetchState(): Promise { + const { data } = await apiClient.get('/api/autopodbor/state'); + return data; +} + +export async function fetchRun(id: number): Promise { + const { data } = await apiClient.get<{ data: RunDto }>(`/api/autopodbor/runs/${id}`); + return data.data; +} + +export async function fetchCompetitor(id: number): Promise<{ competitor: CompetitorDto; sources: SourceDto[] }> { + const { data } = await apiClient.get<{ data: CompetitorDto; sources: SourceDto[] }>(`/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 { + const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/search', p); + return data.data; +} + +export async function startStudy(competitor_id: number): Promise { + 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 { + 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 { + 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 { + 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> { + const { data } = await apiClient.post<{ data: Array<{ id: number; name: string }> }>('/api/autopodbor/projects', p); + return data.data; +} diff --git a/app/resources/js/stores/autopodborStore.ts b/app/resources/js/stores/autopodborStore.ts new file mode 100644 index 00000000..3e397e95 --- /dev/null +++ b/app/resources/js/stores/autopodborStore.ts @@ -0,0 +1,170 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { + fetchState, + fetchRun, + fetchCompetitor, + startSearch, + startStudy, + startResolve, + startManualStudy, + addManualSource, + createProjects, + type RunDto, + type CompetitorDto, + type SourceDto, +} from '../api/autopodbor'; + +/** Задержка между тиками опроса (вынесена для тестируемости). */ +export const POLL_MS = 2500; + +const TERMINAL: ReadonlySet = new Set(['done', 'empty', 'failed']); + +export const useAutopodborStore = defineStore('autopodbor', () => { + const enabled = ref(false); + const prices = ref<{ search: string; study: string }>({ search: '0', study: '0' }); + const runs = ref([]); + const currentRun = ref(null); + const competitor = ref(null); + const sources = ref([]); + const loading = ref(false); + + // Internal poll handle — not exposed as reactive state. + let _pollTimeout: ReturnType | null = null; + + // ——— Actions ——— + + async function loadState(): Promise { + loading.value = true; + try { + const state = await fetchState(); + enabled.value = state.enabled; + prices.value = state.prices; + runs.value = state.runs; + } finally { + loading.value = false; + } + } + + async function search(p: { + region_code: number; + examples: string[]; + about_self: string[]; + include_federal: boolean; + }): Promise { + const run = await startSearch(p); + currentRun.value = run; + return run; + } + + async function study(competitorId: number): Promise { + const run = await startStudy(competitorId); + currentRun.value = run; + return run; + } + + async function resolve(p: { name: string; region_code: number }): Promise { + const run = await startResolve(p); + currentRun.value = run; + return run; + } + + async function manualStudy(p: { + competitor_id?: number; + name?: string; + site_url?: string; + directory?: string; + region_code: number; + }): Promise { + const run = await startManualStudy(p); + currentRun.value = run; + return run; + } + + async function loadCompetitor(id: number): Promise { + const result = await fetchCompetitor(id); + competitor.value = result.competitor; + sources.value = result.sources; + } + + async function addSource(p: { competitor_id: number; raw: string }): Promise { + const source = await addManualSource(p); + sources.value.push(source); + return source; + } + + async function makeProjects(p: { + source_ids: number[]; + regions: number[]; + daily_limit_target: number; + delivery_days_mask: number; + launch: boolean; + }): Promise> { + return await createProjects(p); + } + + /** + * Опрашивает run каждые POLL_MS мс до терминального статуса. + * + * Реализация: первый запрос выполняется немедленно (без начального setTimeout), + * далее — рекурсивный setTimeout(POLL_MS). Это обеспечивает детерминированное + * поведение с vi.useFakeTimers() + vi.runAllTimersAsync(). + * + * Возвращает Promise, который резолвится в финальный RunDto. + * stopPolling() отменяет ожидающий тайм-аут (текущий tick уже не прерывается). + */ + function pollRun(id: number, onTick?: (run: RunDto) => void): Promise { + stopPolling(); + + return new Promise((resolve) => { + async function tick(): Promise { + const run = await fetchRun(id); + currentRun.value = run; + onTick?.(run); + + if (TERMINAL.has(run.status)) { + resolve(run); + return; + } + + // Schedule next tick only if not already cancelled by stopPolling(). + _pollTimeout = setTimeout(() => { + _pollTimeout = null; + tick(); + }, POLL_MS); + } + + // Start immediately — no leading delay. + tick(); + }); + } + + function stopPolling(): void { + if (_pollTimeout !== null) { + clearTimeout(_pollTimeout); + _pollTimeout = null; + } + } + + return { + // State + enabled, + prices, + runs, + currentRun, + competitor, + sources, + loading, + // Actions + loadState, + search, + study, + resolve, + manualStudy, + loadCompetitor, + addSource, + makeProjects, + pollRun, + stopPolling, + }; +}); diff --git a/app/tests/Frontend/autopodborStore.spec.ts b/app/tests/Frontend/autopodborStore.spec.ts new file mode 100644 index 00000000..a4721f0c --- /dev/null +++ b/app/tests/Frontend/autopodborStore.spec.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; + +vi.mock('../../resources/js/api/autopodbor'); +import * as api from '../../resources/js/api/autopodbor'; +import { useAutopodborStore } from '../../resources/js/stores/autopodborStore'; + +describe('autopodborStore', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it('loadState заполняет enabled/prices/runs', async () => { + (api.fetchState as ReturnType).mockResolvedValue({ + enabled: true, + prices: { search: '500', study: '300' }, + runs: [{ id: 1, kind: 'search', status: 'done' }], + }); + const s = useAutopodborStore(); + await s.loadState(); + expect(s.enabled).toBe(true); + expect(s.prices.search).toBe('500'); + expect(s.runs.length).toBe(1); + }); + + it('search кладёт currentRun', async () => { + (api.startSearch as ReturnType).mockResolvedValue({ id: 9, kind: 'search', status: 'queued' }); + const s = useAutopodborStore(); + await s.search({ region_code: 16, examples: ['okna.ru'], about_self: [], include_federal: true }); + expect(api.startSearch).toHaveBeenCalled(); + expect(s.currentRun?.id).toBe(9); + }); + + it('loadCompetitor кладёт competitor и sources', async () => { + (api.fetchCompetitor as ReturnType).mockResolvedValue({ + competitor: { id: 3, name: 'Окна' }, + sources: [{ id: 1, signal_type: 'site' }], + }); + const s = useAutopodborStore(); + await s.loadCompetitor(3); + expect(s.competitor?.id).toBe(3); + expect(s.sources.length).toBe(1); + }); + + it('pollRun опрашивает до терминального статуса', async () => { + vi.useFakeTimers(); + (api.fetchRun as ReturnType) + .mockResolvedValueOnce({ id: 5, kind: 'search', status: 'running' }) + .mockResolvedValueOnce({ id: 5, kind: 'search', status: 'done' }); + const s = useAutopodborStore(); + const p = s.pollRun(5); + // прокрутить таймеры и микрозадачи + await vi.runAllTimersAsync(); + const final = await p; + expect(final.status).toBe('done'); + expect(s.currentRun?.status).toBe('done'); + vi.useRealTimers(); + }); + + it('makeProjects возвращает созданные проекты', async () => { + (api.createProjects as ReturnType).mockResolvedValue([{ id: 1, name: 'Окна Комфорт' }]); + const s = useAutopodborStore(); + const res = await s.makeProjects({ + source_ids: [1], + regions: [16], + daily_limit_target: 20, + delivery_days_mask: 127, + launch: false, + }); + expect(res).toHaveLength(1); + }); +}); From 26c75b23fdb2983c1a5b746e50953b524dff6093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 15:10:29 +0300 Subject: [PATCH 029/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=84=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=20=E2=80=94=20=D0=BA=D0=B0=D1=80=D0=BA=D0=B0=D1=81=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0,=20=D0=B2=D1=85=D0=BE?= =?UTF-8?q?=D0=B4,=20=D1=80=D0=BE=D1=83=D1=82,=20=D0=BF=D1=83=D0=BD=D0=BA?= =?UTF-8?q?=D1=82=20=D0=BC=D0=B5=D0=BD=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../js/components/layout/AppSidebar.vue | 16 ++ app/resources/js/router/index.ts | 6 + app/resources/js/stores/autopodborStore.ts | 2 + .../js/views/autopodbor/AutopodborView.vue | 57 +++++ .../views/autopodbor/screens/EntryScreen.vue | 237 ++++++++++++++++++ app/tests/Frontend/AutopodborView.spec.ts | 40 +++ 6 files changed, 358 insertions(+) create mode 100644 app/resources/js/views/autopodbor/AutopodborView.vue create mode 100644 app/resources/js/views/autopodbor/screens/EntryScreen.vue create mode 100644 app/tests/Frontend/AutopodborView.spec.ts diff --git a/app/resources/js/components/layout/AppSidebar.vue b/app/resources/js/components/layout/AppSidebar.vue index d6881aa1..6493f4be 100644 --- a/app/resources/js/components/layout/AppSidebar.vue +++ b/app/resources/js/components/layout/AppSidebar.vue @@ -13,6 +13,7 @@ import Kbd from '../ui/Kbd.vue'; import { useAuthStore } from '../../stores/auth'; import { useDealsCountStore } from '../../stores/dealsCount'; import { useCommandPalette } from '../../composables/useCommandPalette'; +import { useAutopodborStore } from '../../stores/autopodborStore'; interface NavItem { title: string; @@ -20,6 +21,7 @@ interface NavItem { to: string; count?: number; countKey?: string; + badge?: string; } interface NavGroup { eyebrow: string; @@ -32,9 +34,11 @@ const route = useRoute(); const auth = useAuthStore(); const dealsCount = useDealsCountStore(); const { openPalette } = useCommandPalette(); +const autopodbor = useAutopodborStore(); onMounted(() => { if (auth.user?.tenant_id) void dealsCount.load(auth.user.tenant_id); + void autopodbor.loadState().catch(() => {}); }); const navGroups = computed(() => [ @@ -42,6 +46,7 @@ const navGroups = computed(() => [ eyebrow: 'Работа', items: [ { title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' }, + ...(autopodbor.enabled ? [{ title: 'Автоподбор конкурентов', icon: 'mdi-radar', to: '/autopodbor', badge: 'NEW' }] : []), // B2: count из dealsCount-store; null → undefined (NavItem.count — number|undefined), // resolveCount затем → 0 и v-if скрывает бейдж пока счётчик не загружен. { @@ -106,6 +111,7 @@ defineExpose({ navGroups }); :data-tour="`nav-${item.to.replace('/', '')}`" > {{ item.title }} + {{ item.badge }} diff --git a/app/resources/js/router/index.ts b/app/resources/js/router/index.ts index 27e7a735..c88e45ea 100644 --- a/app/resources/js/router/index.ts +++ b/app/resources/js/router/index.ts @@ -140,6 +140,12 @@ const routes: RouteRecordRaw[] = [ devLabel: 'Проекты', }, }, + { + path: '/autopodbor', + name: 'autopodbor', + component: () => import('../views/autopodbor/AutopodborView.vue'), + meta: { layout: 'app', title: 'Автоподбор конкурентов', requiresAuth: true, transition: 'ld-route-fadeup', devLabel: 'Автоподбор' }, + }, { path: '/billing', name: 'billing', diff --git a/app/resources/js/stores/autopodborStore.ts b/app/resources/js/stores/autopodborStore.ts index 3e397e95..94f5dd06 100644 --- a/app/resources/js/stores/autopodborStore.ts +++ b/app/resources/js/stores/autopodborStore.ts @@ -41,6 +41,8 @@ export const useAutopodborStore = defineStore('autopodbor', () => { enabled.value = state.enabled; prices.value = state.prices; runs.value = state.runs; + } catch { + // Сетевая ошибка — enabled остаётся false, не роняем UI } finally { loading.value = false; } diff --git a/app/resources/js/views/autopodbor/AutopodborView.vue b/app/resources/js/views/autopodbor/AutopodborView.vue new file mode 100644 index 00000000..c8f3850a --- /dev/null +++ b/app/resources/js/views/autopodbor/AutopodborView.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/app/resources/js/views/autopodbor/screens/EntryScreen.vue b/app/resources/js/views/autopodbor/screens/EntryScreen.vue new file mode 100644 index 00000000..9e013f7c --- /dev/null +++ b/app/resources/js/views/autopodbor/screens/EntryScreen.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/app/tests/Frontend/AutopodborView.spec.ts b/app/tests/Frontend/AutopodborView.spec.ts new file mode 100644 index 00000000..8c8ad02a --- /dev/null +++ b/app/tests/Frontend/AutopodborView.spec.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { createVuetify } from 'vuetify'; + +vi.mock('../../resources/js/api/autopodbor'); +import * as api from '../../resources/js/api/autopodbor'; +import AutopodborView from '../../resources/js/views/autopodbor/AutopodborView.vue'; + +const vuetify = createVuetify(); + +function mountView() { + return mount(AutopodborView, { global: { plugins: [vuetify] } }); +} + +describe('AutopodborView', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + (api.fetchState as any).mockResolvedValue({ enabled: true, prices: { search: '500', study: '300' }, runs: [] }); + }); + + it('по умолчанию показывает экран входа с двумя дверями', async () => { + const w = mountView(); + await new Promise((r) => setTimeout(r, 0)); + expect(w.text()).toContain('Подобрать конкурентов'); + expect(w.text()).toContain('Указать своего конкурента'); + }); + + it('клик «Подобрать конкурентов» переключает на форму подбора (autoform)', async () => { + const w = mountView(); + await new Promise((r) => setTimeout(r, 0)); + // найди кнопку двери подбора и кликни + const btn = w.findAll('button').find((b) => b.text().includes('Подобрать')); + expect(btn).toBeTruthy(); + await btn!.trigger('click'); + // screen стал autoform: т.к. AutoFormScreen ещё не создан, контент дверей пропал + expect(w.text()).not.toContain('Указать своего конкурента'); + }); +}); From 6dad152fbd3dad9314ed2735b51ba05f9fa63cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 15:15:16 +0300 Subject: [PATCH 030/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=8D=D0=BD=D0=B4=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D0=BD=D1=82=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=83=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=20+=20competitor=5Fid=20=D0=B2=20RunResource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/Api/AutopodborController.php | 16 +++++++ .../Http/Resources/Autopodbor/RunResource.php | 1 + app/routes/web.php | 1 + .../AutopodborRunCompetitorsApiTest.php | 42 +++++++++++++++++++ 4 files changed, 60 insertions(+) create mode 100644 app/tests/Feature/Autopodbor/AutopodborRunCompetitorsApiTest.php diff --git a/app/app/Http/Controllers/Api/AutopodborController.php b/app/app/Http/Controllers/Api/AutopodborController.php index a32e8aed..062013de 100644 --- a/app/app/Http/Controllers/Api/AutopodborController.php +++ b/app/app/Http/Controllers/Api/AutopodborController.php @@ -86,6 +86,22 @@ class AutopodborController extends Controller ]); } + /** GET /api/autopodbor/runs/{run}/competitors */ + public function runCompetitors(Request $request, int $run): JsonResponse + { + $tenantId = $request->user()->tenant_id; + // убедимся, что прогон принадлежит tenant (404 если чужой) + AutopodborRun::where('tenant_id', $tenantId)->findOrFail($run); + + $competitors = AutopodborCompetitor::where('tenant_id', $tenantId) + ->where('search_run_id', $run) + ->orderByDesc('relevance_pct') + ->orderBy('id') + ->get(); + + return response()->json(['data' => CompetitorResource::collection($competitors)]); + } + /** POST /api/autopodbor/search */ public function search(Request $request, AutopodborRunService $svc): JsonResponse { diff --git a/app/app/Http/Resources/Autopodbor/RunResource.php b/app/app/Http/Resources/Autopodbor/RunResource.php index 29f504a5..456da653 100644 --- a/app/app/Http/Resources/Autopodbor/RunResource.php +++ b/app/app/Http/Resources/Autopodbor/RunResource.php @@ -16,6 +16,7 @@ class RunResource extends JsonResource return [ 'id' => $this->id, 'kind' => $this->kind, + 'competitor_id' => $this->competitor_id, 'status' => $this->status, 'region_code' => $this->region_code, 'params' => $this->params, diff --git a/app/routes/web.php b/app/routes/web.php index 634c8c9a..b932974f 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -334,6 +334,7 @@ Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/projec Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/autopodbor')->group(function () { Route::get('/state', 'App\Http\Controllers\Api\AutopodborController@state'); Route::get('/runs/{run}', 'App\Http\Controllers\Api\AutopodborController@run')->where('run', '[0-9]+'); + Route::get('/runs/{run}/competitors', 'App\Http\Controllers\Api\AutopodborController@runCompetitors')->where('run', '[0-9]+'); Route::get('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@competitor')->where('competitor', '[0-9]+'); Route::post('/search', 'App\Http\Controllers\Api\AutopodborController@search'); Route::post('/study', 'App\Http\Controllers\Api\AutopodborController@study'); diff --git a/app/tests/Feature/Autopodbor/AutopodborRunCompetitorsApiTest.php b/app/tests/Feature/Autopodbor/AutopodborRunCompetitorsApiTest.php new file mode 100644 index 00000000..c339d85a --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborRunCompetitorsApiTest.php @@ -0,0 +1,42 @@ + Queue::fake()); + +it('GET /api/autopodbor/runs/{run}/competitors отдаёт конкурентов прогона по убыванию релевантности', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'done','region_code'=>16,'params'=>[]]); + AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'name'=>'Б','relevance_pct'=>60,'dedup_key'=>'b']); + AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'name'=>'А','relevance_pct'=>100,'dedup_key'=>'a']); + + $resp = $this->actingAs($user)->getJson("/api/autopodbor/runs/{$run->id}/competitors") + ->assertOk() + ->assertJsonStructure(['data' => [['id','name','relevance_pct']]]); + $data = $resp->json('data'); + expect($data[0]['name'])->toBe('А')->and($data[1]['name'])->toBe('Б'); // 100 раньше 60 +}); + +it('study-прогон в RunResource отдаёт competitor_id', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + $search = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'done','region_code'=>16,'params'=>[]]); + $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$search->id,'name'=>'Окна','dedup_key'=>'o']); + $study = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'done','region_code'=>16,'competitor_id'=>$comp->id,'params'=>[]]); + + $this->actingAs($user)->getJson("/api/autopodbor/runs/{$study->id}") + ->assertOk() + ->assertJsonPath('data.competitor_id', $comp->id); +}); From 5b3300735d69cbddcec6186a49035f37fc85117f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 15:22:36 +0300 Subject: [PATCH 031/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=84=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=20=E2=80=94=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D1=8B=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=20=D0=BF=D0=BE=D0=B4=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D1=81=D0=B2=D0=BE=D0=B5=D0=B3=D0=BE=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D0=BA=D1=83=D1=80=D0=B5=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/resources/js/api/autopodbor.ts | 1 + .../js/views/autopodbor/AutopodborView.vue | 4 + .../autopodbor/screens/AutoFormScreen.vue | 280 ++++++++++++++++++ .../autopodbor/screens/ManualFormScreen.vue | 256 ++++++++++++++++ .../Frontend/AutopodborFormScreens.spec.ts | 62 ++++ 5 files changed, 603 insertions(+) create mode 100644 app/resources/js/views/autopodbor/screens/AutoFormScreen.vue create mode 100644 app/resources/js/views/autopodbor/screens/ManualFormScreen.vue create mode 100644 app/tests/Frontend/AutopodborFormScreens.spec.ts diff --git a/app/resources/js/api/autopodbor.ts b/app/resources/js/api/autopodbor.ts index 7bb8e090..89690c97 100644 --- a/app/resources/js/api/autopodbor.ts +++ b/app/resources/js/api/autopodbor.ts @@ -18,6 +18,7 @@ export interface RunDto { started_at: string | null; finished_at: string | null; created_at: string | null; + competitor_id: number | null; } export interface CompetitorDto { diff --git a/app/resources/js/views/autopodbor/AutopodborView.vue b/app/resources/js/views/autopodbor/AutopodborView.vue index c8f3850a..bb649b04 100644 --- a/app/resources/js/views/autopodbor/AutopodborView.vue +++ b/app/resources/js/views/autopodbor/AutopodborView.vue @@ -2,6 +2,8 @@ import { ref, reactive, provide, onMounted } from 'vue'; import { useAutopodborStore } from '../../stores/autopodborStore'; import EntryScreen from './screens/EntryScreen.vue'; +import AutoFormScreen from './screens/AutoFormScreen.vue'; +import ManualFormScreen from './screens/ManualFormScreen.vue'; type ScreenName = | 'entry' @@ -35,6 +37,8 @@ provide('autopodborNav', { go, ctx, screen }); const screens: Partial> = { entry: EntryScreen, + autoform: AutoFormScreen, + manualform: ManualFormScreen, }; onMounted(() => { diff --git a/app/resources/js/views/autopodbor/screens/AutoFormScreen.vue b/app/resources/js/views/autopodbor/screens/AutoFormScreen.vue new file mode 100644 index 00000000..7453e8d3 --- /dev/null +++ b/app/resources/js/views/autopodbor/screens/AutoFormScreen.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/app/resources/js/views/autopodbor/screens/ManualFormScreen.vue b/app/resources/js/views/autopodbor/screens/ManualFormScreen.vue new file mode 100644 index 00000000..7dff5963 --- /dev/null +++ b/app/resources/js/views/autopodbor/screens/ManualFormScreen.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/app/tests/Frontend/AutopodborFormScreens.spec.ts b/app/tests/Frontend/AutopodborFormScreens.spec.ts new file mode 100644 index 00000000..9816cc6a --- /dev/null +++ b/app/tests/Frontend/AutopodborFormScreens.spec.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { createVuetify } from 'vuetify'; +import { reactive, ref } from 'vue'; + +vi.mock('../../resources/js/api/autopodbor'); +import AutoFormScreen from '../../resources/js/views/autopodbor/screens/AutoFormScreen.vue'; +import ManualFormScreen from '../../resources/js/views/autopodbor/screens/ManualFormScreen.vue'; +import { useAutopodborStore } from '../../resources/js/stores/autopodborStore'; + +const vuetify = createVuetify(); + +function makeNav() { + return { go: vi.fn(), ctx: reactive({ runId: null, competitorId: null, selectedSourceIds: [], loadMsg: '', loadSub: '' }), screen: ref('autoform') }; +} +function mountScreen(Comp: any, nav: any) { + return mount(Comp, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } }); +} + +describe('AutoFormScreen', () => { + beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); }); + + it('подбор: собирает примеры и зовёт store.search, затем go(list)', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'search').mockResolvedValue({ id: 7, kind: 'search', status: 'queued' } as any); + vi.spyOn(store, 'pollRun').mockResolvedValue({ id: 7, kind: 'search', status: 'done' } as any); + const nav = makeNav(); + const w = mountScreen(AutoFormScreen, nav); + const textInputs = w.findAll('input'); + await textInputs[0].setValue('okna-kazan.ru'); + (w.vm as any).regionCode = 16; + const submitBtn = w.findAll('button').find(b => b.text().includes('Подобрать')); + await submitBtn!.trigger('click'); + await new Promise(r => setTimeout(r, 0)); + expect(store.search).toHaveBeenCalled(); + const arg = (store.search as any).mock.calls[0][0]; + expect(arg.examples).toContain('okna-kazan.ru'); + expect(nav.go).toHaveBeenCalledWith('list'); + }); +}); + +describe('ManualFormScreen', () => { + beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); }); + + it('свой конкурент по сайту: зовёт manualStudy и go(detail)', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'manualStudy').mockResolvedValue({ id: 9, kind: 'study', status: 'queued', competitor_id: 3 } as any); + vi.spyOn(store, 'pollRun').mockResolvedValue({ id: 9, kind: 'study', status: 'done', competitor_id: 3 } as any); + const nav = makeNav(); + const w = mountScreen(ManualFormScreen, nav); + (w.vm as any).regionCode = 16; + const inputs = w.findAll('input'); + await inputs[0].setValue('okna-komfort-kzn.ru'); + const btn = w.findAll('button').find(b => b.text().includes('Собрать источники')); + await btn!.trigger('click'); + await new Promise(r => setTimeout(r, 0)); + expect(store.manualStudy).toHaveBeenCalled(); + expect(nav.go).toHaveBeenCalledWith('detail'); + expect(nav.ctx.competitorId).toBe(3); + }); +}); From 710259d6cb1847a8d7890acec933f80b9e180fe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 15:33:20 +0300 Subject: [PATCH 032/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=84=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=20=E2=80=94=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20=D0=BA=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D1=83=D1=80=D0=B5=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/resources/js/api/autopodbor.ts | 5 + app/resources/js/stores/autopodborStore.ts | 10 +- .../js/views/autopodbor/AutopodborView.vue | 6 +- .../views/autopodbor/screens/ListScreen.vue | 310 ++++++++++++++++++ .../autopodbor/screens/LoadingScreen.vue | 72 ++++ .../Frontend/AutopodborListScreen.spec.ts | 69 ++++ 6 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 app/resources/js/views/autopodbor/screens/ListScreen.vue create mode 100644 app/resources/js/views/autopodbor/screens/LoadingScreen.vue create mode 100644 app/tests/Frontend/AutopodborListScreen.spec.ts diff --git a/app/resources/js/api/autopodbor.ts b/app/resources/js/api/autopodbor.ts index 89690c97..0c34fdc3 100644 --- a/app/resources/js/api/autopodbor.ts +++ b/app/resources/js/api/autopodbor.ts @@ -116,3 +116,8 @@ export async function createProjects(p: { 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 { + const { data } = await apiClient.get<{ data: CompetitorDto[] }>(`/api/autopodbor/runs/${runId}/competitors`); + return data.data; +} diff --git a/app/resources/js/stores/autopodborStore.ts b/app/resources/js/stores/autopodborStore.ts index 94f5dd06..dbfeca2e 100644 --- a/app/resources/js/stores/autopodborStore.ts +++ b/app/resources/js/stores/autopodborStore.ts @@ -1,4 +1,4 @@ -import { defineStore } from 'pinia'; +import { defineStore } from 'pinia'; import { ref } from 'vue'; import { fetchState, @@ -10,6 +10,7 @@ import { startManualStudy, addManualSource, createProjects, + fetchRunCompetitors, type RunDto, type CompetitorDto, type SourceDto, @@ -27,6 +28,7 @@ export const useAutopodborStore = defineStore('autopodbor', () => { const currentRun = ref(null); const competitor = ref(null); const sources = ref([]); + const runCompetitors = ref([]); const loading = ref(false); // Internal poll handle — not exposed as reactive state. @@ -148,6 +150,10 @@ export const useAutopodborStore = defineStore('autopodbor', () => { } } + async function loadRunCompetitors(runId: number): Promise { + runCompetitors.value = await fetchRunCompetitors(runId); + } + return { // State enabled, @@ -156,6 +162,7 @@ export const useAutopodborStore = defineStore('autopodbor', () => { currentRun, competitor, sources, + runCompetitors, loading, // Actions loadState, @@ -168,5 +175,6 @@ export const useAutopodborStore = defineStore('autopodbor', () => { makeProjects, pollRun, stopPolling, + loadRunCompetitors, }; }); diff --git a/app/resources/js/views/autopodbor/AutopodborView.vue b/app/resources/js/views/autopodbor/AutopodborView.vue index bb649b04..85e6e330 100644 --- a/app/resources/js/views/autopodbor/AutopodborView.vue +++ b/app/resources/js/views/autopodbor/AutopodborView.vue @@ -1,9 +1,11 @@ - + + + + \ No newline at end of file diff --git a/app/resources/js/views/autopodbor/screens/LoadingScreen.vue b/app/resources/js/views/autopodbor/screens/LoadingScreen.vue new file mode 100644 index 00000000..9aa72d64 --- /dev/null +++ b/app/resources/js/views/autopodbor/screens/LoadingScreen.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/app/tests/Frontend/AutopodborListScreen.spec.ts b/app/tests/Frontend/AutopodborListScreen.spec.ts new file mode 100644 index 00000000..1d3c60a6 --- /dev/null +++ b/app/tests/Frontend/AutopodborListScreen.spec.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { createVuetify } from 'vuetify'; +import { reactive, ref } from 'vue'; + +vi.mock('../../resources/js/api/autopodbor'); +import ListScreen from '../../resources/js/views/autopodbor/screens/ListScreen.vue'; +import { useAutopodborStore } from '../../resources/js/stores/autopodborStore'; + +const vuetify = createVuetify(); +function makeNav(runId: number | null = 5) { + return { go: vi.fn(), ctx: reactive({ runId, competitorId: null, selectedSourceIds: [], loadMsg: '', loadSub: '' }), screen: ref('list') }; +} +function mountList(nav: any) { + return mount(ListScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } }); +} + +describe('ListScreen', () => { + beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); }); + + it('загружает и показывает конкурентов прогона', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadRunCompetitors').mockImplementation(async () => { + store.runCompetitors = [ + { id: 1, name: 'Окна Комфорт', is_federal: false, relevance_pct: 100, studied_at: '2026-06-28', origin: 'auto', site_url: 'okna.ru', directory_urls: [], description: 'd', study_run_id: 9, search_run_id: 5 }, + { id: 2, name: 'Пластика Окон', is_federal: false, relevance_pct: 96, studied_at: null, origin: 'auto', site_url: 'p.ru', directory_urls: [], description: 'd', study_run_id: null, search_run_id: 5 }, + ] as any; + }); + const nav = makeNav(5); + const w = mountList(nav); + await new Promise(r => setTimeout(r, 0)); + expect(store.loadRunCompetitors).toHaveBeenCalledWith(5); + expect(w.text()).toContain('Окна Комфорт'); + expect(w.text()).toContain('Пластика Окон'); + }); + + it('«Открыть источники» по изученному ведёт на detail', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadRunCompetitors').mockImplementation(async () => { + store.runCompetitors = [{ id: 1, name: 'Окна Комфорт', is_federal: false, relevance_pct: 100, studied_at: '2026-06-28', origin: 'auto', site_url: 'okna.ru', directory_urls: [], description: 'd', study_run_id: 9, search_run_id: 5 }] as any; + }); + const nav = makeNav(5); + const w = mountList(nav); + await new Promise(r => setTimeout(r, 0)); + const btn = w.findAll('button').find(b => b.text().includes('Открыть источники')); + expect(btn).toBeTruthy(); + await btn!.trigger('click'); + expect(nav.ctx.competitorId).toBe(1); + expect(nav.go).toHaveBeenCalledWith('detail'); + }); + + it('«Изучить подробнее» по неизученному зовёт store.study', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadRunCompetitors').mockImplementation(async () => { + store.runCompetitors = [{ id: 2, name: 'Пластика Окон', is_federal: false, relevance_pct: 96, studied_at: null, origin: 'auto', site_url: 'p.ru', directory_urls: [], description: 'd', study_run_id: null, search_run_id: 5 }] as any; + }); + vi.spyOn(store, 'study').mockResolvedValue({ id: 11, kind: 'study', status: 'queued', competitor_id: 2 } as any); + vi.spyOn(store, 'pollRun').mockResolvedValue({ id: 11, kind: 'study', status: 'done', competitor_id: 2 } as any); + const nav = makeNav(5); + const w = mountList(nav); + await new Promise(r => setTimeout(r, 0)); + const btn = w.findAll('button').find(b => b.text().includes('Изучить')); + await btn!.trigger('click'); + await new Promise(r => setTimeout(r, 0)); + expect(store.study).toHaveBeenCalledWith(2); + expect(nav.go).toHaveBeenCalledWith('detail'); + }); +}); From 128963f73c3d32c8596b1344e01479c328aec811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 15:41:07 +0300 Subject: [PATCH 033/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=84=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=20=E2=80=94=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D0=BA=D1=83=D1=80=D0=B5=D0=BD=D1=82=D0=B0=20(?= =?UTF-8?q?=D0=B2=D1=8B=D0=B1=D0=BE=D1=80,=20=D1=80=D1=83=D1=87=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../js/views/autopodbor/AutopodborView.vue | 3 + .../views/autopodbor/screens/DetailScreen.vue | 585 ++++++++++++++++++ .../Frontend/AutopodborDetailScreen.spec.ts | 75 +++ 3 files changed, 663 insertions(+) create mode 100644 app/resources/js/views/autopodbor/screens/DetailScreen.vue create mode 100644 app/tests/Frontend/AutopodborDetailScreen.spec.ts diff --git a/app/resources/js/views/autopodbor/AutopodborView.vue b/app/resources/js/views/autopodbor/AutopodborView.vue index 85e6e330..19224eba 100644 --- a/app/resources/js/views/autopodbor/AutopodborView.vue +++ b/app/resources/js/views/autopodbor/AutopodborView.vue @@ -6,6 +6,7 @@ import AutoFormScreen from './screens/AutoFormScreen.vue'; import ManualFormScreen from './screens/ManualFormScreen.vue'; import LoadingScreen from './screens/LoadingScreen.vue'; import ListScreen from './screens/ListScreen.vue'; +import DetailScreen from './screens/DetailScreen.vue'; type ScreenName = | 'entry' @@ -28,6 +29,7 @@ const ctx = reactive({ selectedSourceIds: [] as number[], loadMsg: '', loadSub: '', + editProjectId: null as number | null, }); function go(name: ScreenName) { @@ -43,6 +45,7 @@ const screens: Partial> = { manualform: ManualFormScreen, loading: LoadingScreen, list: ListScreen, + detail: DetailScreen, }; onMounted(() => { diff --git a/app/resources/js/views/autopodbor/screens/DetailScreen.vue b/app/resources/js/views/autopodbor/screens/DetailScreen.vue new file mode 100644 index 00000000..1de9c9bc --- /dev/null +++ b/app/resources/js/views/autopodbor/screens/DetailScreen.vue @@ -0,0 +1,585 @@ + + + + + diff --git a/app/tests/Frontend/AutopodborDetailScreen.spec.ts b/app/tests/Frontend/AutopodborDetailScreen.spec.ts new file mode 100644 index 00000000..580af3aa --- /dev/null +++ b/app/tests/Frontend/AutopodborDetailScreen.spec.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { createVuetify } from 'vuetify'; +import { reactive, ref } from 'vue'; + +vi.mock('../../resources/js/api/autopodbor'); +import DetailScreen from '../../resources/js/views/autopodbor/screens/DetailScreen.vue'; +import { useAutopodborStore } from '../../resources/js/stores/autopodborStore'; + +const vuetify = createVuetify(); +function makeNav(competitorId = 3) { + return { go: vi.fn(), ctx: reactive({ runId: null, competitorId, selectedSourceIds: [] as number[], loadMsg: '', loadSub: '', editProjectId: null as number|null }), screen: ref('detail') }; +} +function mountDetail(nav: any) { + return mount(DetailScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } }); +} + +function seed(store: any) { + store.competitor = { id: 3, name: 'Окна Комфорт', is_federal: false, relevance_pct: 100, origin: 'auto', site_url: 'okna.ru', directory_urls: [], description: 'd', studied_at: '2026-06-28', study_run_id: 9, search_run_id: 5 }; + store.sources = [ + { id: 11, competitor_id: 3, signal_type: 'site', identifier: 'okna-komfort-kzn.ru', phone_kind: null, provenance_url: null, provenance_label: '2ГИС', created_project_id: null, existing_project_id: null }, + { id: 12, competitor_id: 3, signal_type: 'call', identifier: '78432001122', phone_kind: 'real', provenance_url: null, provenance_label: '2ГИС', created_project_id: null, existing_project_id: null }, + { id: 13, competitor_id: 3, signal_type: 'call', identifier: '78003507700', phone_kind: 'substitute', provenance_url: null, provenance_label: 'футер', created_project_id: 99, existing_project_id: 99 }, + ]; +} + +describe('DetailScreen', () => { + beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); }); + + it('грузит конкурента и показывает источники', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => seed(store)); + const nav = makeNav(3); + const w = mountDetail(nav); + await new Promise(r => setTimeout(r, 0)); + expect(store.loadCompetitor).toHaveBeenCalledWith(3); + expect(w.text()).toContain('okna-komfort-kzn.ru'); + expect(w.text()).toContain('проект создан'); // источник 13 с existing_project_id + }); + + it('по умолчанию выбраны источники без проекта (11 и 12, не 13)', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => seed(store)); + const nav = makeNav(3); + mountDetail(nav); + await new Promise(r => setTimeout(r, 0)); + expect(nav.ctx.selectedSourceIds).toContain(11); + expect(nav.ctx.selectedSourceIds).toContain(12); + expect(nav.ctx.selectedSourceIds).not.toContain(13); + }); + + it('«Создать проекты» ведёт на create', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => seed(store)); + const nav = makeNav(3); + const w = mountDetail(nav); + await new Promise(r => setTimeout(r, 0)); + const btn = w.findAll('button').find(b => b.text().includes('Создать проекты')); + await btn!.trigger('click'); + expect(nav.go).toHaveBeenCalledWith('create'); + }); + + it('«Изменить проект» у созданного источника ведёт на editproject', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => seed(store)); + const nav = makeNav(3); + const w = mountDetail(nav); + await new Promise(r => setTimeout(r, 0)); + const btn = w.findAll('button').find(b => b.text().includes('Изменить проект')); + await btn!.trigger('click'); + expect(nav.ctx.editProjectId).toBe(99); + expect(nav.go).toHaveBeenCalledWith('editproject'); + }); +}); From 5f671209f69175aeed6bb77534d3b16a50a41e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 15:47:56 +0300 Subject: [PATCH 034/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=84=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=20=E2=80=94=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D1=8B=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=B3=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../js/views/autopodbor/AutopodborView.vue | 6 + .../views/autopodbor/screens/CreateScreen.vue | 521 ++++++++++++++++++ .../views/autopodbor/screens/DoneScreen.vue | 125 +++++ .../Frontend/AutopodborCreateScreen.spec.ts | 68 +++ 4 files changed, 720 insertions(+) create mode 100644 app/resources/js/views/autopodbor/screens/CreateScreen.vue create mode 100644 app/resources/js/views/autopodbor/screens/DoneScreen.vue create mode 100644 app/tests/Frontend/AutopodborCreateScreen.spec.ts diff --git a/app/resources/js/views/autopodbor/AutopodborView.vue b/app/resources/js/views/autopodbor/AutopodborView.vue index 19224eba..d238d9d4 100644 --- a/app/resources/js/views/autopodbor/AutopodborView.vue +++ b/app/resources/js/views/autopodbor/AutopodborView.vue @@ -7,6 +7,8 @@ import ManualFormScreen from './screens/ManualFormScreen.vue'; import LoadingScreen from './screens/LoadingScreen.vue'; import ListScreen from './screens/ListScreen.vue'; import DetailScreen from './screens/DetailScreen.vue'; +import CreateScreen from './screens/CreateScreen.vue'; +import DoneScreen from './screens/DoneScreen.vue'; type ScreenName = | 'entry' @@ -30,6 +32,8 @@ const ctx = reactive({ loadMsg: '', loadSub: '', editProjectId: null as number | null, + createdCount: 0, + launched: false, }); function go(name: ScreenName) { @@ -46,6 +50,8 @@ const screens: Partial> = { loading: LoadingScreen, list: ListScreen, detail: DetailScreen, + create: CreateScreen, + done: DoneScreen, }; onMounted(() => { diff --git a/app/resources/js/views/autopodbor/screens/CreateScreen.vue b/app/resources/js/views/autopodbor/screens/CreateScreen.vue new file mode 100644 index 00000000..a01cb7ec --- /dev/null +++ b/app/resources/js/views/autopodbor/screens/CreateScreen.vue @@ -0,0 +1,521 @@ + + + + + diff --git a/app/resources/js/views/autopodbor/screens/DoneScreen.vue b/app/resources/js/views/autopodbor/screens/DoneScreen.vue new file mode 100644 index 00000000..a847f291 --- /dev/null +++ b/app/resources/js/views/autopodbor/screens/DoneScreen.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/app/tests/Frontend/AutopodborCreateScreen.spec.ts b/app/tests/Frontend/AutopodborCreateScreen.spec.ts new file mode 100644 index 00000000..a2d915a3 --- /dev/null +++ b/app/tests/Frontend/AutopodborCreateScreen.spec.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { createVuetify } from 'vuetify'; +import { reactive, ref } from 'vue'; + +vi.mock('../../resources/js/api/autopodbor'); +import CreateScreen from '../../resources/js/views/autopodbor/screens/CreateScreen.vue'; +import { useAutopodborStore } from '../../resources/js/stores/autopodborStore'; + +const vuetify = createVuetify(); +function makeNav() { + return { go: vi.fn(), ctx: reactive({ runId: 5, competitorId: 3, selectedSourceIds: [11, 12], loadMsg: '', loadSub: '', editProjectId: null, createdCount: 0, launched: false }), screen: ref('create') }; +} +function seed(store: any) { + store.competitor = { id: 3, name: 'Окна Комфорт', is_federal: false, relevance_pct: 100, origin: 'auto', site_url: 'okna.ru', directory_urls: [], description: 'd', studied_at: '2026-06-28', study_run_id: 9, search_run_id: 5 }; + store.sources = [ + { id: 11, competitor_id: 3, signal_type: 'site', identifier: 'okna-komfort-kzn.ru', phone_kind: null, provenance_url: null, provenance_label: '2ГИС', created_project_id: null, existing_project_id: null }, + { id: 12, competitor_id: 3, signal_type: 'call', identifier: '78432001122', phone_kind: 'real', provenance_url: null, provenance_label: '2ГИС', created_project_id: null, existing_project_id: null }, + ]; +} +function mountCreate(nav: any) { + return mount(CreateScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } }); +} + +describe('CreateScreen', () => { + beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); }); + + it('показывает выбранные источники и число проектов', async () => { + const store = useAutopodborStore(); seed(store); + const w = mountCreate(makeNav()); + await new Promise(r => setTimeout(r, 0)); + expect(w.text()).toContain('okna-komfort-kzn.ru'); + expect(w.text()).toContain('Окна Комфорт'); // производное имя + }); + + it('«Создать (без запуска)» зовёт makeProjects(launch=false) и идёт на done', async () => { + const store = useAutopodborStore(); seed(store); + vi.spyOn(store, 'makeProjects').mockResolvedValue([{ id: 1, name: 'Окна Комфорт' }, { id: 2, name: 'Окна Комфорт ✓' }] as any); + const nav = makeNav(); + const w = mountCreate(nav); + (w.vm as any).regionCode = 16; + await new Promise(r => setTimeout(r, 0)); + const btn = w.findAll('button').find(b => b.text().includes('без запуска')); + await btn!.trigger('click'); + await new Promise(r => setTimeout(r, 0)); + expect(store.makeProjects).toHaveBeenCalled(); + const arg = (store.makeProjects as any).mock.calls[0][0]; + expect(arg.launch).toBe(false); + expect(arg.source_ids).toEqual([11, 12]); + expect(nav.ctx.createdCount).toBe(2); + expect(nav.go).toHaveBeenCalledWith('done'); + }); + + it('409 нехватки баланса показывает сообщение и возвращает на create', async () => { + const store = useAutopodborStore(); seed(store); + vi.spyOn(store, 'makeProjects').mockRejectedValue({ response: { data: { error: 'balance_insufficient' } } }); + const nav = makeNav(); + const w = mountCreate(nav); + (w.vm as any).regionCode = 16; + await new Promise(r => setTimeout(r, 0)); + const btn = w.findAll('button').find(b => b.text().includes('запустить')); + await btn!.trigger('click'); + await new Promise(r => setTimeout(r, 0)); + expect(store.makeProjects).toHaveBeenCalled(); + expect(nav.go).toHaveBeenCalledWith('create'); + }); +}); From 2027698a62bca5be1243d57f58cafcfee3d2a299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 15:53:36 +0300 Subject: [PATCH 035/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=84=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=20=E2=80=94=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=B8?= =?UTF-8?q?=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B5=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../js/views/autopodbor/AutopodborView.vue | 2 + .../autopodbor/screens/EditProjectScreen.vue | 375 ++++++++++++++++++ .../AutopodborEditProjectScreen.spec.ts | 46 +++ 3 files changed, 423 insertions(+) create mode 100644 app/resources/js/views/autopodbor/screens/EditProjectScreen.vue create mode 100644 app/tests/Frontend/AutopodborEditProjectScreen.spec.ts diff --git a/app/resources/js/views/autopodbor/AutopodborView.vue b/app/resources/js/views/autopodbor/AutopodborView.vue index d238d9d4..91e1d794 100644 --- a/app/resources/js/views/autopodbor/AutopodborView.vue +++ b/app/resources/js/views/autopodbor/AutopodborView.vue @@ -9,6 +9,7 @@ import ListScreen from './screens/ListScreen.vue'; import DetailScreen from './screens/DetailScreen.vue'; import CreateScreen from './screens/CreateScreen.vue'; import DoneScreen from './screens/DoneScreen.vue'; +import EditProjectScreen from './screens/EditProjectScreen.vue'; type ScreenName = | 'entry' @@ -52,6 +53,7 @@ const screens: Partial> = { detail: DetailScreen, create: CreateScreen, done: DoneScreen, + editproject: EditProjectScreen, }; onMounted(() => { diff --git a/app/resources/js/views/autopodbor/screens/EditProjectScreen.vue b/app/resources/js/views/autopodbor/screens/EditProjectScreen.vue new file mode 100644 index 00000000..895cd629 --- /dev/null +++ b/app/resources/js/views/autopodbor/screens/EditProjectScreen.vue @@ -0,0 +1,375 @@ + + + + + diff --git a/app/tests/Frontend/AutopodborEditProjectScreen.spec.ts b/app/tests/Frontend/AutopodborEditProjectScreen.spec.ts new file mode 100644 index 00000000..068e657b --- /dev/null +++ b/app/tests/Frontend/AutopodborEditProjectScreen.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { createVuetify } from 'vuetify'; +import { reactive, ref } from 'vue'; +import axios from 'axios'; + +vi.mock('axios'); +import EditProjectScreen from '../../resources/js/views/autopodbor/screens/EditProjectScreen.vue'; + +const vuetify = createVuetify(); +function makeNav(editProjectId: number | null = 99) { + return { go: vi.fn(), ctx: reactive({ runId: null, competitorId: 3, selectedSourceIds: [], loadMsg: '', loadSub: '', editProjectId, createdCount: 0, launched: false }), screen: ref('editproject') }; +} +function mountEdit(nav: any) { + return mount(EditProjectScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } }); +} + +describe('EditProjectScreen', () => { + beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); }); + + it('грузит проект и заполняет форму', async () => { + (axios.get as any).mockResolvedValue({ data: { data: { id: 99, name: 'Окна Комфорт 🎭', regions: [16], daily_limit_target: 20, delivery_days_mask: 127, signal_type: 'call', signal_identifier: '78003507700' } } }); + const w = mountEdit(makeNav(99)); + await new Promise(r => setTimeout(r, 0)); + expect(axios.get).toHaveBeenCalledWith('/api/projects/99'); + expect((w.vm as any).name).toBe('Окна Комфорт 🎭'); + expect((w.vm as any).dailyLimit).toBe(20); + }); + + it('сохранение шлёт PATCH и возвращает на detail', async () => { + (axios.get as any).mockResolvedValue({ data: { data: { id: 99, name: 'Окна Комфорт 🎭', regions: [16], daily_limit_target: 20, delivery_days_mask: 127 } } }); + (axios.patch as any).mockResolvedValue({ data: { data: { id: 99 } } }); + const nav = makeNav(99); + const w = mountEdit(nav); + await new Promise(r => setTimeout(r, 0)); + const btn = w.findAll('button').find(b => b.text().includes('Сохранить')); + await btn!.trigger('click'); + await new Promise(r => setTimeout(r, 0)); + expect(axios.patch).toHaveBeenCalled(); + const [url, body] = (axios.patch as any).mock.calls[0]; + expect(url).toBe('/api/projects/99'); + expect(body.name).toBe('Окна Комфорт 🎭'); + expect(nav.go).toHaveBeenCalledWith('detail'); + }); +}); From 2a4f3b7517fb2a1b6706efde8a4a0332f239409d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 16:00:22 +0300 Subject: [PATCH 036/417] =?UTF-8?q?test(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=81=D0=BA=D0=B2=D0=BE?= =?UTF-8?q?=D0=B7=D0=BD=D0=BE=D0=B9=20smoke=20=D0=BF=D0=BE=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=D0=BC=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0=D0=BC=20+?= =?UTF-8?q?=20defineExpose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../js/views/autopodbor/AutopodborView.vue | 2 + .../Frontend/AutopodborFlowSmoke.spec.ts | 65 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 app/tests/Frontend/AutopodborFlowSmoke.spec.ts diff --git a/app/resources/js/views/autopodbor/AutopodborView.vue b/app/resources/js/views/autopodbor/AutopodborView.vue index 91e1d794..0141df79 100644 --- a/app/resources/js/views/autopodbor/AutopodborView.vue +++ b/app/resources/js/views/autopodbor/AutopodborView.vue @@ -59,6 +59,8 @@ const screens: Partial> = { onMounted(() => { void store.loadState(); }); + +defineExpose({ go, screen, ctx }); diff --git a/app/resources/js/views/admin/AdminInvoicesView.vue b/app/resources/js/views/admin/AdminInvoicesView.vue new file mode 100644 index 00000000..b13bdbee --- /dev/null +++ b/app/resources/js/views/admin/AdminInvoicesView.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/app/resources/views/pdf/act.blade.php b/app/resources/views/pdf/act.blade.php new file mode 100644 index 00000000..e264f792 --- /dev/null +++ b/app/resources/views/pdf/act.blade.php @@ -0,0 +1,38 @@ + + + + + + + +

Акт № {{ $act->upd_number }} от {{ \Illuminate\Support\Carbon::parse($act->issued_at)->format('d.m.Y') }}

+ +

Исполнитель: {{ $seller->name }}, ИНН {{ $seller->inn }}{{ $seller->kpp ? ', КПП '.$seller->kpp : '' }}

+

Заказчик: {{ $act->buyer_name }}, ИНН {{ $act->buyer_inn }}{{ $act->buyer_kpp ? ', КПП '.$act->buyer_kpp : '' }}

+

Основание: счёт № {{ $invoiceNumber }}

+ + + + +
Наименование услугиСумма
1Оплата генерации рекламных лидов{{ number_format((float) $act->amount_total, 2, '.', ' ') }} ₽
+ +

Всего оказано услуг на сумму: {{ number_format((float) $act->amount_total, 2, '.', ' ') }} ₽
Без НДС

+

Вышеперечисленные услуги оказаны полностью и в срок. Заказчик претензий по объёму, качеству и срокам оказания услуг не имеет.

+ + + + + + +
Исполнитель

_______________ / {{ $seller->director_name ?? $seller->name }}
Заказчик

_______________ / {{ $act->buyer_name }}
+ + diff --git a/app/resources/views/pdf/invoice.blade.php b/app/resources/views/pdf/invoice.blade.php new file mode 100644 index 00000000..335dc368 --- /dev/null +++ b/app/resources/views/pdf/invoice.blade.php @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + +
{{ $seller->bank_name }}БИК{{ $seller->bank_bik }}
Сч. №{{ $seller->bank_corr }}
Получатель
{{ $seller->name }}
ИНН {{ $seller->inn }} {{ $seller->kpp ? 'КПП '.$seller->kpp : '' }}
Сч. №{{ $seller->bank_account }}
+ +

Счёт на оплату № {{ $invoice->invoice_number }} от {{ \Illuminate\Support\Carbon::parse($invoice->issued_at)->format('d.m.Y') }}

+ +

Поставщик (Исполнитель): {{ $seller->name }}, ИНН {{ $seller->inn }}{{ $seller->kpp ? ', КПП '.$seller->kpp : '' }}{{ $seller->legal_address ? ', '.$seller->legal_address : '' }}

+

Покупатель (Заказчик): {{ $invoice->payer_name }}, ИНН {{ $invoice->payer_inn }}{{ $invoice->payer_kpp ? ', КПП '.$invoice->payer_kpp : '' }}{{ $invoice->payer_address ? ', '.$invoice->payer_address : '' }}

+ + + + @foreach($items as $i => $it) + + + + + + + + + @endforeach +
НаименованиеКол-воЕд.ЦенаСумма
{{ $i + 1 }}{{ $it->name }}{{ (int) $it->quantity }}{{ $it->unit }}{{ number_format((float) $it->price, 2, '.', ' ') }}{{ number_format((float) $it->amount_total, 2, '.', ' ') }}
+ +

Итого: {{ number_format((float) $invoice->amount_total, 2, '.', ' ') }} ₽
Без НДС

+

Назначение платежа: {{ $invoice->payment_purpose }}

+

Оплатить до: {{ \Illuminate\Support\Carbon::parse($invoice->expires_at)->format('d.m.Y') }}

+ + diff --git a/app/routes/console.php b/app/routes/console.php index 9cc7569f..b992d7f4 100644 --- a/app/routes/console.php +++ b/app/routes/console.php @@ -104,6 +104,14 @@ Schedule::command('billing:preflight-sweep') ->onSuccess(fn () => $hb->recordRunResult('billing:preflight-sweep', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('billing:preflight-sweep', false, 'Command failed', null)); +// Этап 1 «оплата по счёту»: просроченные неоплаченные счета → overdue. +// 03:40 МСК — после ночных ретеншен-задач, вне пиковых часов. +Schedule::command('invoices:expire') + ->dailyAt('03:40') + ->timezone('Europe/Moscow') + ->onSuccess(fn () => $hb->recordRunResult('invoices:expire', true, null, null)) + ->onFailure(fn () => $hb->recordRunResult('invoices:expire', false, 'Command failed', null)); + // Billing v2 Spec C §3.7: повторные письма заморозки (reminder +1д, final +3д). // Идёт ПОСЛЕ основного sweep — если sweep только что заморозил тенанта, окно reminder // (24h+) ещё не открылось, повторного письма в тот же день не будет (correct). diff --git a/app/routes/web.php b/app/routes/web.php index d0e092f4..c294bf87 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -139,6 +139,11 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () { // SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц. Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index'); + // SaaS-admin → Счета: список выставленных счетов + ручная отметка оплаты (Этап 1). + Route::get('/api/admin/invoices', 'App\Http\Controllers\Api\AdminInvoiceController@index'); + Route::post('/api/admin/invoices/{id}/mark-paid', 'App\Http\Controllers\Api\AdminInvoiceController@markPaid') + ->whereNumber('id'); + // Sprint 3D (G4): SaaS-admin billing row-actions — приостановка/возврат/смена тарифа. Route::get('/api/admin/billing/tariff-plans', 'App\Http\Controllers\Api\AdminBillingController@tariffPlans'); Route::patch('/api/admin/billing/tenants/{id}/status', 'App\Http\Controllers\Api\AdminBillingController@updateStatus') @@ -238,6 +243,9 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(fun Route::get('/balance-status', 'App\Http\Controllers\Api\BillingController@balanceStatus'); Route::get('/transactions', 'App\Http\Controllers\Api\BillingController@transactions'); Route::get('/invoices', 'App\Http\Controllers\Api\BillingController@invoices'); + Route::post('/invoices', 'App\Http\Controllers\Api\InvoiceController@store'); + Route::get('/invoices/{id}/pdf', 'App\Http\Controllers\Api\InvoiceController@pdf')->whereNumber('id'); + Route::get('/invoices/{id}/act', 'App\Http\Controllers\Api\InvoiceController@act')->whereNumber('id'); }); // API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware. @@ -381,6 +389,7 @@ Route::view('/import', 'welcome'); // Sprint 4 — CSV-импорт истори Route::view('/admin', 'welcome'); Route::view('/admin/tenants', 'welcome'); Route::view('/admin/billing', 'welcome'); +Route::view('/admin/invoices', 'welcome'); Route::view('/admin/incidents', 'welcome'); Route::view('/admin/system', 'welcome'); Route::view('/admin/pricing-tiers', 'welcome'); diff --git a/app/tests/Feature/Billing/AdminInvoiceIndexTest.php b/app/tests/Feature/Billing/AdminInvoiceIndexTest.php new file mode 100644 index 00000000..99889e22 --- /dev/null +++ b/app/tests/Feature/Billing/AdminInvoiceIndexTest.php @@ -0,0 +1,49 @@ + $tenantId, 'legal_entity_id' => $legalEntityId, 'invoice_number' => $number, + 'payer_type' => 'legal', 'payer_name' => 'ООО Клиент', 'payer_inn' => '5000000000', + 'amount_net' => $amount, 'amount_total' => $amount, 'status' => $status, + 'issued_at' => now(), 'expires_at' => now()->addDays(5), + ]); +} + +it('GET /api/admin/invoices отдаёт выставленные счета с пагинацией', function () { + $tenant = Tenant::factory()->create(); + $le = LegalEntity::create(['code' => 'al_'.uniqid(), 'name' => 'ИП', 'legal_form' => 'IP', 'inn' => '770000000010', 'is_default' => true]); + adminSeedInvoice($tenant->id, $le->id, 'issued', '100.00', 'СЧ-2026-01001'); + adminSeedInvoice($tenant->id, $le->id, 'issued', '200.00', 'СЧ-2026-01002'); + adminSeedInvoice($tenant->id, $le->id, 'paid', '300.00', 'СЧ-2026-01003'); + + $this->getJson('/api/admin/invoices?status=issued') + ->assertOk() + ->assertJsonStructure(['data' => [['id', 'invoice_number', 'amount_total', 'status', 'tenant_id']], 'meta' => ['total']]) + ->assertJsonPath('meta.total', 2); +}); + +it('POST /api/admin/invoices/{id}/mark-paid зачисляет баланс и ставит paid', function () { + Storage::fake('local'); + $tenant = Tenant::factory()->create(['balance_rub' => '0.00']); + User::factory()->create(['tenant_id' => $tenant->id]); + $le = LegalEntity::create(['code' => 'al2_'.uniqid(), 'name' => 'ИП', 'legal_form' => 'IP', 'inn' => '770000000011', 'is_default' => true]); + $invoice = adminSeedInvoice($tenant->id, $le->id, 'issued', '700.00', 'СЧ-2026-01010'); + + $this->postJson("/api/admin/invoices/{$invoice->id}/mark-paid")->assertOk(); + + expect((string) $tenant->fresh()->balance_rub)->toBe('700.00') + ->and(SaasInvoice::find($invoice->id)->status)->toBe('paid'); +}); diff --git a/app/tests/Feature/Billing/ExpireInvoicesTest.php b/app/tests/Feature/Billing/ExpireInvoicesTest.php new file mode 100644 index 00000000..3aba31f0 --- /dev/null +++ b/app/tests/Feature/Billing/ExpireInvoicesTest.php @@ -0,0 +1,33 @@ + $tenantId, 'legal_entity_id' => $leId, 'invoice_number' => $number, + 'payer_type' => 'legal', 'amount_net' => '100.00', 'amount_total' => '100.00', + 'status' => $status, 'issued_at' => now()->subDays(10), 'expires_at' => $expiresAt, + ]); +} + +it('помечает overdue только просроченные неоплаченные счета', function () { + $t = Tenant::factory()->create(); + $le = LegalEntity::create(['code' => 'exp_'.uniqid(), 'name' => 'ИП', 'legal_form' => 'IP', 'inn' => '770000000020']); + $stale = expSeed($t->id, $le->id, 'issued', 'СЧ-2026-02001', now()->subDay()); + $fresh = expSeed($t->id, $le->id, 'issued', 'СЧ-2026-02002', now()->addDay()); + $paid = expSeed($t->id, $le->id, 'paid', 'СЧ-2026-02003', now()->subDay()); + + $this->artisan('invoices:expire')->assertExitCode(0); + + expect(SaasInvoice::find($stale->id)->status)->toBe('overdue') + ->and(SaasInvoice::find($fresh->id)->status)->toBe('issued') + ->and(SaasInvoice::find($paid->id)->status)->toBe('paid'); +}); diff --git a/app/tests/Feature/Billing/InvoiceCreateTest.php b/app/tests/Feature/Billing/InvoiceCreateTest.php new file mode 100644 index 00000000..937d6ae6 --- /dev/null +++ b/app/tests/Feature/Billing/InvoiceCreateTest.php @@ -0,0 +1,102 @@ + 'seller_'.uniqid(), 'name' => 'ИП Лидерра', 'legal_form' => 'IP', + 'inn' => '770000000001', 'bank_name' => 'ВТБ', 'bank_bik' => '044525187', + 'bank_account' => '40802810000000000001', 'bank_corr' => '30101810700000000187', + 'is_default' => true, + ]); +} + +function makeClientRequisites(int $tenantId): TenantRequisites +{ + return TenantRequisites::create([ + 'tenant_id' => $tenantId, + 'subject_type' => 'legal_entity', + 'contact_name' => 'Иван Клиентов', + 'contact_phone' => '+79150000000', + 'inn' => '5000000000', + 'legal_name' => 'ООО Клиент', + 'kpp' => '500001001', + 'legal_address' => 'г. Москва, ул. Пример, 1', + 'bank_account' => '40702810000000000002', + ]); +} + +it('создаёт счёт issued с позицией, без НДС, номером и PDF', function () { + Storage::fake('local'); + $tenant = Tenant::factory()->create(); + makeSellerLe(); + makeClientRequisites($tenant->id); + + $invoice = app(InvoiceService::class)->create($tenant->id, '1500.00', null); + + expect($invoice->status)->toBe(SaasInvoice::STATUS_ISSUED) + ->and((string) $invoice->amount_total)->toBe('1500.00') + ->and((float) $invoice->vat_amount)->toBe(0.0) + ->and($invoice->invoice_number)->toStartWith('СЧ-') + ->and($invoice->pdf_path)->not->toBeNull() + ->and($invoice->payer_name)->toBe('ООО Клиент') + ->and($invoice->items()->count())->toBe(1) + ->and($invoice->payment_purpose)->toContain($invoice->invoice_number); + + Storage::disk('local')->assertExists($invoice->pdf_path); +}); + +it('бросает доменную ошибку если реквизиты клиента не заполнены', function () { + $tenant = Tenant::factory()->create(); + makeSellerLe(); + + app(InvoiceService::class)->create($tenant->id, '1500.00', null); +})->throws(RequisitesIncompleteException::class); + +it('POST /api/billing/invoices создаёт счёт и возвращает 201 с pdf-ссылкой', function () { + Storage::fake('local'); + $tenant = Tenant::factory()->create(); + makeSellerLe(); + makeClientRequisites($tenant->id); + $this->actingAs(User::factory()->create(['tenant_id' => $tenant->id])); + + $this->postJson('/api/billing/invoices', ['amount_rub' => 2000]) + ->assertStatus(201) + ->assertJsonStructure(['invoice' => ['id', 'invoice_number', 'amount_total', 'pdf_url']]); +}); + +it('POST /api/billing/invoices без реквизитов → 422', function () { + Storage::fake('local'); + $tenant = Tenant::factory()->create(); + makeSellerLe(); + $this->actingAs(User::factory()->create(['tenant_id' => $tenant->id])); + + $this->postJson('/api/billing/invoices', ['amount_rub' => 2000])->assertStatus(422); +}); + +it('GET /api/billing/invoices/{id}/pdf скачивает PDF своего счёта', function () { + Storage::fake('local'); + $tenant = Tenant::factory()->create(); + makeSellerLe(); + makeClientRequisites($tenant->id); + $this->actingAs(User::factory()->create(['tenant_id' => $tenant->id])); + + $invoice = app(InvoiceService::class)->create($tenant->id, '2000.00', null); + + $this->get("/api/billing/invoices/{$invoice->id}/pdf") + ->assertOk() + ->assertHeader('content-type', 'application/pdf'); +}); diff --git a/app/tests/Feature/Billing/InvoiceMarkPaidTest.php b/app/tests/Feature/Billing/InvoiceMarkPaidTest.php new file mode 100644 index 00000000..e0acad1c --- /dev/null +++ b/app/tests/Feature/Billing/InvoiceMarkPaidTest.php @@ -0,0 +1,69 @@ +create(['balance_rub' => $balance]); + User::factory()->create(['tenant_id' => $tenant->id]); + $le = LegalEntity::create([ + 'code' => 'mp_'.uniqid(), 'name' => 'ИП Лидерра', 'legal_form' => 'IP', + 'inn' => '770000000099', 'is_default' => true, + ]); + $invoice = SaasInvoice::create([ + 'tenant_id' => $tenant->id, 'legal_entity_id' => $le->id, + 'invoice_number' => 'СЧ-2026-00777', 'payer_type' => 'legal', 'payer_name' => 'ООО К', + 'payer_inn' => '5000000000', 'amount_net' => $amount, 'amount_total' => $amount, + 'status' => SaasInvoice::STATUS_ISSUED, 'issued_at' => now(), 'expires_at' => now()->addDays(5), + ]); + + return [$tenant, $invoice]; +} + +it('mark-paid зачисляет баланс, ставит paid, создаёт акт и шлёт письмо', function () { + Storage::fake('local'); + Mail::fake(); + [$tenant, $invoice] = seedPaidScenario('100.00', '1500.00'); + + app(InvoicePaymentService::class)->markPaid($invoice->id); + + $invoice->refresh(); + $tenant->refresh(); + expect($invoice->status)->toBe(SaasInvoice::STATUS_PAID) + ->and($invoice->paid_at)->not->toBeNull() + ->and((string) $tenant->balance_rub)->toBe('1600.00') + ->and(SaasTransaction::where('invoice_id', $invoice->id)->where('status', 'success')->count())->toBe(1) + ->and(SaasUpdDocument::where('invoice_id', $invoice->id)->count())->toBe(1); + + $actPath = SaasUpdDocument::where('invoice_id', $invoice->id)->value('pdf_path'); + Mail::assertQueued(InvoicePaidNotification::class, fn ($mail) => $mail->actPdfPath === $actPath + && count($mail->attachments()) === 1); +}); + +it('повторный mark-paid идемпотентен — баланс не удваивается, второй акт не создаётся', function () { + Storage::fake('local'); + Mail::fake(); + [$tenant, $invoice] = seedPaidScenario('0.00', '500.00'); + + $svc = app(InvoicePaymentService::class); + $svc->markPaid($invoice->id); + $svc->markPaid($invoice->id); + + $tenant->refresh(); + expect((string) $tenant->balance_rub)->toBe('500.00') + ->and(SaasUpdDocument::where('invoice_id', $invoice->id)->count())->toBe(1); +}); diff --git a/app/tests/Feature/Billing/InvoiceNumberGeneratorTest.php b/app/tests/Feature/Billing/InvoiceNumberGeneratorTest.php new file mode 100644 index 00000000..bd74d2cf --- /dev/null +++ b/app/tests/Feature/Billing/InvoiceNumberGeneratorTest.php @@ -0,0 +1,59 @@ + 'le_num_'.uniqid(), 'name' => 'ИП Тест', 'legal_form' => 'IP', 'inn' => '770000000000', + ]); +} + +function seedNumberingInvoice(int $tenantId, int $legalEntityId, string $number, string $issuedAt): SaasInvoice +{ + return SaasInvoice::create([ + 'tenant_id' => $tenantId, + 'legal_entity_id' => $legalEntityId, + 'invoice_number' => $number, + 'payer_type' => 'legal', + 'amount_net' => '100.00', + 'amount_total' => '100.00', + 'status' => SaasInvoice::STATUS_ISSUED, + 'issued_at' => $issuedAt, + 'expires_at' => $issuedAt, + ]); +} + +it('первый счёт юрлица за год получает номер -00001', function () { + $le = makeLeForNumbering(); + $num = (new InvoiceNumberGenerator)->next($le->id, Carbon::parse('2026-06-29 12:00:00')); + expect($num)->toBe('СЧ-2026-00001'); +}); + +it('следующий номер инкрементируется по существующим счетам того же юрлица/года', function () { + $tenant = Tenant::factory()->create(); + $le = makeLeForNumbering(); + seedNumberingInvoice($tenant->id, $le->id, 'СЧ-2026-00007', '2026-03-01 00:00:00'); + + $num = (new InvoiceNumberGenerator)->next($le->id, Carbon::parse('2026-06-29 12:00:00')); + expect($num)->toBe('СЧ-2026-00008'); +}); + +it('нумерация изолирована по юрлицу', function () { + $tenant = Tenant::factory()->create(); + $leA = makeLeForNumbering(); + $leB = makeLeForNumbering(); + seedNumberingInvoice($tenant->id, $leA->id, 'СЧ-2026-00042', '2026-02-01 00:00:00'); + + expect((new InvoiceNumberGenerator)->next($leB->id, Carbon::parse('2026-06-29 12:00:00'))) + ->toBe('СЧ-2026-00001'); +}); diff --git a/app/tests/Frontend/AdminInvoicesView.spec.ts b/app/tests/Frontend/AdminInvoicesView.spec.ts new file mode 100644 index 00000000..ad30fbd5 --- /dev/null +++ b/app/tests/Frontend/AdminInvoicesView.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import AdminInvoicesView from '../../resources/js/views/admin/AdminInvoicesView.vue'; +import * as adminApi from '../../resources/js/api/admin'; + +const oneIssued = { + data: [ + { + id: 5, + invoice_number: 'СЧ-2026-00005', + amount_total: '700.00', + status: 'issued', + issued_at: '2026-06-29', + expires_at: null, + tenant_id: 2, + tenant_name: 'ООО Клиент', + payer_name: 'ООО Клиент', + }, + ], + meta: { total: 1, current_page: 1, last_page: 1, per_page: 25 }, +}; + +describe('AdminInvoicesView', () => { + beforeEach(() => vi.clearAllMocks()); + + it('рендерит счёт и его статус', async () => { + vi.spyOn(adminApi, 'listAdminInvoices').mockResolvedValue(oneIssued); + const w = mount(AdminInvoicesView, { global: { plugins: [createVuetify()] } }); + await flushPromises(); + expect(w.text()).toContain('СЧ-2026-00005'); + expect(w.text()).toContain('Выставлен'); + }); + + it('«Отметить оплаченным» открывает диалог и зовёт markInvoicePaid после подтверждения', async () => { + vi.spyOn(adminApi, 'listAdminInvoices').mockResolvedValue(oneIssued); + const spy = vi.spyOn(adminApi, 'markInvoicePaid').mockResolvedValue(); + + const w = mount(AdminInvoicesView, { + global: { + plugins: [createVuetify()], + stubs: { + VDialog: { + template: '
', + props: ['modelValue'], + }, + }, + }, + }); + await flushPromises(); + + const btn = w.find('[data-testid="mark-paid-5"]'); + expect(btn.exists()).toBe(true); + await btn.trigger('click'); + await w.vm.$nextTick(); + + const confirm = w.findAll('button').find((b) => b.text().includes('Подтверждаю')); + expect(confirm).toBeTruthy(); + await confirm!.trigger('click'); + await flushPromises(); + + expect(spy).toHaveBeenCalledWith(5); + }); +}); diff --git a/app/tests/Frontend/BillingInvoices.spec.ts b/app/tests/Frontend/BillingInvoices.spec.ts new file mode 100644 index 00000000..a2cadbbb --- /dev/null +++ b/app/tests/Frontend/BillingInvoices.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import InvoicesTable from '../../resources/js/components/billing/InvoicesTable.vue'; +import * as billingApi from '../../resources/js/api/billing'; +import type { BillingInvoice } from '../../resources/js/api/billing'; + +function inv(over: Partial = {}): BillingInvoice { + return { + id: 1, + invoice_number: 'СЧ-2026-00001', + amount_total: '1500.00', + status: 'issued', + issued_at: '2026-06-29T09:00:00+00:00', + expires_at: '2026-07-05T09:00:00+00:00', + has_pdf: true, + has_act: false, + pdf_url: '/api/billing/invoices/1/pdf', + act_url: null, + ...over, + }; +} + +describe('InvoicesTable — список счетов', () => { + beforeEach(() => vi.clearAllMocks()); + + it('рендерит счёт со статусом и кнопкой «Счёт»; кнопки «Акт» нет пока счёт не оплачен', async () => { + vi.spyOn(billingApi, 'getInvoices').mockResolvedValue({ data: [inv()] }); + const w = mount(InvoicesTable, { global: { plugins: [createVuetify()] } }); + await flushPromises(); + + expect(w.text()).toContain('СЧ-2026-00001'); + expect(w.text()).toContain('Выставлен'); + expect(w.find('[data-testid="inv-pdf-1"]').exists()).toBe(true); + expect(w.find('[data-testid="inv-act-1"]').exists()).toBe(false); + }); + + it('для оплаченного счёта показывает кнопку «Акт»', async () => { + vi.spyOn(billingApi, 'getInvoices').mockResolvedValue({ + data: [inv({ id: 2, status: 'paid', has_act: true, act_url: '/api/billing/invoices/2/act' })], + }); + const w = mount(InvoicesTable, { global: { plugins: [createVuetify()] } }); + await flushPromises(); + + expect(w.text()).toContain('Оплачен'); + expect(w.find('[data-testid="inv-act-2"]').exists()).toBe(true); + }); + + it('пустой список — empty-state', async () => { + vi.spyOn(billingApi, 'getInvoices').mockResolvedValue({ data: [] }); + const w = mount(InvoicesTable, { global: { plugins: [createVuetify()] } }); + await flushPromises(); + expect(w.text()).toContain('появятся'); + }); +}); diff --git a/app/tests/Frontend/InvoicesTable.spec.ts b/app/tests/Frontend/InvoicesTable.spec.ts index edbd883d..8366ac68 100644 --- a/app/tests/Frontend/InvoicesTable.spec.ts +++ b/app/tests/Frontend/InvoicesTable.spec.ts @@ -9,24 +9,32 @@ vi.mock('../../resources/js/api/billing'); const vuetify = createVuetify(); +function inv(over: Partial = {}): BillingInvoice { + return { + id: 1, + invoice_number: 'СЧ-2026-00001', + amount_total: '990.00', + status: 'issued', + issued_at: '2026-05-07T00:00:00Z', + expires_at: '2026-05-14T00:00:00Z', + has_pdf: true, + has_act: false, + pdf_url: '/api/billing/invoices/1/pdf', + act_url: null, + ...over, + }; +} + describe('InvoicesTable.vue', () => { it('показывает empty-state без счетов', async () => { vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: [] }); const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } }); await flushPromises(); - expect(wrapper.text()).toContain('Счета появятся'); + expect(wrapper.text()).toContain('появятся'); }); it('рендерит строки счетов из API', async () => { - const inv: BillingInvoice = { - id: 1, - invoice_number: 'СЧ-2026-00001', - amount_total: '990.00', - status: 'issued', - issued_at: '2026-05-07T00:00:00Z', - has_pdf: true, - }; - vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: [inv] }); + vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: [inv()] }); const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } }); await flushPromises(); const text = wrapper.text(); @@ -34,36 +42,31 @@ describe('InvoicesTable.vue', () => { expect(text).toContain('Выставлен'); }); - it('PDF-кнопка disabled при has_pdf=false и активна при has_pdf=true', async () => { + it('кнопка «Счёт» disabled при has_pdf=false и активна при has_pdf=true', async () => { const invs: BillingInvoice[] = [ - { - id: 1, - invoice_number: 'СЧ-2026-00010', - amount_total: '990.00', - status: 'issued', - issued_at: '2026-05-07T00:00:00Z', - has_pdf: false, - }, - { - id: 2, - invoice_number: 'СЧ-2026-00011', - amount_total: '500.00', - status: 'paid', - issued_at: '2026-05-08T00:00:00Z', - has_pdf: true, - }, + inv({ id: 1, invoice_number: 'СЧ-2026-00010', has_pdf: false, pdf_url: null }), + inv({ id: 2, invoice_number: 'СЧ-2026-00011', status: 'paid', has_pdf: true }), ]; vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: invs }); const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } }); await flushPromises(); - const pdfButtons = wrapper.findAll('button').filter((b) => b.text().includes('PDF')); + const pdfButtons = wrapper.findAll('button, a').filter((b) => b.text().includes('Счёт')); expect(pdfButtons).toHaveLength(2); // Строка 1 (has_pdf=false) → disabled; строка 2 (has_pdf=true) → активна. expect(pdfButtons[0].attributes('disabled')).toBeDefined(); expect(pdfButtons[1].attributes('disabled')).toBeUndefined(); }); + it('показывает кнопку «Акт» только при has_act=true', async () => { + vi.mocked(billingApi.getInvoices).mockResolvedValue({ + data: [inv({ id: 7, status: 'paid', has_act: true, act_url: '/api/billing/invoices/7/act' })], + }); + const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } }); + await flushPromises(); + expect(wrapper.find('[data-testid="inv-act-7"]').exists()).toBe(true); + }); + it('показывает error-alert при сбое', async () => { vi.mocked(billingApi.getInvoices).mockRejectedValue(new Error('fail')); const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } }); @@ -72,15 +75,9 @@ describe('InvoicesTable.vue', () => { }); it('renders amount_total with ₽ suffix', async () => { - const inv: BillingInvoice = { - id: 1, - invoice_number: 'INV-1', - amount_total: '1234.00', - status: 'paid', - issued_at: '2026-05-23T00:00:00Z', - has_pdf: true, - }; - vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: [inv] }); + vi.mocked(billingApi.getInvoices).mockResolvedValue({ + data: [inv({ invoice_number: 'INV-1', amount_total: '1234.00', status: 'paid' })], + }); const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } }); await flushPromises(); expect(wrapper.text()).toMatch(/1\s?234\s?₽/); diff --git a/app/tests/Frontend/TopupDialogInvoice.spec.ts b/app/tests/Frontend/TopupDialogInvoice.spec.ts new file mode 100644 index 00000000..e925dae9 --- /dev/null +++ b/app/tests/Frontend/TopupDialogInvoice.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import TopupDialog from '../../resources/js/components/billing/TopupDialog.vue'; +import * as billingApi from '../../resources/js/api/billing'; + +describe('TopupDialog — оплата по счёту', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('open', vi.fn()); + }); + + it('при способе «По счёту» и сумме вызывает createInvoice и открывает PDF', async () => { + const spy = vi.spyOn(billingApi, 'createInvoice').mockResolvedValue({ + id: 1, + invoice_number: 'СЧ-2026-00001', + amount_total: '1500.00', + pdf_url: '/api/billing/invoices/1/pdf', + }); + + const wrapper = mount(TopupDialog, { + props: { modelValue: true }, + global: { plugins: [createVuetify()] }, + }); + + const vm = wrapper.vm as unknown as { method: string; amount: number | null; submit: () => Promise }; + vm.method = 'invoice'; + vm.amount = 1500; + await vm.submit(); + await flushPromises(); + + expect(spy).toHaveBeenCalledWith(1500); + expect(window.open).toHaveBeenCalledWith('/api/billing/invoices/1/pdf', '_blank'); + expect(wrapper.emitted('invoiced')?.[0]).toEqual(['СЧ-2026-00001']); + }); + + it('способ «Картой» вызывает topup, не createInvoice', async () => { + const topupSpy = vi.spyOn(billingApi, 'topup').mockResolvedValue({ balance_rub: '2000.00' }); + const invoiceSpy = vi.spyOn(billingApi, 'createInvoice'); + + const wrapper = mount(TopupDialog, { + props: { modelValue: true }, + global: { plugins: [createVuetify()] }, + }); + + const vm = wrapper.vm as unknown as { method: string; amount: number | null; submit: () => Promise }; + vm.method = 'card'; + vm.amount = 2000; + await vm.submit(); + await flushPromises(); + + expect(topupSpy).toHaveBeenCalledWith(2000); + expect(invoiceSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/docs/superpowers/plans/2026-06-29-oplata-po-schetu-etap1.md b/docs/superpowers/plans/2026-06-29-oplata-po-schetu-etap1.md new file mode 100644 index 00000000..23d4ab9c --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-oplata-po-schetu-etap1.md @@ -0,0 +1,1339 @@ +# Оплата по счёту (Этап 1) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Клиент-юрлицо самостоятельно формирует счёт на пополнение баланса (PDF), оплачивает банковским переводом по реквизитам ВТБ; администратор отмечает оплату одной кнопкой → баланс зачисляется, формируется Акт (без НДС), клиент получает письмо. + +**Architecture:** Переиспользуем существующие таблицы (`saas_invoices`, `saas_invoice_items`, `saas_upd_documents`, `saas_transactions`), сервис зачисления `BillingTopupService::topup()` и письмо `InvoicePaidNotification`. Добавляем PDF-генерацию (dompdf), сервисы создания счёта/акта/отметки оплаты, клиентские и админ-эндпоинты, экраны Vue. Схема БД НЕ меняется. + +**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16 (RLS), Vue 3 + Vuetify 3, Pest 4 (backend), Vitest (frontend), barryvdh/laravel-dompdf. + +**Спека:** [docs/superpowers/specs/2026-06-29-oplata-po-schetu-design.md](../specs/2026-06-29-oplata-po-schetu-design.md) + +**Рабочая директория кода:** `app/` (Laravel-корень). Все пути ниже — относительно `app/`, если не указано иное. + +--- + +## Карта файлов + +**Создаём:** +- `app/Services/Billing/Invoice/InvoiceNumberGenerator.php` — атомарная нумерация `СЧ-ГГГГ-NNNNN` по `legal_entity_id`. +- `app/Services/Billing/Invoice/InvoiceService.php` — создание счёта + позиции + PDF. +- `app/Services/Billing/Invoice/ActService.php` — создание Акта (закрывающий, без НДС) + PDF. +- `app/Services/Billing/Invoice/InvoicePaymentService.php` — отметка оплаты (зачисление + акт + письмо, идемпотентно). +- `app/Services/Billing/Invoice/PdfRenderer.php` — обёртка dompdf (Blade→PDF, сохранение в storage). +- `app/Http/Controllers/Api/InvoiceController.php` — клиентские эндпоинты счетов. +- `app/Http/Controllers/Api/AdminInvoiceController.php` — админ список + mark-paid. +- `app/Models/SaasInvoice.php`, `app/Models/SaasInvoiceItem.php`, `app/Models/SaasUpdDocument.php` — Eloquent-модели (если отсутствуют — проверить `app/Models/`). +- `resources/views/pdf/invoice.blade.php` — шаблон счёта. +- `resources/views/pdf/act.blade.php` — шаблон акта. +- `resources/js/views/admin/AdminInvoicesView.vue` — админ-экран «Счета». +- Тесты: `tests/Unit/Billing/InvoiceNumberGeneratorTest.php`, `tests/Feature/Billing/InvoiceCreateTest.php`, `tests/Feature/Billing/InvoiceMarkPaidTest.php`, `tests/Feature/Billing/AdminInvoiceIndexTest.php`, `tests/Frontend/TopupDialogInvoice.spec.ts`, `tests/Frontend/AdminInvoicesView.spec.ts`. + +**Модифицируем:** +- `composer.json` — +dompdf. +- `routes/web.php` — клиентские роуты в группе `/api/billing` (строка ~235) + админ в группе `saas-admin`/`admin-db` (строка ~111). +- `app/Http/Controllers/Api/BillingController.php:301` — расширить `invoices()` (добавить `has_act`, `act` ссылки, `expires_at`). +- `resources/js/components/billing/TopupDialog.vue` — вкладка «По счёту». +- `resources/js/api/admin.ts` + клиентский billing api-модуль — методы. + +--- + +## Предусловие (данные, не код) — собрать у владельца ПЕРЕД демо + +В `legal_entities` должна быть строка нашего ИП с `is_default=true`: `name`, `inn`, `bank_account`, `bank_bik`, `legal_address`, банк (ВТБ). Без неё шапка счёта пустая. На демо использовать тестовые реквизиты, для прода — реальные от владельца. + +--- + +## Task 1: Установка dompdf + +**Files:** +- Modify: `app/composer.json` +- Create: `app/config/dompdf.php` (публикуется пакетом) + +- [ ] **Step 1: Установить пакет** + +Run (в `app/`): +```bash +composer require barryvdh/laravel-dompdf:^3.1 +``` +Expected: пакет добавлен в `require`, `barryvdh/laravel-dompdf` в composer.lock. + +- [ ] **Step 2: Опубликовать конфиг и включить шрифт с кириллицей** + +Run: +```bash +php artisan vendor:publish --provider="Barryvdh\DomPDF\ServiceProvider" +``` +В `config/dompdf.php` убедиться: `'default_font' => 'dejavu sans'` (DejaVu Sans поддерживает кириллицу из коробки в dompdf). + +- [ ] **Step 3: Smoke-проверка рендера** + +Run: +```bash +php artisan tinker --execute="echo strlen(\Barryvdh\DomPDF\Facade\Pdf::loadHTML('

Тест кириллица

')->output());" +``` +Expected: число > 1000 (PDF сгенерирован, не пусто). + +- [ ] **Step 4: Commit** (по эскейпу — см. «Правила коммитов» в конце) + +```bash +git add app/composer.json app/composer.lock app/config/dompdf.php +git commit -m "chore(billing): add dompdf for invoice/act PDF generation" +``` + +--- + +## Task 2: InvoiceNumberGenerator — атомарная нумерация + +Формат `СЧ-ГГГГ-NNNNN` (год по Europe/Moscow), последовательная в пределах `legal_entity_id`+год, без дыр и гонок. Используем PostgreSQL advisory lock на пару (legal_entity_id, year), затем MAX+1 среди счетов этого юрлица за год. + +**Files:** +- Create: `app/Services/Billing/Invoice/InvoiceNumberGenerator.php` +- Test: `tests/Unit/Billing/InvoiceNumberGeneratorTest.php` + +- [ ] **Step 1: Failing test** + +```php +next(legalEntityId: 1, now: \Illuminate\Support\Carbon::parse('2026-06-29 12:00:00')); + expect($num)->toBe('СЧ-2026-00001'); +}); + +it('следующий номер инкрементируется по существующим счетам того же юрлица/года', function () { + SaasInvoice::factory()->create([ + 'legal_entity_id' => 1, + 'invoice_number' => 'СЧ-2026-00007', + 'issued_at' => '2026-03-01 00:00:00', + ]); + $gen = new InvoiceNumberGenerator; + $num = $gen->next(legalEntityId: 1, now: \Illuminate\Support\Carbon::parse('2026-06-29 12:00:00')); + expect($num)->toBe('СЧ-2026-00008'); +}); +``` + +- [ ] **Step 2: Run, verify FAIL** + +Run: `php artisan test tests/Unit/Billing/InvoiceNumberGeneratorTest.php` +Expected: FAIL «Class InvoiceNumberGenerator not found». + +- [ ] **Step 3: Implement** + +```php +year; + + // Advisory lock на пару чисел (legal_entity_id, year) — освобождается на COMMIT. + DB::statement('SELECT pg_advisory_xact_lock(?, ?)', [$legalEntityId, $year]); + + $prefix = sprintf('СЧ-%d-', $year); + $maxNumber = SaasInvoice::query() + ->where('legal_entity_id', $legalEntityId) + ->where('invoice_number', 'like', $prefix.'%') + ->orderByDesc('invoice_number') + ->value('invoice_number'); + + $seq = 1; + if ($maxNumber !== null) { + $seq = ((int) substr((string) $maxNumber, strlen($prefix))) + 1; + } + + return sprintf('%s%05d', $prefix, $seq); + } +} +``` + +- [ ] **Step 4: Создать фабрику если нужно** + +Если `database/factories/SaasInvoiceFactory.php` отсутствует — создать с дефолтами всех NOT NULL полей (`tenant_id`, `legal_entity_id`, `invoice_number`, `payer_type='legal'`, `amount_net`, `amount_total`, `expires_at`). Модель `SaasInvoice` создаётся в Task 3. + +- [ ] **Step 5: Run, verify PASS** + +Run: `php artisan test tests/Unit/Billing/InvoiceNumberGeneratorTest.php` +Expected: PASS (2 теста). Если падает на отсутствии модели — выполнить Task 3 Step «модели» первым, вернуться. + +- [ ] **Step 6: Commit** + +```bash +git add app/app/Services/Billing/Invoice/InvoiceNumberGenerator.php app/tests/Unit/Billing/InvoiceNumberGeneratorTest.php app/database/factories/SaasInvoiceFactory.php +git commit -m "feat(billing): atomic invoice number generator СЧ-ГГГГ-NNNNN" +``` + +--- + +## Task 3: Eloquent-модели счёта/позиции/акта + +**Files:** +- Create: `app/Models/SaasInvoice.php`, `app/Models/SaasInvoiceItem.php`, `app/Models/SaasUpdDocument.php` +- (проверить `app/Models/` — если уже есть, пропустить создание, дополнить fillable/casts) + +- [ ] **Step 1: Проверить наличие** + +Run: `ls app/app/Models/ | grep -i -E "SaasInvoice|SaasUpd"` +Expected: вероятно пусто → создаём. + +- [ ] **Step 2: SaasInvoice** + +```php + 'decimal:2', 'vat_amount' => 'decimal:2', 'amount_total' => 'decimal:2', + 'issued_at' => 'datetime', 'expires_at' => 'datetime', 'paid_at' => 'datetime', 'cancelled_at' => 'datetime', + ]; + + public function items() + { + return $this->hasMany(SaasInvoiceItem::class, 'invoice_id'); + } +} +``` + +- [ ] **Step 3: SaasInvoiceItem** + +```php + 'decimal:3', 'price' => 'decimal:2', + 'amount_net' => 'decimal:2', 'amount_total' => 'decimal:2', + ]; +} +``` + +- [ ] **Step 4: SaasUpdDocument** + +```php + 'decimal:2', 'amount_total' => 'decimal:2', 'issued_at' => 'datetime', + ]; +} +``` + +- [ ] **Step 5: Verify** + +Run: `php artisan test tests/Unit/Billing/InvoiceNumberGeneratorTest.php` +Expected: PASS (теперь модель есть). + +- [ ] **Step 6: Commit** + +```bash +git add app/app/Models/SaasInvoice.php app/app/Models/SaasInvoiceItem.php app/app/Models/SaasUpdDocument.php +git commit -m "feat(billing): Eloquent models for saas_invoices/items/upd" +``` + +--- + +## Task 4: PdfRenderer — обёртка dompdf + +**Files:** +- Create: `app/Services/Billing/Invoice/PdfRenderer.php` + +- [ ] **Step 1: Implement (тонкая обёртка, без отдельного теста — покрывается feature-тестами рендера)** + +```php + $data + */ + public function renderToStorage(string $view, array $data, string $relativePath): string + { + $pdf = Pdf::loadView($view, $data)->setPaper('a4'); + Storage::disk('local')->put($relativePath, $pdf->output()); + + return $relativePath; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/app/Services/Billing/Invoice/PdfRenderer.php +git commit -m "feat(billing): PdfRenderer wrapper around dompdf" +``` + +--- + +## Task 5: Blade-шаблоны счёта и акта + +**Files:** +- Create: `resources/views/pdf/invoice.blade.php`, `resources/views/pdf/act.blade.php` + +- [ ] **Step 1: invoice.blade.php** + +Шаблон российского счёта на оплату. Переменные: `$invoice` (SaasInvoice), `$items` (коллекция), `$seller` (LegalEntity — наш ИП). Все суммы — «Без НДС». + +```blade + + + + + + + + +
{{ $seller->bank_name ?? '' }}БИК{{ $seller->bank_bik ?? '' }}
Сч. №{{ $seller->bank_account ?? '' }}
Получатель
{{ $seller->name }}
ИНН {{ $seller->inn }}@if($seller->kpp) КПП {{ $seller->kpp }}@endif
Сч. №{{ $seller->settlement_account ?? $seller->bank_account ?? '' }}
+

Счёт на оплату № {{ $invoice->invoice_number }} от {{ \Illuminate\Support\Carbon::parse($invoice->issued_at)->format('d.m.Y') }}

+

Поставщик: {{ $seller->name }}, ИНН {{ $seller->inn }}, {{ $seller->legal_address ?? '' }}

+

Покупатель: {{ $invoice->payer_name }}, ИНН {{ $invoice->payer_inn }}@if($invoice->payer_kpp), КПП {{ $invoice->payer_kpp }}@endif, {{ $invoice->payer_address }}

+ + + @foreach($items as $i => $it) + + @endforeach +
НаименованиеКол-воЕд.ЦенаСумма
{{ $i+1 }}{{ $it->name }}{{ $it->quantity }}{{ $it->unit }}{{ $it->price }}{{ $it->amount_total }}
+

Итого: {{ $invoice->amount_total }} ₽
Без НДС

+

Назначение платежа: {{ $invoice->payment_purpose }}

+

Оплатить до: {{ \Illuminate\Support\Carbon::parse($invoice->expires_at)->format('d.m.Y') }}

+ +``` + +- [ ] **Step 2: act.blade.php** + +```blade + + +

Акт № {{ $act->upd_number }} от {{ \Illuminate\Support\Carbon::parse($act->issued_at)->format('d.m.Y') }}

+

Исполнитель: {{ $seller->name }}, ИНН {{ $seller->inn }}

+

Заказчик: {{ $act->buyer_name }}, ИНН {{ $act->buyer_inn }}

+

Основание: счёт № {{ $invoiceNumber }}

+ + + +
Наименование услугиСумма
1Пополнение баланса Лидерра{{ $act->amount_total }} ₽
+

Всего оказано услуг на сумму: {{ $act->amount_total }} ₽
Без НДС

+

Услуги оказаны полностью и в срок. Заказчик претензий по объёму, качеству и срокам не имеет.

+ +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/resources/views/pdf/invoice.blade.php app/resources/views/pdf/act.blade.php +git commit -m "feat(billing): PDF Blade templates for invoice and act" +``` + +> Примечание полей `legal_entities`: проверить реальные имена колонок банка (`bank_name`, `bank_bik`, `bank_account`, `settlement_account`?) по `db/schema.sql:280`. Если `settlement_account` нет — убрать из шаблона. Не выдумывать колонки. + +--- + +## Task 6: InvoiceService — создание счёта + +**Files:** +- Create: `app/Services/Billing/Invoice/InvoiceService.php` +- Test: `tests/Feature/Billing/InvoiceCreateTest.php` + +- [ ] **Step 1: Failing feature-тест** + +```php +create(); + LegalEntity::factory()->create(['is_default' => true, 'inn' => '7700000000', 'name' => 'ИП Тест']); + TenantRequisites::factory()->create([ + 'tenant_id' => $tenant->id, 'subject_type' => 'legal_entity', + 'inn' => '5000000000', 'organization_name' => 'ООО Клиент', 'bank_account' => '40702810000000000001', + ]); + + $invoice = app(InvoiceService::class)->create($tenant->id, '1500.00', $tenant->id); + + expect($invoice->status)->toBe(SaasInvoice::STATUS_ISSUED) + ->and($invoice->amount_total)->toBe('1500.00') + ->and((float) $invoice->vat_amount)->toBe(0.0) + ->and($invoice->invoice_number)->toStartWith('СЧ-') + ->and($invoice->pdf_path)->not->toBeNull() + ->and($invoice->items()->count())->toBe(1) + ->and($invoice->payment_purpose)->toContain($invoice->invoice_number); +}); + +it('бросает доменную ошибку если реквизиты клиента не заполнены', function () { + $tenant = Tenant::factory()->create(); + LegalEntity::factory()->create(['is_default' => true]); + app(InvoiceService::class)->create($tenant->id, '1500.00', $tenant->id); +})->throws(\App\Services\Billing\Invoice\RequisitesIncompleteException::class); +``` + +- [ ] **Step 2: Run, verify FAIL** — `php artisan test tests/Feature/Billing/InvoiceCreateTest.php` → FAIL (классов нет). + +- [ ] **Step 3: Implement exception** + +```php +first(); + if ($req === null || blank($req->inn)) { + throw new RequisitesIncompleteException('Заполните реквизиты компании.'); + } + + $seller = LegalEntity::where('is_default', true)->firstOrFail(); + + return DB::transaction(function () use ($tenantId, $amountRub, $req, $seller) { + $now = Carbon::now('Europe/Moscow'); + $number = $this->numbers->next($seller->id, $now); + + $invoice = SaasInvoice::create([ + 'tenant_id' => $tenantId, + 'legal_entity_id' => $seller->id, + 'invoice_number' => $number, + 'payer_type' => $req->subject_type === 'legal_entity' ? 'legal' : 'individual', + 'payer_name' => $req->organization_name ?? $req->contact_name, + 'payer_inn' => $req->inn, + 'payer_kpp' => $req->kpp, + 'payer_address' => $req->legal_address, + 'payer_email' => $req->contact_email ?? null, + 'amount_net' => $amountRub, + 'vat_rate' => 0, + 'vat_amount' => 0, + 'amount_total' => $amountRub, + 'payment_purpose' => "Оплата по счёту {$number}. Пополнение баланса Лидерра. Без НДС.", + 'status' => SaasInvoice::STATUS_ISSUED, + 'issued_at' => $now, + 'expires_at' => $now->copy()->addWeekdays(5), + ]); + + SaasInvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'name' => 'Пополнение баланса Лидерра', + 'quantity' => 1, 'unit' => 'усл.', + 'price' => $amountRub, 'amount_net' => $amountRub, + 'vat_rate' => 0, 'vat_amount' => 0, 'amount_total' => $amountRub, + ]); + + $path = $this->pdf->renderToStorage('pdf.invoice', [ + 'invoice' => $invoice, + 'items' => $invoice->items()->get(), + 'seller' => $seller, + ], "invoices/{$invoice->id}-{$number}.pdf"); + + $invoice->pdf_path = $path; + $invoice->save(); + + return $invoice; + }); + } +} +``` + +> Проверить реальные имена колонок `tenant_requisites` (`organization_name`? `contact_email`? `legal_address`?) по `db/schema.sql:751`. Подставить фактические; не выдумывать. + +- [ ] **Step 5: Run, verify PASS** — `php artisan test tests/Feature/Billing/InvoiceCreateTest.php`. При падении на именах колонок — поправить fillable/маппинг по схеме, повторить. + +- [ ] **Step 6: Commit** + +```bash +git add app/app/Services/Billing/Invoice/InvoiceService.php app/app/Services/Billing/Invoice/RequisitesIncompleteException.php app/tests/Feature/Billing/InvoiceCreateTest.php +git commit -m "feat(billing): InvoiceService — create issued invoice + items + PDF" +``` + +--- + +## Task 7: ActService — закрывающий документ (Акт, без НДС) + +**Files:** +- Create: `app/Services/Billing/Invoice/ActService.php` + +- [ ] **Step 1: Implement (тестируется внутри InvoiceMarkPaidTest, Task 8)** + +```php +legal_entity_id); + $now = Carbon::now('Europe/Moscow'); + $number = str_replace('СЧ-', 'АКТ-', $invoice->invoice_number); + + $act = SaasUpdDocument::create([ + 'tenant_id' => $invoice->tenant_id, + 'legal_entity_id' => $invoice->legal_entity_id, + 'upd_number' => $number, + 'upd_function' => SaasUpdDocument::FUNCTION_DOP, + 'buyer_type' => $invoice->payer_type, + 'buyer_name' => $invoice->payer_name, + 'buyer_inn' => $invoice->payer_inn, + 'buyer_kpp' => $invoice->payer_kpp, + 'buyer_address' => $invoice->payer_address, + 'amount_net' => $invoice->amount_total, + 'vat_rate' => 0, 'vat_amount' => 0, + 'amount_total' => $invoice->amount_total, + 'invoice_id' => $invoice->id, + 'transaction_id' => $transactionId, + 'status' => 'issued', + 'issued_at' => $now, + ]); + + $path = $this->pdf->renderToStorage('pdf.act', [ + 'act' => $act, 'seller' => $seller, 'invoiceNumber' => $invoice->invoice_number, + ], "acts/{$act->id}-{$number}.pdf"); + + $act->pdf_path = $path; + $act->save(); + + return $act; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/app/Services/Billing/Invoice/ActService.php +git commit -m "feat(billing): ActService — closing act (no VAT) PDF" +``` + +--- + +## Task 8: InvoicePaymentService — отметка оплаты (зачисление + акт + письмо) + +Образец идемпотентности и RLS-контекста — `PaymentWebhookController::receive()`. + +**Files:** +- Create: `app/Services/Billing/Invoice/InvoicePaymentService.php` +- Test: `tests/Feature/Billing/InvoiceMarkPaidTest.php` + +- [ ] **Step 1: Failing test** + +```php +create(['balance_rub' => '100.00']); + LegalEntity::factory()->create(['is_default' => true]); + $invoice = SaasInvoice::factory()->create([ + 'tenant_id' => $tenant->id, 'status' => SaasInvoice::STATUS_ISSUED, + 'amount_total' => '1500.00', 'amount_net' => '1500.00', + ]); + + app(InvoicePaymentService::class)->markPaid($invoice->id); + + $invoice->refresh(); + $tenant->refresh(); + expect($invoice->status)->toBe(SaasInvoice::STATUS_PAID) + ->and($invoice->paid_at)->not->toBeNull() + ->and((string) $tenant->balance_rub)->toBe('1600.00') + ->and(SaasTransaction::where('invoice_id', $invoice->id)->where('status', 'success')->count())->toBe(1) + ->and(SaasUpdDocument::where('invoice_id', $invoice->id)->count())->toBe(1); + Mail::assertQueued(InvoicePaidNotification::class); +}); + +it('повторный mark-paid идемпотентен — баланс не удваивается', function () { + Mail::fake(); + $tenant = Tenant::factory()->create(['balance_rub' => '0.00']); + LegalEntity::factory()->create(['is_default' => true]); + $invoice = SaasInvoice::factory()->create([ + 'tenant_id' => $tenant->id, 'status' => SaasInvoice::STATUS_ISSUED, + 'amount_total' => '500.00', 'amount_net' => '500.00', + ]); + $svc = app(InvoicePaymentService::class); + $svc->markPaid($invoice->id); + $svc->markPaid($invoice->id); + + $tenant->refresh(); + expect((string) $tenant->balance_rub)->toBe('500.00') + ->and(SaasUpdDocument::where('invoice_id', $invoice->id)->count())->toBe(1); +}); +``` + +- [ ] **Step 2: Run, verify FAIL.** + +- [ ] **Step 3: Implement** + +```php +tenant_id); + + // Атомарный claim issued→paid; 0 строк = уже оплачен (идемпотентный no-op). + $claimed = SaasInvoice::where('id', $invoice->id) + ->where('status', SaasInvoice::STATUS_ISSUED) + ->update(['status' => SaasInvoice::STATUS_PAID, 'paid_at' => now()]); + if ($claimed === 0) { + return; + } + + $tx = SaasTransaction::create([ + 'tenant_id' => $invoice->tenant_id, + 'type' => 'topup', + 'amount_rub' => $invoice->amount_total, + 'gateway_code' => 'bank_transfer', + 'payment_method' => 'bank_transfer', + 'legal_entity_id' => $invoice->legal_entity_id, + 'invoice_id' => $invoice->id, + 'status' => 'success', + 'description' => 'Оплата по счёту '.$invoice->invoice_number, + 'created_at' => now(), + 'completed_at' => now(), + ]); + + $balanceTx = $this->topup->topup((int) $invoice->tenant_id, (string) $invoice->amount_total, null); + + $act = $this->acts->createForInvoice($invoice->fresh(), (int) $tx->id); + + SaasTransaction::where('id', $tx->id)->update([ + 'balance_rub_after' => $balanceTx->balance_rub_after, + 'balance_transaction_id' => $balanceTx->id, + 'upd_id' => $act->id, + ]); + SaasInvoice::where('id', $invoice->id)->update(['transaction_id' => $tx->id]); + + // Письмо клиенту (после COMMIT — через afterCommit Mailable/queue). + $tenant = Tenant::find($invoice->tenant_id); + $recipient = User::where('tenant_id', $invoice->tenant_id)->orderBy('id')->first(); + if ($tenant !== null && $recipient !== null) { + Mail::to($recipient->email)->queue(new InvoicePaidNotification( + $recipient, $tenant, (string) $invoice->amount_total, $invoice->invoice_number, null + )); + } + }); + } +} +``` + +> Проверить enum `saas_transactions.type` — допускает `'topup'` (схема: CHECK type IN topup/refund/manual_credit/manual_debit ✓). `gateway_code` nullable ✓. + +- [ ] **Step 4: Run, verify PASS** (2 теста). Если RLS/SET LOCAL мешает в тест-БД (суперюзер) — тесты всё равно проходят (GUC игнор без политики). + +- [ ] **Step 5: rls-reviewer** + +Запустить агента `rls-reviewer` на изменения (зачисление под admin-соединением с SET LOCAL). Учесть его замечания (особенно: корректный tenant-контекст при admin-db соединении в Task 9). + +- [ ] **Step 6: Commit** + +```bash +git add app/app/Services/Billing/Invoice/InvoicePaymentService.php app/tests/Feature/Billing/InvoiceMarkPaidTest.php +git commit -m "feat(billing): InvoicePaymentService — idempotent mark-paid + credit + act + email" +``` + +--- + +## Task 9: Клиентский InvoiceController + роуты + +**Files:** +- Create: `app/Http/Controllers/Api/InvoiceController.php` +- Modify: `routes/web.php` (группа `/api/billing`, ~235) + `BillingController::invoices()` (расширить ответ) + +- [ ] **Step 1: Failing test (создание + 422)** — добавить в `tests/Feature/Billing/InvoiceCreateTest.php`: + +```php +it('POST /api/billing/invoices создаёт счёт и возвращает 201 с pdf-ссылкой', function () { + [$user, $tenant] = makeTenantUser(); // helper проекта; иначе Sanctum::actingAs + LegalEntity::factory()->create(['is_default' => true]); + TenantRequisites::factory()->create(['tenant_id' => $tenant->id, 'inn' => '5000000000', 'organization_name' => 'ООО К', 'bank_account' => '40702810000000000001', 'subject_type' => 'legal_entity']); + + $this->actingAs($user)->postJson('/api/billing/invoices', ['amount_rub' => 2000]) + ->assertStatus(201) + ->assertJsonStructure(['invoice' => ['id', 'invoice_number', 'amount_total', 'pdf_url']]); +}); + +it('POST /api/billing/invoices без реквизитов → 422', function () { + [$user, $tenant] = makeTenantUser(); + LegalEntity::factory()->create(['is_default' => true]); + $this->actingAs($user)->postJson('/api/billing/invoices', ['amount_rub' => 2000]) + ->assertStatus(422); +}); +``` + +> Использовать существующий тест-хелпер аутентификации тенанта (поискать в `tests/` как делают `InvoiceCreateTest`-соседи, напр. `Sanctum::actingAs` + tenant middleware). Не выдумывать `makeTenantUser`, если его нет — заменить на фактический паттерн проекта. + +- [ ] **Step 2: Implement controller** + +```php +validate([ + 'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'], + ]); + /** @var User $user */ + $user = $request->user(); + $amountRub = bcadd((string) $validated['amount_rub'], '0', 2); + + try { + $invoice = $this->invoices->create((int) $user->tenant_id, $amountRub, (int) $user->id); + } catch (RequisitesIncompleteException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json(['invoice' => [ + 'id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'amount_total' => $invoice->amount_total, + 'pdf_url' => "/api/billing/invoices/{$invoice->id}/pdf", + ]], 201); + } + + public function pdf(Request $request, int $id): StreamedResponse + { + /** @var User $user */ + $user = $request->user(); + $invoice = SaasInvoice::where('id', $id)->where('tenant_id', $user->tenant_id)->firstOrFail(); + abort_if($invoice->pdf_path === null || ! Storage::disk('local')->exists($invoice->pdf_path), 404); + + return Storage::disk('local')->download($invoice->pdf_path, "Счёт-{$invoice->invoice_number}.pdf"); + } + + public function act(Request $request, int $id): StreamedResponse + { + /** @var User $user */ + $user = $request->user(); + $invoice = SaasInvoice::where('id', $id)->where('tenant_id', $user->tenant_id)->firstOrFail(); + $act = \App\Models\SaasUpdDocument::where('invoice_id', $invoice->id)->firstOrFail(); + abort_if($act->pdf_path === null || ! Storage::disk('local')->exists($act->pdf_path), 404); + + return Storage::disk('local')->download($act->pdf_path, "Акт-{$act->upd_number}.pdf"); + } +} +``` + +- [ ] **Step 3: Routes** — в группе `/api/billing` (`routes/web.php` ~236) добавить: + +```php + Route::post('/invoices', 'App\Http\Controllers\Api\InvoiceController@store'); + Route::get('/invoices/{id}/pdf', 'App\Http\Controllers\Api\InvoiceController@pdf')->whereNumber('id'); + Route::get('/invoices/{id}/act', 'App\Http\Controllers\Api\InvoiceController@act')->whereNumber('id'); +``` + +- [ ] **Step 4: Расширить `BillingController::invoices()`** — в `select` добавить `expires_at`, `paid_at`; в map добавить `expires_at`, `has_act` (есть ли saas_upd_documents по invoice_id), `pdf_url`/`act_url`. Конкретно: + +```php +$rows = DB::table('saas_invoices') + ->where('tenant_id', $tenantId) + ->orderBy('issued_at', 'desc') + ->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'expires_at', 'pdf_path']); + +$actInvoiceIds = DB::table('saas_upd_documents') + ->where('tenant_id', $tenantId)->pluck('invoice_id')->filter()->flip(); + +return response()->json([ + 'data' => $rows->map(static fn (\stdClass $r): array => [ + 'id' => $r->id, + 'invoice_number' => $r->invoice_number, + 'amount_total' => $r->amount_total, + 'status' => $r->status, + 'issued_at' => $r->issued_at, + 'expires_at' => $r->expires_at, + 'has_pdf' => $r->pdf_path !== null, + 'has_act' => isset($actInvoiceIds[$r->id]), + 'pdf_url' => $r->pdf_path !== null ? "/api/billing/invoices/{$r->id}/pdf" : null, + 'act_url' => isset($actInvoiceIds[$r->id]) ? "/api/billing/invoices/{$r->id}/act" : null, + ])->all(), +]); +``` + +- [ ] **Step 5: Run, verify PASS** — `php artisan test tests/Feature/Billing/InvoiceCreateTest.php`. + +- [ ] **Step 6: Commit** + +```bash +git add app/app/Http/Controllers/Api/InvoiceController.php app/routes/web.php app/app/Http/Controllers/Api/BillingController.php app/tests/Feature/Billing/InvoiceCreateTest.php +git commit -m "feat(billing): client invoice API — create/list/download" +``` + +--- + +## Task 10: Админский AdminInvoiceController + роуты + +**Files:** +- Create: `app/Http/Controllers/Api/AdminInvoiceController.php` +- Modify: `routes/web.php` (группа `saas-admin`/`admin-db`, ~111) +- Test: `tests/Feature/Billing/AdminInvoiceIndexTest.php` + +- [ ] **Step 1: Failing test (список + mark-paid)** + +```php +create(); + SaasInvoice::factory()->count(3)->create(['tenant_id' => $tenant->id, 'status' => 'issued']); + actingAsAdmin() // helper зоны saas-admin (см. соседние Admin*Test) + ->getJson('/api/admin/invoices?status=issued') + ->assertOk() + ->assertJsonStructure(['data' => [['id', 'invoice_number', 'amount_total', 'status', 'tenant_id']], 'meta' => ['total']]); +}); + +it('POST /api/admin/invoices/{id}/mark-paid зачисляет и ставит paid', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '0.00']); + LegalEntity::factory()->create(['is_default' => true]); + $invoice = SaasInvoice::factory()->create(['tenant_id' => $tenant->id, 'status' => 'issued', 'amount_total' => '700.00', 'amount_net' => '700.00']); + + actingAsAdmin()->postJson("/api/admin/invoices/{$invoice->id}/mark-paid")->assertOk(); + + $tenant->refresh(); + expect($tenant->balance_rub)->toBe('700.00') + ->and(SaasInvoice::find($invoice->id)->status)->toBe('paid'); +}); +``` + +> `actingAsAdmin()` — использовать фактический паттерн зоны saas-admin из соседних тестов (`tests/Feature/.../Admin*Test.php`), не выдумывать. + +- [ ] **Step 2: Implement controller** (серверная пагинация/фильтр как `AdminTenantsController::index`) + +```php +query('per_page', 25))); + $query = DB::table('saas_invoices as i') + ->leftJoin('tenants as t', 't.id', '=', 'i.tenant_id') + ->select('i.id', 'i.invoice_number', 'i.amount_total', 'i.status', + 'i.issued_at', 'i.expires_at', 'i.tenant_id', 't.name as tenant_name', 'i.payer_name'); + + $status = $request->query('status'); + if (is_string($status) && in_array($status, ['issued', 'paid', 'overdue', 'cancelled'], true)) { + $query->where('i.status', $status); + } + $search = trim((string) $request->query('search', '')); + if ($search !== '') { + $query->where(function ($q) use ($search) { + $q->where('i.invoice_number', 'ilike', "%{$search}%") + ->orWhere('i.payer_name', 'ilike', "%{$search}%") + ->orWhere('t.name', 'ilike', "%{$search}%"); + }); + } + + $page = $query->orderByDesc('i.issued_at')->paginate($perPage); + + return response()->json([ + 'data' => array_map(static fn ($r) => (array) $r, $page->items()), + 'meta' => ['current_page' => $page->currentPage(), 'last_page' => $page->lastPage(), 'total' => $page->total(), 'per_page' => $page->perPage()], + ]); + } + + public function markPaid(Request $request, int $id): JsonResponse + { + $this->payments->markPaid($id); + + return response()->json(['status' => 'ok']); + } +} +``` + +- [ ] **Step 3: Routes** — в группе `saas-admin`/`admin-db` (`routes/web.php` ~138) добавить: + +```php + Route::get('/api/admin/invoices', 'App\Http\Controllers\Api\AdminInvoiceController@index'); + Route::post('/api/admin/invoices/{id}/mark-paid', 'App\Http\Controllers\Api\AdminInvoiceController@markPaid')->whereNumber('id'); +``` + +- [ ] **Step 4: Run, verify PASS.** Если падает на larastan-baseline (новые `getJson`) — обновить `count` в `phpstan-baseline.neon` (см. урок про getJson count-pattern). + +- [ ] **Step 5: Commit** + +```bash +git add app/app/Http/Controllers/Api/AdminInvoiceController.php app/routes/web.php app/tests/Feature/Billing/AdminInvoiceIndexTest.php app/phpstan-baseline.neon +git commit -m "feat(billing): admin invoice API — list + mark-paid" +``` + +--- + +## Task 11: Frontend — вкладка «По счёту» в TopupDialog + +**Files:** +- Modify: `resources/js/components/billing/TopupDialog.vue` +- Modify: клиентский billing api-модуль (поискать `resources/js/api/billing.ts` или где `topup` определён) +- Test: `tests/Frontend/TopupDialogInvoice.spec.ts` + +- [ ] **Step 1: Прочитать `TopupDialog.vue`** — понять текущую разметку (способ оплаты картой), Vuetify-компоненты, где разместить переключатель «Карта / По счёту». + +- [ ] **Step 2: Failing vitest** — `tests/Frontend/TopupDialogInvoice.spec.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import TopupDialog from '../../resources/js/components/billing/TopupDialog.vue'; +import * as billingApi from '../../resources/js/api/billing'; + +describe('TopupDialog — оплата по счёту', () => { + beforeEach(() => vi.clearAllMocks()); + + it('при выборе «По счёту» и сумме вызывает createInvoice', async () => { + const spy = vi.spyOn(billingApi, 'createInvoice').mockResolvedValue({ + id: 1, invoice_number: 'СЧ-2026-00001', amount_total: '1500.00', pdf_url: '/api/billing/invoices/1/pdf', + }); + const w = mount(TopupDialog, { props: { modelValue: true }, global: { plugins: [createVuetify()] } }); + const vm = w.vm as any; + vm.method = 'invoice'; + vm.amount = 1500; + await vm.submit(); + await flushPromises(); + expect(spy).toHaveBeenCalledWith(1500); + }); +}); +``` + +> Подогнать имена `method`/`amount`/`submit` под фактический компонент после Step 1; defineExpose при необходимости. + +- [ ] **Step 3: api-метод** — добавить в billing api-модуль: + +```ts +export interface CreatedInvoice { id: number; invoice_number: string; amount_total: string; pdf_url: string; } +export async function createInvoice(amountRub: number): Promise { + const { data } = await axios.post('/api/billing/invoices', { amount_rub: amountRub }); + return data.invoice; +} +``` + +- [ ] **Step 4: UI** — в TopupDialog добавить `v-btn-toggle` «Карта / По счёту (для юрлиц)»; при `method==='invoice'` кнопка «Сформировать счёт» → `createInvoice(amount)` → открыть `pdf_url` (скачивание) + тост «Счёт сформирован». Если 422 (нет реквизитов) — показать сообщение и ссылку на заполнение реквизитов. Палитра Forest, Vuetify-only. + +- [ ] **Step 5: Run, verify PASS** — `npm run test:vue -- TopupDialogInvoice`. + +- [ ] **Step 6: Commit** + +```bash +git add app/resources/js/components/billing/TopupDialog.vue app/resources/js/api/billing.ts app/tests/Frontend/TopupDialogInvoice.spec.ts +git commit -m "feat(billing): TopupDialog invoice payment tab" +``` + +--- + +## Task 12: Frontend — «Мои счета» (список + скачивание) в BillingView + +**Files:** +- Modify: `resources/js/views/BillingView.vue` (раздел счетов; найти где зовётся `/api/billing/invoices`) +- Modify: billing api-модуль (`listInvoices`) + +- [ ] **Step 1: Прочитать `BillingView.vue`** — найти существующий блок счетов (real-but-empty). + +- [ ] **Step 2: api-метод** + +```ts +export interface ClientInvoice { id:number; invoice_number:string; amount_total:string; status:string; issued_at:string; expires_at:string|null; has_pdf:boolean; has_act:boolean; pdf_url:string|null; act_url:string|null; } +export async function listInvoices(): Promise { + const { data } = await axios.get('/api/billing/invoices'); + return data.data; +} +``` + +- [ ] **Step 3: UI** — таблица счетов: номер, сумма, статус (чип: Выставлен/Оплачен/Просрочен), дата, кнопки «Скачать счёт» (`pdf_url`) и «Скачать акт» (`act_url`, если `has_act`). Статус-чипы в палитре Forest. + +- [ ] **Step 4: vitest** — `tests/Frontend/BillingInvoices.spec.ts`: мокнуть `listInvoices`, проверить рендер статуса и наличие кнопки «Скачать акт» только при `has_act`. + +- [ ] **Step 5: Run PASS + Commit** + +```bash +git add app/resources/js/views/BillingView.vue app/resources/js/api/billing.ts app/tests/Frontend/BillingInvoices.spec.ts +git commit -m "feat(billing): client «Мои счета» list with downloads" +``` + +--- + +## Task 13: Frontend — Админ-экран «Счета» + +**Files:** +- Create: `resources/js/views/admin/AdminInvoicesView.vue` +- Modify: `resources/js/api/admin.ts` (методы `listAdminInvoices`, `markInvoicePaid`) +- Modify: `routes/web.php` (`Route::view('/admin/invoices', 'welcome')`) + фронт-роутер (`resources/js/router`) + пункт меню админки +- Test: `tests/Frontend/AdminInvoicesView.spec.ts` + +- [ ] **Step 1: api-методы** + +```ts +export interface AdminInvoiceRow { id:number; invoice_number:string; amount_total:string; status:string; issued_at:string; expires_at:string|null; tenant_id:number; tenant_name:string|null; payer_name:string|null; } +export async function listAdminInvoices(p:{status?:string;search?:string;page?:number;perPage?:number}={}): Promise<{data:AdminInvoiceRow[];meta:{total:number;current_page:number;last_page:number;per_page:number}}> { + const { data } = await axios.get('/api/admin/invoices', { params: { status:p.status, search:p.search, page:p.page, per_page:p.perPage } }); + return data; +} +export async function markInvoicePaid(id:number): Promise { await axios.post(`/api/admin/invoices/${id}/mark-paid`); } +``` + +- [ ] **Step 2: Failing vitest** — `tests/Frontend/AdminInvoicesView.spec.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import AdminInvoicesView from '../../resources/js/views/admin/AdminInvoicesView.vue'; +import * as adminApi from '../../resources/js/api/admin'; + +describe('AdminInvoicesView', () => { + beforeEach(() => vi.clearAllMocks()); + it('кнопка «Отметить оплаченным» открывает диалог и зовёт markInvoicePaid после подтверждения', async () => { + vi.spyOn(adminApi, 'listAdminInvoices').mockResolvedValue({ + data: [{ id: 5, invoice_number: 'СЧ-2026-00005', amount_total: '700.00', status: 'issued', issued_at: '2026-06-29', expires_at: null, tenant_id: 2, tenant_name: 'ООО К', payer_name: 'ООО К' }], + meta: { total: 1, current_page: 1, last_page: 1, per_page: 25 }, + }); + const spy = vi.spyOn(adminApi, 'markInvoicePaid').mockResolvedValue(); + const w = mount(AdminInvoicesView, { + global: { plugins: [createVuetify()], stubs: { VDialog: { template: '
', props: ['modelValue'] } } }, + }); + await flushPromises(); + await w.find('[data-testid="mark-paid-5"]').trigger('click'); + await w.vm.$nextTick(); + const confirm = w.findAll('button').find(b => b.text().includes('Подтверждаю')); + await confirm!.trigger('click'); + await flushPromises(); + expect(spy).toHaveBeenCalledWith(5); + }); +}); +``` + +- [ ] **Step 3: Implement AdminInvoicesView.vue** — таблица (серверная пагинация/поиск/фильтр статуса как `AdminTenantsView.vue`), у `issued`-строк кнопка `[data-testid="mark-paid-{id}"]` «Отметить оплаченным» → `v-dialog` подтверждения с суммой/клиентом/номером → `markInvoicePaid(id)` → перезагрузка списка + тост. Палитра Forest. + +- [ ] **Step 4: Роут + меню** — `Route::view('/admin/invoices', 'welcome')` в `routes/web.php`; запись в фронт-роутере; пункт «Счета» в навигации админки (рядом с «Биллинг»). + +- [ ] **Step 5: Run PASS + Commit** + +```bash +git add app/resources/js/views/admin/AdminInvoicesView.vue app/resources/js/api/admin.ts app/routes/web.php app/resources/js/router app/tests/Frontend/AdminInvoicesView.spec.ts +git commit -m "feat(billing): admin «Счета» screen with mark-paid" +``` + +--- + +## Task 14: Просрочка счёта (overdue) + +**Files:** +- Create: `app/Console/Commands/ExpireInvoicesCommand.php` +- Modify: `routes/console.php` или `app/Console/Kernel.php` (расписание — найти как регистрируются cron в проекте) + +- [ ] **Step 1: Failing test** — `tests/Feature/Billing/ExpireInvoicesTest.php`: + +```php +create(); + $stale = SaasInvoice::factory()->create(['tenant_id' => $t->id, 'status' => 'issued', 'expires_at' => now()->subDay()]); + $fresh = SaasInvoice::factory()->create(['tenant_id' => $t->id, 'status' => 'issued', 'expires_at' => now()->addDay()]); + $paid = SaasInvoice::factory()->create(['tenant_id' => $t->id, 'status' => 'paid', 'expires_at' => now()->subDay()]); + + $this->artisan('invoices:expire')->assertOk(); + + expect(SaasInvoice::find($stale->id)->status)->toBe('overdue') + ->and(SaasInvoice::find($fresh->id)->status)->toBe('issued') + ->and(SaasInvoice::find($paid->id)->status)->toBe('paid'); +}); +``` + +- [ ] **Step 2: Implement command** + +```php +where('expires_at', '<', now()) + ->update(['status' => SaasInvoice::STATUS_OVERDUE]); + + return self::SUCCESS; + } +} +``` + +- [ ] **Step 3: Расписание** — добавить `$schedule->command('invoices:expire')->dailyAt('03:30')` тем же способом, что зарегистрированы прочие cron проекта (проверить `routes/console.php` / `bootstrap/app.php` withSchedule). + +- [ ] **Step 4: Run PASS + Commit** + +```bash +git add app/app/Console/Commands/ExpireInvoicesCommand.php app/routes/console.php app/tests/Feature/Billing/ExpireInvoicesTest.php +git commit -m "feat(billing): invoices:expire — mark overdue unpaid invoices" +``` + +--- + +## Task 15: Полный прогон + демо + +- [ ] **Step 1: Backend full** — `composer test` (или `php artisan test`). Все зелёные. +- [ ] **Step 2: Frontend full** — `npm run test:vue`. Все зелёные. +- [ ] **Step 3: Линт/стат** — `composer pint`, `composer stan` (обновить baseline при новых getJson), `npm run lint:vue`. +- [ ] **Step 4: Локальное демо для владельца** (требование заказчика): + - изолированный сервер (как при демо «Тенанты»): `CAPTCHA_DRIVER=null CAPTCHA_FAKE_PASSES=true php artisan serve --port=8001`; + - засеять тестовый ИП в `legal_entities` (is_default) + тестового тенанта с реквизитами; + - пройти путь: клиент формирует счёт → скачивает PDF → админ «Счета» → «Отметить оплаченным» → проверить пополнение баланса + скачать акт + письмо (через `Mail::fake`/лог); + - снять 3-4 скрина, показать владельцу, дождаться «ок». +- [ ] **Step 5: Только после «ок» на демо** — деплой по эскейпу (отдельный заход, `bin/deploy-source-edit.sh`), предусловие: заполнить реальные реквизиты ИП в `legal_entities` на проде. + +--- + +## Правила коммитов и окружения (важно) + +- **Коммиты — только по эскейпу владельца** (CLAUDE.md §главное п.4/п.8). Шаги «Commit» в плане выполнять, запросив у владельца «эскейп». +- **Схему БД не трогаем** — Этап 1 переиспортирует существующие таблицы. Если внезапно понадобится колонка — STOP, спросить (правило §5 п.8: правка `schema.sql` только с CHANGELOG + rls-reviewer). +- **PDF в storage `local`** (приватно), отдаём только владельцу счёта (tenant-scoped) / админу. Не класть в public. +- **Тест-БД** — `liderra_testing` через `php artisan migrate`, НИКОГДА не прод. +- **Larastan baseline** — новые `getJson()` в тестах ломают count-pattern; обновлять `phpstan-baseline.neon`. +- **CLAUDE.md, NewProjectDialog.vue, db/CHANGELOG_schema.md, observer-файлы** — НЕ трогать (параллельная сессия). + +--- + +## Self-Review (выполнен) + +**1. Покрытие спеки:** §2 (переиспользование) — Tasks 3,8; §3.1 dompdf — Task 1; §3.2 сервисы — Tasks 2,6,7,8; §3.3 API — Tasks 9,10; §3.4 экраны — Tasks 11,12,13; §3.5 PDF-шаблоны — Task 5; §5 граничные (422, идемпотентность, overdue, нумерация) — Tasks 6,8,14,2; §6 тесты — в каждой задаче; §7 предусловие данных — отдельный блок + Task 15; §8 демо — Task 15. **Пробел:** Этап 2 — вне scope (намеренно). + +**2. Заглушки:** код приведён в каждом шаге; помечены 3 места «проверить фактические имена колонок по схеме» (`legal_entities` банк-поля, `tenant_requisites` поля, тест-хелперы аутентификации) — это требование сверки с реальностью, НЕ заглушка-плейсхолдер. + +**3. Консистентность типов:** `InvoiceNumberGenerator::next(int,?Carbon):string`, `InvoiceService::create(int,string,?int):SaasInvoice`, `ActService::createForInvoice(SaasInvoice,int):SaasUpdDocument`, `InvoicePaymentService::markPaid(int):void` — имена методов/полей согласованы между задачами и тестами. diff --git a/docs/superpowers/specs/2026-06-29-oplata-po-schetu-design.md b/docs/superpowers/specs/2026-06-29-oplata-po-schetu-design.md new file mode 100644 index 00000000..2e860c75 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-oplata-po-schetu-design.md @@ -0,0 +1,164 @@ +# Оплата по счёту (банковский перевод) — дизайн + +**Дата:** 2026-06-29 +**Статус:** утверждён владельцем (устно: «ок делай»), Этап 1 +**Автор сессии:** Claude (brainstorming) + +--- + +## 0. Контекст и решение по каналу + +Владелец хочет добавить **оплату по счёту** для клиентов-юрлиц/ИП в дополнение к онлайн-оплате картой (ЮKassa, уже работает). + +**Исследование зафиксировало (важно, не переоткрывать):** + +- **ЮKassa для этого сценария не подходит.** Её «оплата по счёту» (B2B) работает только через **Сбербанк Бизнес Онлайн** — обе стороны должны быть в Сбербанке. Расчётный счёт ИП открыт в **ВТБ** → путь закрыт. Обычная платёжка ВТБ↔другой банк идёт мимо ЮKassa. +- **Идея «деньги паркуются на счёте ЮKassa, потом раздаём» — нереализуема** для банковского перевода от юрлица. Деньги всегда падают на наш расчётный счёт (ВТБ). +- **Автомат возможен только со стороны ВТБ** через «Интеграционный Банк-Клиент» (ВТБ API, REST h2h). Но: **только poll, без вебхуков**; частота обновления выписки публично не задокументирована (риск «раз в сутки»); технически сложно (КриптоПро, сертификат УНЭП). → **Это Этап 2**, после подтверждения у банка. + +**Решение владельца:** идём **поэтапно**. + +- **Этап 1 (эта спека):** выставление счёта (самообслуживание клиентом) + закрывающий документ + ручная отметка оплаты администратором → автозачисление баланса. +- **Этап 2 (отдельная спека позже):** заменить ручную отметку на автоматический опрос ВТБ API. Перед началом — список вопросов менеджеру ВТБ (частота выписки, лимит опроса, условия подключения ИБК, СБП для бизнеса). + +--- + +## 1. Цель Этапа 1 + +Клиент-юрлицо самостоятельно формирует счёт на пополнение баланса, оплачивает его банковским переводом по нашим реквизитам ВТБ. Администратор, увидев поступление, одной кнопкой подтверждает оплату — система автоматически зачисляет баланс, формирует закрывающий документ (Акт, без НДС) и уведомляет клиента. + +**Критерии успеха:** + +1. Клиент может ввести/подтянуть реквизиты компании, сумму и скачать PDF-счёт. +2. Счёт виден в «Моих счетах» со статусом. +3. Администратор видит выставленные счета и отмечает оплату одной кнопкой (с переспросом). +4. Отметка оплаты атомарно: зачисляет баланс (тот же ledger, что онлайн), формирует Акт PDF, шлёт письмо, ставит статус «оплачен». Повтор — no-op. +5. Клиент скачивает счёт и акт из кабинета. +6. Все суммы и документы — «Без НДС» (УСН). +7. **Схема БД не меняется** — переиспользуем существующие таблицы. + +**Вне scope Этапа 1 (YAGNI):** автоматический опрос ВТБ; загрузка банковской выписки и авто-матч; УПД со счётом-фактурой (НДС); частичные оплаты; возвраты по счетам. + +--- + +## 2. Что уже есть в коде (переиспользуем, НЕ строим заново) + +| Готово | Где | Как используем | +|---|---|---| +| Таблица счетов `saas_invoices` (номер, плательщик ЮЛ/физлицо, ИНН/КПП/адрес, суммы, НДС, статусы `draft/issued/paid/overdue/cancelled`, `expires_at`, `pdf_path`, `transaction_id`) | `db/schema.sql:2371` | хранение счёта | +| Позиции счёта `saas_invoice_items` | `db/schema.sql:2410` | 1 позиция «Пополнение баланса» | +| Таблица закрывающих документов `saas_upd_documents` (покупатель, суммы, `pdf_path`, `status`, `invoice_id`, `transaction_id`, `upd_function` СЧФ/ДОП) | `db/schema.sql:2428` | хранение Акта (function=ДОП, без счёта-фактуры) | +| Транзакции `saas_transactions` (`type=topup`, `invoice_id`, `upd_id`, `payment_method='bank_transfer'`, `legal_entity_id`, статусы) | `db/schema.sql:2492` | строка пополнения | +| RLS-политики на все 4 таблицы (tenant isolation) | `db/schema.sql:3137+` | изоляция тенантов | +| Зачисление баланса `BillingTopupService::topup()` (lockForUpdate + append-only ledger) | `app/Services/Billing/BillingTopupService.php` | автозачисление при отметке оплаты | +| Идемпотентный атомарный claim pending→success + RLS-контекст (`SET LOCAL app.current_tenant_id`) | `app/Http/Controllers/Api/PaymentWebhookController.php` | образец для отметки оплаты | +| Письмо «Счёт оплачен» `InvoicePaidNotification` (шаблон `emails.invoice_paid`) | `app/Mail/InvoicePaidNotification.php` | уведомление клиента | +| Список счетов клиента `GET /api/billing/invoices` | `app/Http/Controllers/Api/BillingController.php:301` | «Мои счета» (расширить) | +| Реквизиты клиента `tenant_requisites` (1:1, ИНН/КПП/ОГРН/адрес/банк) + `RequisitesService::upsert()` | `app/Services/Requisites/RequisitesService.php` | плательщик в счёте | +| Подтяжка по ИНН (DaData) `PartyLookup` / `DaDataPartyClient` | `app/Services/DaData/` | автозаполнение реквизитов | +| Наши юрлица оператора `legal_entities` (ИНН, КПП NULL для ИП, `bank_account`, `bank_bik`, `is_default`) | `db/schema.sql:280` | получатель (наш ИП, ВТБ) | +| Способ онлайн-пополнения (диалог) | `app/resources/js/components/billing/TopupDialog.vue` | добавить вкладку «По счёту» | + +--- + +## 3. Что строим нового + +### 3.1. Зависимость: генерация PDF + +В проекте **нет PDF-библиотеки**. Добавляем **`barryvdh/laravel-dompdf`** (HTML/Blade → PDF, без бинарников, кириллица через шрифт DejaVu Sans). Альтернативы (mPDF, wkhtmltopdf/snappy) тяжелее или требуют системный бинарь — отвергнуты для простоты на Windows-dev + Linux-prod. + +### 3.2. Сервисы (backend) + +- **`InvoiceService`** — создание счёта: + - нумерация `СЧ-2026-NNNNN` — последовательная по `legal_entity_id` + год, без дыр, через `UNIQUE (legal_entity_id, invoice_number)` + атомарный инкремент (advisory-lock или `SELECT ... FOR UPDATE` по счётчику); + - заполняет `saas_invoices` (payer из `tenant_requisites`, `legal_entity_id` = `is_default` ИП, `amount_net=amount_total`, `vat_rate=0`, `vat_amount=0`, `payment_purpose` с номером счёта, `expires_at` = +5 рабочих дней) + 1 строку `saas_invoice_items`; + - генерирует PDF счёта → `pdf_path`. +- **`ActService`** (закрывающий документ) — при отметке оплаты: + - создаёт `saas_upd_documents` (`upd_function='ДОП'`, без НДС, buyer из счёта, `invoice_id`, `transaction_id`), генерирует PDF Акта → `pdf_path`. +- **`InvoicePaymentService`** — отметка оплаты (по образцу `PaymentWebhookController`): + - в транзакции с `SET LOCAL app.current_tenant_id`: атомарный claim `saas_invoices.status issued→paid`; если 0 строк — no-op (идемпотентность); + - создаёт `saas_transactions(type=topup, status=success, invoice_id, payment_method='bank_transfer', legal_entity_id)`; + - зачисляет через `BillingTopupService::topup()`; пишет `balance_transaction_id`, `upd_id`; + - вызывает `ActService`; шлёт `InvoicePaidNotification`. + +### 3.3. Контроллеры / API + +**Клиент (auth + tenant):** +- `POST /api/billing/invoices` — создать счёт `{ amount_rub }` (реквизиты берутся из `tenant_requisites`; если не заполнены — 422 с подсказкой заполнить). +- `GET /api/billing/invoices` — список (расширить существующий: статус, ссылки на PDF счёта и акта). +- `GET /api/billing/invoices/{id}/pdf` — скачать счёт. +- `GET /api/billing/invoices/{id}/act` — скачать акт (если оплачен). +- Реквизиты компании — переиспользовать существующий endpoint G1/SP2 (`RequisitesService`); ИНН-автоподтяжка — существующий DaData endpoint. + +**Админ (admin-зона):** +- `GET /api/admin/invoices` — список выставленных/всех счетов (фильтр по статусу, поиск по номеру/клиенту), серверная пагинация (как недавний экран «Тенанты»). +- `POST /api/admin/invoices/{id}/mark-paid` — отметить оплаченным (идемпотентно, через `InvoicePaymentService`). + +### 3.4. Экраны (Vue 3 + Vuetify 3, палитра Forest) + +- **Клиент:** в `TopupDialog.vue` — вкладка/способ **«По счёту (для юрлиц)»**: форма реквизитов (ИНН→автоподтяжка) при первом разе, сумма, кнопка «Сформировать счёт» → скачивание PDF + тост. +- **Клиент:** раздел **«Мои счета»** (расширить существующий список в BillingView): номер, сумма, статус (Выставлен / Оплачен / Просрочен), кнопки «Скачать счёт» / «Скачать акт». +- **Админ:** экран **«Счета»**: таблица выставленных счетов, серверная пагинация/поиск, кнопка **«Отметить оплаченным»** с диалогом подтверждения (`v-dialog`, как в manual-queue), показ суммы/клиента/номера в подтверждении. + +### 3.5. PDF-шаблоны (Blade) + +- `resources/views/pdf/invoice.blade.php` — счёт: шапка (наш ИП + ВТБ-реквизиты как получатель), плательщик (реквизиты клиента), таблица позиций, итог, «Без НДС», назначение платежа с номером счёта, срок оплаты. +- `resources/views/pdf/act.blade.php` — Акт об оказании услуг: исполнитель (наш ИП), заказчик (клиент), услуга, сумма, «Без НДС», ссылка на номер счёта. + +--- + +## 4. Поток данных (happy path) + +``` +Клиент: TopupDialog «По счёту» → (реквизиты, если нужно) → сумма → POST /api/billing/invoices + → InvoiceService: saas_invoices(issued) + items + PDF → возврат ссылки на PDF +Клиент скачивает счёт, платит платёжкой (в назначении — номер счёта) + ... деньги идут на наш счёт ВТБ (~2 часа) ... +Админ: экран «Счета» → «Отметить оплаченным» → подтверждение → POST /api/admin/invoices/{id}/mark-paid + → InvoicePaymentService (транзакция + SET LOCAL tenant): + claim issued→paid (атомарно; 0 строк = no-op) + saas_transactions(topup, success, bank_transfer) + BillingTopupService::topup() → баланс += сумма (ledger) + ActService → saas_upd_documents(ДОП) + PDF + InvoicePaidNotification (email) +Клиент: видит «Оплачен», скачивает счёт и акт; баланс пополнен +``` + +## 5. Обработка ошибок и граничные случаи + +- **Реквизиты не заполнены** при создании счёта → 422 с понятным сообщением «Заполните реквизиты компании». +- **Сумма** — min/max как у онлайн-пополнения (валидация на бэке). +- **Нумерация** — атомарная, без дыр и гонок (advisory lock per legal_entity); `UNIQUE` ловит дубль. +- **Просроченный счёт** — `expires_at` прошёл и не оплачен → статус `overdue` (cron-задача раз в день или ленивый пересчёт при чтении). Просроченный нельзя «оплатить» без админ-переопределения (на Этапе 1 — просто предупреждение в подтверждении). +- **Идемпотентность отметки** — повторное `mark-paid` → claim 0 строк → no-op, без двойного зачисления/двойного акта. +- **RLS** — admin-операция отмечает счёт чужого тенанта: чтение через admin-соединение; зачисление строго под `SET LOCAL app.current_tenant_id` нужного тенанта (как webhook). +- **Деньги** — `BillingTopupService::topup()` уже атомарен (lockForUpdate); не дублируем. + +## 6. Тестирование (TDD) + +- **Unit:** `InvoiceService` (нумерация без дыр, поля счёта, vat=0); `ActService` (ДОП без НДС); генерация PDF не падает (smoke). +- **Feature (backend):** создание счёта клиентом (с/без реквизитов → 422); список; скачивание PDF; `mark-paid` happy-path (баланс += сумма, статус paid, акт создан, письмо отправлено — `Mail::fake`); идемпотентность повторного `mark-paid`; RLS-изоляция (чужой тенант не видит счёт). +- **Frontend (vitest):** вкладка «По счёту» в TopupDialog; «Мои счета» рендерит статусы и кнопки; админ-экран «Счета» зовёт `mark-paid` после подтверждения в диалоге. +- Прод-условие RLS воспроизводить осторожно (тест-БД под суперюзером скрывает RLS-баги — см. [[feedback-prod-full-test-isolated-db]]). + +## 7. Предусловия к запуску (данные, не код) + +- Заполнить в `legal_entities` строку нашего ИП: ИНН, банк (ВТБ), `bank_account`, `bank_bik`, адрес, `is_default=true`. Без этого шапка счёта пустая. Разовая настройка — собрать у владельца. + +## 8. Демо перед выкатом (требование владельца) + +Перед любым выкатом на боевой — **локальное демо**: владелец сам формирует счёт, скачивает PDF, отмечает оплату, видит пополнение баланса и акт. Только после «ок» на демо — деплой. (Боевая БД/деплой — отдельно, по эскейпу.) + +## 9. Этап 2 (отдельно, не сейчас) + +Автоматический опрос ВТБ API («Интеграционный Банк-Клиент»), матч поступления по сумме + назначению (номер счёта) → тот же `InvoicePaymentService`, только триггер не кнопка, а планировщик. Предшествует — вопросы менеджеру ВТБ: +1. Как часто формируется/обновляется выписка, доступная по API? Есть ли SLA? +2. Допустимая частота опроса API (раз в минуту — ок)? +3. Есть ли push/вебхук о входящем платеже (или только опрос)? +4. Условия и стоимость подключения «Интеграционный Банк-Клиент» для ИП; что с сертификатом (КриптоПро/УНЭП)? +5. СБП для бизнеса (B2B): мгновенное зачисление + есть ли API-уведомление? +6. Точные REST-методы получения выписки (из `Specifications_VTB_API`). + +Полезные ссылки ВТБ: +- `https://db.vtb.ru/faq/files/vtb-api-kak-rabotaet-servis/Specifications_VTB_API_18.11.24.pdf` +- `https://db.vtb.ru/faq/files/vtb-api-kak-rabotaet-servis/Instruction_VTB_API_21.04.24.pdf` From 915dfbc8fc249f48aae2f22bdb42484577d32a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 29 Jun 2026 11:46:38 +0300 Subject: [PATCH 042/417] =?UTF-8?q?docs(=D0=9F=D0=98=D0=9B=D0=9E=D0=A2):?= =?UTF-8?q?=20=D1=81=D0=BD=D0=B8=D0=BC=D0=BE=D0=BA=2029.06=20=E2=80=94=20?= =?UTF-8?q?=D1=84=D0=B8=D1=87=D0=B0=20=C2=AB=D0=BE=D0=BF=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=20=D1=81=D1=87=D1=91=D1=82=D1=83=C2=BB=20?= =?UTF-8?q?(=D0=AD=D1=82=D0=B0=D0=BF=201)=20=D0=B2=D1=8B=D0=BA=D0=B0=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B4=20?= =?UTF-8?q?+=20=D1=80=D0=B5=D0=BA=D0=B2=D0=B8=D0=B7=D0=B8=D1=82=D1=8B=20?= =?UTF-8?q?=D0=98=D0=9F=20=D0=B2=20legal=5Fentities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/observer/STATUS.md | 19 ++++++------------- ПИЛОТ.md | 2 ++ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 6a1d5a38..2c4fab96 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-06-28T09:58:25.674Z +Last updated: 2026-06-29T08:26:07.630Z | Контролёр | Состояние | Детали | |---|---|---| @@ -33,20 +33,13 @@ Last updated: 2026-06-28T09:58:25.674Z | enforce-coverage-verify.mjs | `enforce-coverage-verify.mjs` | 🔴 | | enforce-todowrite-skill-verifier.mjs | `enforce-todowrite-skill-verifier.mjs` | 🔴 | -Недавние escape владельца: 0 · Недавние блоки: 2 - -**Недавние блоки (детали):** - -| Время | Действие | Причина | -|---|---|---| -| 2026-06-27T11:50:42.480Z | bash:cd "c:/моя/проекты/claude-brain" && git add -- "docs/superpowers/specs/2026-06-27-secretary-closing-doors-design.md | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:cd "c:/моя/проекты/cla | -| 2026-06-27T10:01:08.010Z | bash:git restore --staged docs/observer/STATUS.md 2>/dev/null; git diff --staged --name-only | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:git restore --staged d | +Недавние escape владельца: 0 · Недавние блоки: 0 ## Метрики (информационные, не алерты) - Observer evidence: 2354 episodes this month, 0 observer_error markers, 8 PII matches before filter - Legacy v1 episodes (not in factor analysis): 2354 -- Last /brain-retro: 32 day(s) ago +- Last /brain-retro: 33 day(s) ago - Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). ## Метрики дисциплины @@ -124,9 +117,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3440 | MsMpEng | 18.10ч | 0.0ч | -| 21928 | Code | 8.23ч | 14476668.5ч | -| 1212 | svchost | 4.58ч | 0.0ч | +| 3440 | MsMpEng | 20.00ч | NaNч | +| 21928 | Code | 10.83ч | NaNч | +| 1212 | svchost | 5.68ч | NaNч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. diff --git a/ПИЛОТ.md b/ПИЛОТ.md index d4039c5d..aea18d17 100644 --- a/ПИЛОТ.md +++ b/ПИЛОТ.md @@ -10,6 +10,8 @@ > 🟢 **АКТУАЛЬНО (26.06.2026) — перебивает любые упоминания «ждут ООО / Б-1 / осталось подписать» в снимках ниже:** юр. лицо — **ИП, зарегистрировано** (НЕ ООО). **Договор с ЮKassa ПОДПИСАН 26.06** (№НЭК.448000.01), магазин **1392092** активен, приём платежей **включён со стороны ЮKassa**, СБП подключён, `info@liderra.ru` подтверждён. На проде заведены юрлицо+шлюз+webhook (см. снимок go-live ниже). **Флаг `billing_yookassa_enabled` — ВКЛЮЧЁН (подтверждено 27.06 на кластере, намеренно, штатное состояние по решению владельца).** НО **go-live онлайн-оплаты НЕ завершён:** успешной живой оплаты ещё не было (см. свежий снимок 27.06 ниже — 5 тестовых попыток 100₽ отменены на стороне ЮKassa, `paid=false`, деньги не списаны; happy-path «оплата→webhook→зачисление» в бою не проверялся; webhook IP-allowlist пуст). «Регистрация ООО» как блокер — **снято, не актуально.** +**Снимок снят:** 29.06.2026 (~11:00 UTC) — **💳🟢 ВЫКАЧЕНА фича «ОПЛАТА ПО СЧЁТУ» (Этап 1) на боевой liderra.ru.** По командам владельца «ок коммить и пуш, обнови память и деплой только со скилом». **Что выкачено (gitea main `cdfae077`, деплой `bin/deploy-source-edit.sh`):** клиент в кабинете Биллинг → отдельная вкладка **«Счета»** + в «Пополнить» способ «По счёту (для юрлиц)» → формирует **PDF-счёт** по своим реквизитам; админ **`/admin/invoices`** (новый пункт меню «Счета») отмечает оплату одной кнопкой → атомарно зачисляет баланс + формирует **Акт** (без НДС, УСН) + шлёт клиенту письмо «Счёт оплачен» **с вложением PDF-акта**; PDF открываются inline. Просрочка `invoices:expire` (cron 03:40 МСК). Наименование услуги — «**Оплата генерации рекламных лидов**». Зависимость **dompdf** установлена на проде, **«Nothing to migrate»** (схему БД не меняли — таблицы saas_invoices/items/upd уже были). **Реквизиты ИП заполнены в боевом `legal_entities` id=1** (`ip_kondratev`, is_default): банк **Филиал «Центральный» ВТБ (ПАО) Красноярск**, р/с **40802810690810008032**, корр 30101810145250000411, БИК 044525411, ОКПО 2052921109, адрес Красноярск ул.Новосибирская 5-97 (было bank=null). **Предполёт 8/8 GREEN** (prod-deploy-validator), финиш через superpowers:finishing-a-development-branch. **Проверка:** приложение live, HTTPS /login 200, новых ошибок в логе нет, **деньги целы t2 = 1 839 210 ₽ / 1019 сделок**, счетов на проде 0 (чисто, клиенты ещё не выставляли). **ЮKassa для оплаты по счёту НЕ годится** (её B2B только через Сбербанк, обе стороны в Сбере; наш ИП в ВТБ) — деньги по счёту всегда идут прямой платёжкой на р/с ВТБ. **Этап 2** (автозачисление через ВТБ API «Интеграционный Банк-Клиент», только poll, без вебхуков) — отдельно, после вопросов менеджеру ВТБ. Спека/план — `docs/superpowers/specs|plans/2026-06-29-oplata-po-schetu-*`. Память: `project-oplata-po-schetu-etap1`, `reference-ip-requisites-kondratev`. + **Снимок снят:** 28.06.2026 (~13:30 UTC) — **🔍🟢 СКВОЗНАЯ СВЕРКА БАЙТ-В-БАЙТ прод↔git↔бэкап + экран «Тенанты» на 1000 + фронт-стенд в зелёный. Прод приведён к git 1:1.** По командам владельца «сверь прод/локалку/бэкап байт-в-байт» и «да» (выкатить). **Сверка (метод: на Linux `rsync -rclni` checksum, LF без CRLF-шума, свежий клон gitea-main ↔ `/var/www/liderra/app` ↔ распакованный `/tmp/app-backup-0907.tgz`):** исходники прода = **gitea-main `84dfbc85` один-в-один** (контент-расхождений 0 после выката тест-правок); бэкап цел, разница прод↔бэкап = ровно выкат «Тенанты»; **потерянных/изменённых правок НЕТ.** На проде сверх git только не-git артефакты: `*.bak-precutover` ×2 (страховка отката Managed PG — **держать до ~03.07**), `bootstrap/cache/*` ×5 (кэш Laravel, генерится), `tests/Frontend/menuRepositionFix.spec.ts` (старый удалённый тест, висит — деплой без `--delete`; мусор, в рантайме не участвует). **Выкачено в этот заход:** (1) экран «Тенанты» на серверную пагинацию/поиск/фильтры (статус производный CASE + тариф) — демо показано владельцу локально, прод `c92d498b`; (2) фронт-тест-стенд приведён в зелёный — 22 протухших теста в 10 спеках обновлены под актуальные компоненты (только тесты, компоненты не тронуты), `84dfbc85`, выкачены чтобы прод==git. **Полный прогон фронта: 127 файлов / 992 теста зелёные.** **Деньги целы: t2 = 1 839 405 ₽ / 990 сделок** (деплои код-только, БД не тронута). Сайт 200. Рецепт сверки на будущее: rsync-checksum на проде / `git hash-object`, НЕ raw-sha с Windows (CRLF врёт). Кодовая фраза стены — «роутер-наставник». **Снимок снят:** 28.06.2026 (~11:20 UTC) — **🔴→🟢 НАЙДЕН И ПОЧИНЕН ТИХО СЛОМАННЫЙ БИЛЛИНГ-СТОРОЖ + playwright durable + дашборд балансов/вложенности на проде.** По команде владельца «закрывай хвосты». **Главное — баг биллинга:** после переезда на Managed PG (26.06) очередь `liderra-queue` ходит в БД под ролью `crm_app_user` (RLS), и `BalancePreflightSweepJob`/`BalanceFrozenReminderJob` перебирали тенантов через `Tenant::query()` БЕЗ `app.current_tenant_id` → policy `tenants_self_isolation` отдавала **0 строк** → ночной преflight @18:00 МСК **молча стал no-op с 26.06** (heartbeat зелёный, `balance_freeze_log` пуст, ни заморозок бедных, ни снятия блоков у профинансированных). Симптом — 4 застрявших `preflight_blocked` проекта. **Фикс:** список тенантов теперь через `pgsql_supplier` (BYPASSRLS), модель грузится внутри per-tenant `SET LOCAL`. Тесты Billing 18/18, pint/phpstan чисто. **gitea/main + прод = `75dded78`** (cherry-pick поверх `cab0347f`; выкат `bin/deploy-source-edit.sh`, БД миграциями не тронута). **Прогнал sweep вручную на проде — самоисцеление подтверждено:** t25 (10090₽)→сняты блоки 2 проектов (omega/lkomega), t26 (300₽)→снят блок 1, t27 (тест example.org)→заморожен, t30 (`kaa_555@bk.ru`, 20₽ реальный)→заморожен + письмо «пополните»; `balance_freeze_log` снова пишет. **Деньги целы: t2 = 1 839 405 ₽** (до==после). **Playwright «несносимый»:** объявлен в `app/package.json → dependencies` ровно `1.59.0` (под browser chromium-1217) + lock; деплой получил шаг 6d `npm ci --omit=dev --legacy-peer-deps` (только прод-деп, без тяжёлого фронт-дерева, без скачивания браузера) → переживёт будущие чистки `node_modules` (корень падения синхрона 27.06). Поставлен на прод (3 пакета). **Дашборд (выкачено ранее 28.06):** плитка «Балансы сервисов» + «Пополнить», плитка «Клиенты», экран «Лиды» (серверная пагинация) + карточка лида (цепочка до источника), выбор периода. **🟡 Следующим заходом (решение владельца):** экран «Тенанты» на серверную пагинацию/фильтры для 1000 клиентов. **🔴 YC в минусе −606₽:** пополнить `console.yandex.cloud/billing/accounts/dn2w7fcvynjxe6elljct/payments`. Кодовая фраза стены — «роутер-наставник». From 0487be86d0bd54d8480639fa9a67c7fbbee7f91e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 29 Jun 2026 13:22:09 +0300 Subject: [PATCH 043/417] =?UTF-8?q?feat(ui):=20=D1=82=D0=B8=D0=BF=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=BC=D0=B8?= =?UTF-8?q?=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D0=BC=D0=B8,=20=D0=B7=D0=B5?= =?UTF-8?q?=D0=BB=D1=91=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=BD=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=B4=D0=B5=D0=BB=D0=B8,=20=D1=81=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D0=B8=D0=B5=20=D0=B1=D0=B0=D0=BD=D0=BA-=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D0=B2=D0=B8=D0=B7=D0=B8=D1=82=D0=BE=D0=B2=20=D1=83=20?= =?UTF-8?q?=D1=84=D0=B8=D0=B7=D0=BB=D0=B8=D1=86=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Настройки→Реквизиты и диалог проекта: «Физлицо»→«Физическое лицо», «Юрлицо»→«Юридическое лицо» (ключи value не тронуты) - У физлица скрыт блок «Реквизиты для оплаты» (банковских реквизитов нет; оплата по счёту — только для юр/ИП) - Диалог проекта: выбранные дни недели залиты зелёным #0f6e56 как в ProjectDetailsDrawer Co-Authored-By: Claude Opus 4.8 --- .../js/views/projects/NewProjectDialog.vue | 20 ++++++++++++++++--- .../js/views/settings/RequisitesTab.vue | 10 ++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/app/resources/js/views/projects/NewProjectDialog.vue b/app/resources/js/views/projects/NewProjectDialog.vue index 6b134fff..b158d2fb 100644 --- a/app/resources/js/views/projects/NewProjectDialog.vue +++ b/app/resources/js/views/projects/NewProjectDialog.vue @@ -276,7 +276,13 @@
Дни недели приёма - + {{ day }}
@@ -436,9 +442,9 @@ const reqSaving = ref(false); const reqGeneralError = ref(null); const subjectTypeItems = [ - { value: 'individual', title: 'Физлицо' }, + { value: 'individual', title: 'Физическое лицо' }, { value: 'sole_proprietor', title: 'ИП' }, - { value: 'legal_entity', title: 'Юрлицо' }, + { value: 'legal_entity', title: 'Юридическое лицо' }, ]; // Зеркало RequisitesService::isLightComplete — тип лица + имя + телефон (+ ИНН для юр/ИП). @@ -767,4 +773,12 @@ defineExpose({ border-color: currentColor; opacity: 1; } +/* Выбранные дни недели — сплошная зелёная заливка, как в ProjectDetailsDrawer (.pdd-day.active) */ +.day-toggle :deep(.v-btn.day-active) { + background-color: #0f6e56; + color: #fff; +} +.day-toggle :deep(.v-btn.day-active .v-btn__overlay) { + opacity: 0; +} diff --git a/app/resources/js/views/settings/RequisitesTab.vue b/app/resources/js/views/settings/RequisitesTab.vue index 44a33737..7de3d8d2 100644 --- a/app/resources/js/views/settings/RequisitesTab.vue +++ b/app/resources/js/views/settings/RequisitesTab.vue @@ -37,9 +37,9 @@ const lookupMessage = ref(''); const lookupError = ref(false); const subjectTypes = [ - { value: 'individual', label: 'Физлицо' }, + { value: 'individual', label: 'Физическое лицо' }, { value: 'sole_proprietor', label: 'ИП' }, - { value: 'legal_entity', label: 'Юрлицо' }, + { value: 'legal_entity', label: 'Юридическое лицо' }, ]; const requiresInn = computed( @@ -49,8 +49,10 @@ const requiresInn = computed( const isLegalEntity = computed(() => form.subject_type === 'legal_entity'); const isSoleProprietor = computed(() => form.subject_type === 'sole_proprietor'); -// Блок платёжных реквизитов виден, как только выбран тип лица. -const showPayment = computed(() => form.subject_type !== null); +// Блок платёжных реквизитов виден для ИП и юрлица; у физлица банковских реквизитов нет. +const showPayment = computed( + () => form.subject_type !== null && form.subject_type !== 'individual', +); // КПП — только юрлицо; ОГРН/ОГРНИП и юр.адрес — юрлицо и ИП; банк — всегда (когда showPayment). const showKpp = computed(() => isLegalEntity.value); const showOgrn = computed(() => isLegalEntity.value || isSoleProprietor.value); From ab1c048ded858040ce1f324af978b5cfd88eece3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 04:18:46 +0300 Subject: [PATCH 044/417] =?UTF-8?q?feat(=D0=9A=D0=BE=D0=BD=D0=BA=D1=83?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D1=82=D0=BD=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5):=20=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B5=D0=B5=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=83=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D1=8B=E2=86=92=D0=B8=D1=81=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=B8=E2=86=92=D0=BF=D1=80=D0=BE=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D1=8B=20(=D0=BF=D0=BE=D0=B2=D0=B5=D1=80=D1=85=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фича «Конкурентное поле» на dev до уровня прототипа 2026-06-29-konkurentnoe-pole-proto.html. Данные: box (proposal|field) на competitors+sources; phone_type city/mobile/tollfree рядом с phone_kind (вариант C). 3 миграции, дефолты тарифов 300/50. API (AutopodborController): GET /field (+счётчики), GET /proposals, PATCH/DELETE competitors и sources с гвардами активного проекта, переключение box, POST /competitors/manual (+directory_urls), competitor(id) обогащён box+project-статусом; projectStatus отдаёт limit/delivered/days/regions. Смена источника проекта = PATCH /api/projects/{id} (реальный гвард слепка §14.10). Фронт: FieldWorkspaceScreen/FieldCompetitorScreen/FieldProposalsScreen/FieldManualCompetitorScreen + field-shared.css (Forest) + AutopodborServicesPanel в Биллинге. Дословно по прототипу: подзаголовки, баннер предложений, баннер правил времени 18:00 МСК, Справочник 2ГИС·Яндекс, статус проекта 5/день·заявки, окна сбора с ценами 300/50 + «что известно», полные формы. Пункт меню «Конкурентное поле». Тесты: backend автоподбор 80/80, фронт автоподбор 49/49. Движок шага 2 = заглушка FakeCompetitorAgent. OmegaDemoFieldSeeder — только для визуальной проверки (НЕ на прод). Co-Authored-By: Claude Opus 4.8 --- .../Controllers/Api/AutopodborController.php | 331 ++++++++- .../Autopodbor/CompetitorResource.php | 21 +- .../Resources/Autopodbor/SourceResource.php | 14 +- .../Jobs/Autopodbor/RunAutopodborStudyJob.php | 27 +- app/app/Models/AutopodborCompetitor.php | 11 +- app/app/Models/AutopodborSource.php | 2 + .../Agent/Dto/StudyCompetitorResult.php | 2 +- .../Autopodbor/Agent/FakeCompetitorAgent.php | 17 +- ...026_06_29_120000_add_box_to_autopodbor.php | 33 + ...0_add_phone_type_to_autopodbor_sources.php | 24 + ...9_130000_set_autopodbor_default_prices.php | 40 + app/database/seeders/OmegaDemoFieldSeeder.php | 170 +++++ app/resources/js/api/autopodbor.ts | 145 +++- .../billing/AutopodborServicesPanel.vue | 64 ++ .../js/components/layout/AppSidebar.vue | 2 +- app/resources/js/router/index.ts | 2 +- app/resources/js/stores/autopodborStore.ts | 132 +++- app/resources/js/views/BillingView.vue | 3 + .../js/views/autopodbor/AutopodborView.vue | 14 +- .../screens/FieldCompetitorScreen.vue | 654 +++++++++++++++++ .../screens/FieldManualCompetitorScreen.vue | 211 ++++++ .../screens/FieldProposalsScreen.vue | 358 +++++++++ .../screens/FieldWorkspaceScreen.vue | 449 ++++++++++++ .../views/autopodbor/screens/field-shared.css | 693 ++++++++++++++++++ app/routes/web.php | 9 + .../Autopodbor/AutopodborBoxApiTest.php | 81 ++ .../Autopodbor/AutopodborBoxStateTest.php | 65 ++ .../AutopodborCompetitorCrudApiTest.php | 95 +++ .../AutopodborCompetitorSourcesApiTest.php | 49 ++ .../Autopodbor/AutopodborFieldApiTest.php | 120 +++ .../AutopodborManualCompetitorApiTest.php | 55 ++ .../Autopodbor/AutopodborPhoneTypeTest.php | 77 ++ .../AutopodborPriceDefaultsTest.php | 13 + .../Autopodbor/AutopodborProposalsApiTest.php | 46 ++ .../AutopodborSourceCrudApiTest.php | 123 ++++ .../Autopodbor/RunAutopodborStudyJobTest.php | 19 +- .../AutopodborFieldCompetitorScreen.spec.ts | 182 +++++ ...opodborFieldManualCompetitorScreen.spec.ts | 51 ++ .../AutopodborFieldProposalsScreen.spec.ts | 79 ++ .../AutopodborFieldWorkspaceScreen.spec.ts | 135 ++++ .../Frontend/AutopodborFlowSmoke.spec.ts | 5 +- app/tests/Frontend/AutopodborView.spec.ts | 16 +- app/tests/Frontend/autopodborStore.spec.ts | 84 +++ 43 files changed, 4621 insertions(+), 102 deletions(-) create mode 100644 app/database/migrations/2026_06_29_120000_add_box_to_autopodbor.php create mode 100644 app/database/migrations/2026_06_29_120100_add_phone_type_to_autopodbor_sources.php create mode 100644 app/database/migrations/2026_06_29_130000_set_autopodbor_default_prices.php create mode 100644 app/database/seeders/OmegaDemoFieldSeeder.php create mode 100644 app/resources/js/components/billing/AutopodborServicesPanel.vue create mode 100644 app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue create mode 100644 app/resources/js/views/autopodbor/screens/FieldManualCompetitorScreen.vue create mode 100644 app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue create mode 100644 app/resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue create mode 100644 app/resources/js/views/autopodbor/screens/field-shared.css create mode 100644 app/tests/Feature/Autopodbor/AutopodborBoxApiTest.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborBoxStateTest.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborCompetitorCrudApiTest.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborCompetitorSourcesApiTest.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborFieldApiTest.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborManualCompetitorApiTest.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborPhoneTypeTest.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborPriceDefaultsTest.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborProposalsApiTest.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborSourceCrudApiTest.php create mode 100644 app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts create mode 100644 app/tests/Frontend/AutopodborFieldManualCompetitorScreen.spec.ts create mode 100644 app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts create mode 100644 app/tests/Frontend/AutopodborFieldWorkspaceScreen.spec.ts diff --git a/app/app/Http/Controllers/Api/AutopodborController.php b/app/app/Http/Controllers/Api/AutopodborController.php index 062013de..b4e788c7 100644 --- a/app/app/Http/Controllers/Api/AutopodborController.php +++ b/app/app/Http/Controllers/Api/AutopodborController.php @@ -14,6 +14,7 @@ use App\Models\AutopodborCompetitor; use App\Models\AutopodborRun; use App\Models\AutopodborSource; use App\Models\Project; +use App\Models\Tenant; use App\Repositories\PricingTierRepository; use App\Services\Autopodbor\AutopodborDedup; use App\Services\Autopodbor\AutopodborNormalizer; @@ -44,10 +45,10 @@ class AutopodborController extends Controller return response()->json([ 'enabled' => SystemSettings::bool('autopodbor_enabled'), - 'runs' => RunResource::collection($runs), - 'prices' => [ + 'runs' => RunResource::collection($runs), + 'prices' => [ 'search' => (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0'), - 'study' => (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0'), + 'study' => (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0'), ], ]); } @@ -67,21 +68,24 @@ class AutopodborController extends Controller $tenantId = $request->user()->tenant_id; $comp = AutopodborCompetitor::where('tenant_id', $tenantId) - ->with('sources') + ->with('sources.project') ->findOrFail($competitor); - $sources = $comp->sources->map(function ($s) use ($dedup) { + $sources = $comp->sources->map(function (AutopodborSource $s) use ($dedup) { $existingProjectId = $s->created_project_id ?? $dedup->existingProjectId($s->tenant_id, $s->signal_type, $s->identifier); return array_merge( (new SourceResource($s))->resolve(), - ['existing_project_id' => $existingProjectId] + [ + 'existing_project_id' => $existingProjectId, + 'project' => $this->projectStatus($s->project), + ] ); }); return response()->json([ - 'data' => new CompetitorResource($comp), + 'data' => new CompetitorResource($comp), 'sources' => $sources, ]); } @@ -106,9 +110,9 @@ class AutopodborController extends Controller public function search(Request $request, AutopodborRunService $svc): JsonResponse { $v = $request->validate([ - 'region_code' => 'required|integer', - 'examples' => 'array', - 'about_self' => 'array', + 'region_code' => 'required|integer', + 'examples' => 'array', + 'about_self' => 'array', 'include_federal' => 'boolean', ]); @@ -154,7 +158,7 @@ class AutopodborController extends Controller public function resolve(Request $request, AutopodborRunService $svc): JsonResponse { $v = $request->validate([ - 'name' => 'required|string', + 'name' => 'required|string', 'region_code' => 'required|integer', ]); @@ -176,10 +180,10 @@ class AutopodborController extends Controller { $v = $request->validate([ 'competitor_id' => ['nullable', 'integer'], - 'name' => ['nullable', 'string', 'max:255'], - 'site_url' => ['nullable', 'string', 'max:500'], - 'directory' => ['nullable', 'string', 'max:500'], - 'region_code' => ['required', 'integer'], + 'name' => ['nullable', 'string', 'max:255'], + 'site_url' => ['nullable', 'string', 'max:500'], + 'directory' => ['nullable', 'string', 'max:500'], + 'region_code' => ['required', 'integer'], ]); $uid = $request->user()->tenant_id; @@ -193,8 +197,8 @@ class AutopodborController extends Controller return response()->json(['error' => 'name_or_site_required'], 422); } $run = $svc->startManualStudy($uid, [ - 'name' => $name, - 'site_url' => $site, + 'name' => $name, + 'site_url' => $site, 'directory_urls' => ! empty($v['directory']) ? [$v['directory']] : [], ], (int) $v['region_code']); } @@ -207,12 +211,236 @@ class AutopodborController extends Controller return response()->json(['data' => new RunResource($run)], 201); } + /** + * GET /api/autopodbor/field — рабочее место «Конкурентное поле». + * Конкуренты в ящике «поле» с их источниками в поле, статусом проекта по каждому + * источнику и счётчиками (источников / создано проектов / в работе). + */ + public function field(Request $request): JsonResponse + { + $tenantId = $request->user()->tenant_id; + + $competitors = AutopodborCompetitor::where('tenant_id', $tenantId) + ->where('box', 'field') + ->with(['sources' => function ($q) { + $q->where('box', 'field')->with('project'); + }]) + ->orderByDesc('relevance_pct') + ->orderBy('id') + ->get(); + + $payload = $competitors->map(function (AutopodborCompetitor $comp) { + $sources = $comp->sources->map(fn (AutopodborSource $s) => array_merge( + (new SourceResource($s))->resolve(), + ['project' => $this->projectStatus($s->project)], + )); + + $created = $comp->sources->filter(fn ($s) => $s->project !== null); + $inWork = $created->filter( + fn ($s) => $s->project->is_active && $s->project->preflight_blocked_at === null + ); + + return array_merge( + (new CompetitorResource($comp))->resolve(), + [ + 'counters' => [ + 'sources' => $comp->sources->count(), + 'projects_created' => $created->count(), + 'projects_in_work' => $inWork->count(), + ], + 'sources' => $sources, + ], + ); + }); + + return response()->json(['competitors' => $payload]); + } + + /** + * POST /api/autopodbor/competitors/manual — завести конкурента вручную сразу В ПОЛЕ, + * без запуска изучения (§14.2 «+ Добавить вручную»). Изучение источников — отдельно, по кнопке. + */ + public function manualCompetitor(Request $request, AutopodborNormalizer $norm): JsonResponse + { + $v = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:2000'], + 'is_federal' => ['boolean'], + 'relevance_pct' => ['nullable', 'integer', 'min:0', 'max:100'], + 'site_url' => ['nullable', 'string', 'max:500'], + 'directory' => ['nullable', 'string', 'max:500'], + 'directory_urls' => ['nullable', 'array'], + 'directory_urls.*' => ['string', 'max:500'], + ]); + $uid = $request->user()->tenant_id; + + $site = ! empty($v['site_url']) ? $norm->domainHead($v['site_url']) : null; + + $dirs = $v['directory_urls'] ?? (! empty($v['directory']) ? [$v['directory']] : []); + $dirs = array_values(array_filter(array_map('trim', $dirs))); + + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $uid, + 'search_run_id' => null, + 'name' => $v['name'], + 'description' => $v['description'] ?? null, + 'is_federal' => (bool) ($v['is_federal'] ?? false), + 'relevance_pct' => $v['relevance_pct'] ?? null, + 'origin' => 'manual', + 'box' => 'field', + 'site_url' => $site, + 'directory_urls' => $dirs, + 'dedup_key' => $norm->competitorKey($v['name'], $site), + ]); + + return response()->json(['data' => new CompetitorResource($comp)], 201); + } + + /** PATCH /api/autopodbor/competitors/{id} — правка полей карточки конкурента */ + public function updateCompetitor(Request $request, int $competitor): JsonResponse + { + $v = $request->validate([ + 'name' => ['sometimes', 'string', 'max:255'], + 'description' => ['sometimes', 'nullable', 'string', 'max:2000'], + 'is_federal' => ['sometimes', 'boolean'], + 'relevance_pct' => ['sometimes', 'nullable', 'integer', 'min:0', 'max:100'], + 'site_url' => ['sometimes', 'nullable', 'string', 'max:500'], + 'directory_urls' => ['sometimes', 'array'], + 'directory_urls.*' => ['string', 'max:500'], + 'box' => ['sometimes', 'string', 'in:proposal,field'], + ]); + + $comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id) + ->findOrFail($competitor); + + $comp->update($v); + + return response()->json(['data' => new CompetitorResource($comp)]); + } + + /** + * DELETE /api/autopodbor/competitors/{id} — удаление конкурента и его источников. + * Блокируется, если у любого источника есть активный созданный проект + * (управлять проектом нужно через раздел проектов — §14.10). + */ + public function destroyCompetitor(Request $request, int $competitor): JsonResponse + { + $comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id) + ->with('sources.project') + ->findOrFail($competitor); + + $hasActive = $comp->sources->contains( + fn (AutopodborSource $s) => $s->project && $s->project->is_active + ); + + if ($hasActive) { + return response()->json(['error' => 'has_active_projects'], 409); + } + + $comp->sources()->delete(); + $comp->delete(); + + return response()->json(null, 204); + } + + /** GET /api/autopodbor/proposals — конкуренты в ящике «предложения», сорт по похожести. */ + public function proposals(Request $request): JsonResponse + { + $competitors = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id) + ->where('box', 'proposal') + ->orderByDesc('relevance_pct') + ->orderBy('id') + ->get(); + + return response()->json(['data' => CompetitorResource::collection($competitors)]); + } + + /** PATCH /api/autopodbor/competitors/{id}/box — перенос конкурента предложение↔поле */ + public function competitorBox(Request $request, int $competitor): JsonResponse + { + $v = $request->validate([ + 'box' => ['required', 'string', 'in:proposal,field'], + ]); + + $comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id) + ->findOrFail($competitor); + + $comp->update(['box' => $v['box']]); + + return response()->json(['data' => new CompetitorResource($comp)]); + } + + /** + * PATCH /api/autopodbor/sources/{id} — правка значения/провенанса/ящика источника. + * Тип источника (signal_type) НЕИЗМЕНЯЕМ (как в ProjectService — молча игнорируем). + * Смена самого значения (identifier) у источника с активным проектом запрещена — + * это смена источника проекта, делается через раздел проектов (§14.10). + */ + public function updateSource(Request $request, int $source): JsonResponse + { + $v = $request->validate([ + 'identifier' => ['sometimes', 'string', 'max:500'], + 'phone_kind' => ['sometimes', 'nullable', 'string', 'in:real,substitute'], + 'phone_type' => ['sometimes', 'nullable', 'string', 'in:city,mobile,tollfree'], + 'provenance_url' => ['sometimes', 'nullable', 'string', 'max:500'], + 'provenance_label' => ['sometimes', 'nullable', 'string', 'max:255'], + 'box' => ['sometimes', 'string', 'in:proposal,field'], + ]); + + $src = AutopodborSource::where('tenant_id', $request->user()->tenant_id) + ->with('project') + ->findOrFail($source); + + $changesIdentifier = array_key_exists('identifier', $v) && $v['identifier'] !== $src->identifier; + if ($changesIdentifier && $src->project && $src->project->is_active) { + return response()->json(['error' => 'manage_via_project'], 409); + } + + $src->update($v); + + return response()->json(['data' => new SourceResource($src)]); + } + + /** + * DELETE /api/autopodbor/sources/{id} — удаление источника. + * Блокируется, если у источника есть активный созданный проект (§14.10). + */ + public function destroySource(Request $request, int $source): JsonResponse + { + $src = AutopodborSource::where('tenant_id', $request->user()->tenant_id) + ->with('project') + ->findOrFail($source); + + if ($src->project && $src->project->is_active) { + return response()->json(['error' => 'has_active_project'], 409); + } + + $src->delete(); + + return response()->json(null, 204); + } + + /** PATCH /api/autopodbor/sources/{id}/box — перенос источника предложение↔в работу */ + public function sourceBox(Request $request, int $source): JsonResponse + { + $v = $request->validate([ + 'box' => ['required', 'string', 'in:proposal,field'], + ]); + + $src = AutopodborSource::where('tenant_id', $request->user()->tenant_id) + ->findOrFail($source); + + $src->update(['box' => $v['box']]); + + return response()->json(['data' => new SourceResource($src)]); + } + /** POST /api/autopodbor/sources/manual */ public function addManualSource(Request $request, AutopodborNormalizer $norm): JsonResponse { $v = $request->validate([ 'competitor_id' => ['required', 'integer'], - 'raw' => ['required', 'string', 'max:500'], + 'raw' => ['required', 'string', 'max:500'], ]); $uid = $request->user()->tenant_id; @@ -221,7 +449,7 @@ class AutopodborController extends Controller return response()->json(['error' => 'not_studied'], 422); } - $raw = trim($v['raw']); + $raw = trim($v['raw']); $digits = preg_replace('/\D+/', '', $raw) ?? ''; $isCall = strlen($digits) >= 10; $signalType = $isCall ? 'call' : 'site'; @@ -230,12 +458,12 @@ class AutopodborController extends Controller $source = AutopodborSource::updateOrCreate( ['competitor_id' => $comp->id, 'dedup_key' => $norm->sourceKey($signalType, $raw)], [ - 'tenant_id' => $uid, - 'study_run_id' => $comp->study_run_id, - 'signal_type' => $signalType, - 'identifier' => $identifier, - 'phone_kind' => $isCall ? 'real' : null, - 'provenance_url' => null, + 'tenant_id' => $uid, + 'study_run_id' => $comp->study_run_id, + 'signal_type' => $signalType, + 'identifier' => $identifier, + 'phone_kind' => $isCall ? 'real' : null, + 'provenance_url' => null, 'provenance_label' => 'Добавлено вручную', ], ); @@ -243,21 +471,46 @@ class AutopodborController extends Controller return response()->json(['data' => new SourceResource($source)], 201); } + /** + * Статус проекта источника для UI (пауза/работа/блок). null — проекта нет. + * + * @return array{id: int, name: string, is_active: bool, paused_at: ?string, preflight_blocked_at: ?string}|null + */ + private function projectStatus(?Project $project): ?array + { + if ($project === null) { + return null; + } + + return [ + 'id' => $project->id, + 'name' => $project->name, + 'signal_identifier' => $project->signal_identifier, + 'is_active' => (bool) $project->is_active, + 'paused_at' => $project->paused_at?->toIso8601String(), + 'preflight_blocked_at' => $project->preflight_blocked_at?->toIso8601String(), + 'daily_limit_target' => (int) $project->daily_limit_target, + 'delivered_in_month' => (int) $project->delivered_in_month, + 'delivery_days_mask' => (int) $project->delivery_days_mask, + 'regions' => $project->regions ?? [], + ]; + } + /** POST /api/autopodbor/projects */ public function createProjects(Request $request, AutopodborProjectCreator $creator): JsonResponse { $v = $request->validate([ - 'source_ids' => 'required|array', - 'source_ids.*' => 'integer', - 'regions' => 'array', - 'regions.*' => 'integer', + 'source_ids' => 'required|array', + 'source_ids.*' => 'integer', + 'regions' => 'array', + 'regions.*' => 'integer', 'daily_limit_target' => 'required|integer', 'delivery_days_mask' => 'required|integer', - 'launch' => 'boolean', + 'launch' => 'boolean', ]); - $tenant = $request->user()->tenant; - $launch = (bool) ($v['launch'] ?? false); + $tenant = $request->user()->tenant; + $launch = (bool) ($v['launch'] ?? false); // Балансовый preflight при launch=true if ($launch) { @@ -272,11 +525,11 @@ class AutopodborController extends Controller if (! $preflight['passes']) { return response()->json([ - 'error' => 'balance_insufficient', - 'current_balance_rub' => (string) $tenant->balance_rub, + 'error' => 'balance_insufficient', + 'current_balance_rub' => (string) $tenant->balance_rub, 'current_capacity_leads' => $preflight['capacity_leads'], 'would_be_required_leads' => $wouldBe, - 'deficit_leads' => $preflight['deficit_leads'], + 'deficit_leads' => $preflight['deficit_leads'], ], 409); } } @@ -285,7 +538,7 @@ class AutopodborController extends Controller $tenant->id, $v['source_ids'], [ - 'regions' => $v['regions'] ?? [], + 'regions' => $v['regions'] ?? [], 'daily_limit_target' => (int) $v['daily_limit_target'], 'delivery_days_mask' => (int) $v['delivery_days_mask'], ], @@ -302,7 +555,7 @@ class AutopodborController extends Controller * * @return array{passes: bool, capacity_leads: int, deficit_leads: int} */ - private function runPreflight(\App\Models\Tenant $tenant, int $requiredLeads): array + private function runPreflight(Tenant $tenant, int $requiredLeads): array { $tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow')); @@ -320,9 +573,9 @@ class AutopodborController extends Controller ); return [ - 'passes' => $result->passes, + 'passes' => $result->passes, 'capacity_leads' => $result->capacityLeads, - 'deficit_leads' => $result->deficitLeads, + 'deficit_leads' => $result->deficitLeads, ]; } } diff --git a/app/app/Http/Resources/Autopodbor/CompetitorResource.php b/app/app/Http/Resources/Autopodbor/CompetitorResource.php index 50214f05..7a137def 100644 --- a/app/app/Http/Resources/Autopodbor/CompetitorResource.php +++ b/app/app/Http/Resources/Autopodbor/CompetitorResource.php @@ -12,17 +12,18 @@ class CompetitorResource extends JsonResource public function toArray(Request $request): array { return [ - 'id' => $this->id, - 'name' => $this->name, - 'description' => $this->description, - 'is_federal' => $this->is_federal, - 'relevance_pct' => $this->relevance_pct, - 'origin' => $this->origin, - 'site_url' => $this->site_url, + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'is_federal' => $this->is_federal, + 'relevance_pct' => $this->relevance_pct, + 'origin' => $this->origin, + 'box' => $this->box, + 'site_url' => $this->site_url, 'directory_urls' => $this->directory_urls, - 'studied_at' => $this->studied_at?->toIso8601String(), - 'study_run_id' => $this->study_run_id, - 'search_run_id' => $this->search_run_id, + 'studied_at' => $this->studied_at?->toIso8601String(), + 'study_run_id' => $this->study_run_id, + 'search_run_id' => $this->search_run_id, ]; } } diff --git a/app/app/Http/Resources/Autopodbor/SourceResource.php b/app/app/Http/Resources/Autopodbor/SourceResource.php index 387a531b..4288e45c 100644 --- a/app/app/Http/Resources/Autopodbor/SourceResource.php +++ b/app/app/Http/Resources/Autopodbor/SourceResource.php @@ -12,12 +12,14 @@ class SourceResource extends JsonResource public function toArray(Request $request): array { return [ - 'id' => $this->id, - 'competitor_id' => $this->competitor_id, - 'signal_type' => $this->signal_type, - 'identifier' => $this->identifier, - 'phone_kind' => $this->phone_kind, - 'provenance_url' => $this->provenance_url, + 'id' => $this->id, + 'competitor_id' => $this->competitor_id, + 'signal_type' => $this->signal_type, + 'identifier' => $this->identifier, + 'phone_kind' => $this->phone_kind, + 'phone_type' => $this->phone_type, + 'box' => $this->box, + 'provenance_url' => $this->provenance_url, 'provenance_label' => $this->provenance_label, 'created_project_id' => $this->created_project_id, ]; diff --git a/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php b/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php index cc9f0ace..465eab95 100644 --- a/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php +++ b/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php @@ -4,13 +4,13 @@ declare(strict_types=1); namespace App\Jobs\Autopodbor; -use App\Models\AutopodborRun; use App\Models\AutopodborCompetitor; +use App\Models\AutopodborRun; use App\Models\AutopodborSource; use App\Services\Autopodbor\Agent\CompetitorAgent; use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest; -use App\Services\Autopodbor\AutopodborDedup; use App\Services\Autopodbor\AutopodborChargeService; +use App\Services\Autopodbor\AutopodborDedup; use App\Services\Autopodbor\AutopodborNormalizer; use App\Support\SystemSettings; use Illuminate\Bus\Queueable; @@ -53,8 +53,8 @@ class RunAutopodborStudyJob implements ShouldQueue $res = $agent->studyCompetitor(new StudyCompetitorRequest( competitor: [ - 'name' => $comp->name, - 'site_url' => $comp->site_url, + 'name' => $comp->name, + 'site_url' => $comp->site_url, 'directory_urls' => $comp->directory_urls ?? [], ], regionCode: (int) $run->region_code, @@ -76,15 +76,16 @@ class RunAutopodborStudyJob implements ShouldQueue AutopodborSource::updateOrCreate( [ 'competitor_id' => $comp->id, - 'dedup_key' => $s['dedup_key'], + 'dedup_key' => $s['dedup_key'], ], [ - 'tenant_id' => $run->tenant_id, - 'study_run_id' => $run->id, - 'signal_type' => $s['signal_type'], - 'identifier' => $identifier, - 'phone_kind' => $s['phone_kind'] ?? null, - 'provenance_url' => $s['provenance_url'] ?? null, + 'tenant_id' => $run->tenant_id, + 'study_run_id' => $run->id, + 'signal_type' => $s['signal_type'], + 'identifier' => $identifier, + 'phone_kind' => $s['phone_kind'] ?? null, + 'phone_type' => $s['phone_type'] ?? null, + 'provenance_url' => $s['provenance_url'] ?? null, 'provenance_label' => $s['provenance_label'] ?? null, ] ); @@ -98,8 +99,8 @@ class RunAutopodborStudyJob implements ShouldQueue $run->update(['status' => 'done', 'finished_at' => now()]); } catch (\Throwable $e) { $run->update([ - 'status' => 'failed', - 'error_code' => substr($e->getMessage(), 0, 64), + 'status' => 'failed', + 'error_code' => substr($e->getMessage(), 0, 64), 'finished_at' => now(), ]); throw $e; diff --git a/app/app/Models/AutopodborCompetitor.php b/app/app/Models/AutopodborCompetitor.php index bc0488c0..70599b7e 100644 --- a/app/app/Models/AutopodborCompetitor.php +++ b/app/app/Models/AutopodborCompetitor.php @@ -26,14 +26,15 @@ class AutopodborCompetitor extends Model 'dedup_key', 'study_run_id', 'studied_at', + 'box', ]; protected $casts = [ - 'is_federal' => 'bool', - 'directory_urls' => 'array', - 'provenance' => 'array', - 'studied_at' => 'datetime', - 'created_at' => 'datetime', + 'is_federal' => 'bool', + 'directory_urls' => 'array', + 'provenance' => 'array', + 'studied_at' => 'datetime', + 'created_at' => 'datetime', ]; public function sources(): HasMany diff --git a/app/app/Models/AutopodborSource.php b/app/app/Models/AutopodborSource.php index 2aedfddb..d1cd235f 100644 --- a/app/app/Models/AutopodborSource.php +++ b/app/app/Models/AutopodborSource.php @@ -18,10 +18,12 @@ class AutopodborSource extends Model 'signal_type', 'identifier', 'phone_kind', + 'phone_type', 'provenance_url', 'provenance_label', 'dedup_key', 'created_project_id', + 'box', ]; protected $casts = [ diff --git a/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorResult.php b/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorResult.php index ab69c13f..bdd54730 100644 --- a/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorResult.php +++ b/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorResult.php @@ -5,7 +5,7 @@ namespace App\Services\Autopodbor\Agent\Dto; final class StudyCompetitorResult { /** - * @param array $sources + * @param array $sources */ public function __construct(public readonly array $sources) {} } diff --git a/app/app/Services/Autopodbor/Agent/FakeCompetitorAgent.php b/app/app/Services/Autopodbor/Agent/FakeCompetitorAgent.php index f0992745..96f57ee0 100644 --- a/app/app/Services/Autopodbor/Agent/FakeCompetitorAgent.php +++ b/app/app/Services/Autopodbor/Agent/FakeCompetitorAgent.php @@ -2,7 +2,12 @@ namespace App\Services\Autopodbor\Agent; -use App\Services\Autopodbor\Agent\Dto\{FindCompetitorsRequest, FindCompetitorsResult, StudyCompetitorRequest, StudyCompetitorResult, ResolveByNameRequest, ResolveByNameResult}; +use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest; +use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult; +use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest; +use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult; +use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest; +use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult; final class FakeCompetitorAgent implements CompetitorAgent { @@ -19,11 +24,11 @@ final class FakeCompetitorAgent implements CompetitorAgent public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult { return new StudyCompetitorResult([ - ['signal_type' => 'site', 'identifier' => 'okna-komfort-kzn.ru', 'phone_kind' => null, 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'], - ['signal_type' => 'site', 'identifier' => 'okna-komfort.pro', 'phone_kind' => null, 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — сайт в контактах'], - ['signal_type' => 'call', 'identifier' => '78432001122', 'phone_kind' => 'real', 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'], - ['signal_type' => 'call', 'identifier' => '78432009988', 'phone_kind' => 'substitute', 'provenance_url' => 'https://okna-komfort-kzn.ru', 'provenance_label' => 'номер в шапке (коллтрекинг)'], - ['signal_type' => 'call', 'identifier' => '79172001122', 'phone_kind' => 'real', 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — карточка компании'], + ['signal_type' => 'site', 'identifier' => 'okna-komfort-kzn.ru', 'phone_kind' => null, 'phone_type' => null, 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'], + ['signal_type' => 'site', 'identifier' => 'okna-komfort.pro', 'phone_kind' => null, 'phone_type' => null, 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — сайт в контактах'], + ['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' => 'Яндекс.Карты — карточка компании'], ]); } diff --git a/app/database/migrations/2026_06_29_120000_add_box_to_autopodbor.php b/app/database/migrations/2026_06_29_120000_add_box_to_autopodbor.php new file mode 100644 index 00000000..d21882bf --- /dev/null +++ b/app/database/migrations/2026_06_29_120000_add_box_to_autopodbor.php @@ -0,0 +1,33 @@ +where('key', 'autopodbor_price_search_rub')->where('value', '0') + ->update(['value' => '300', 'updated_at' => now()]); + + DB::table('system_settings') + ->where('key', 'autopodbor_price_study_rub')->where('value', '0') + ->update(['value' => '50', 'updated_at' => now()]); + } + + public function down(): void + { + DB::table('system_settings') + ->where('key', 'autopodbor_price_search_rub')->where('value', '300') + ->update(['value' => '0', 'updated_at' => now()]); + + DB::table('system_settings') + ->where('key', 'autopodbor_price_study_rub')->where('value', '50') + ->update(['value' => '0', 'updated_at' => now()]); + } +}; diff --git a/app/database/seeders/OmegaDemoFieldSeeder.php b/app/database/seeders/OmegaDemoFieldSeeder.php new file mode 100644 index 00000000..4b3306cf --- /dev/null +++ b/app/database/seeders/OmegaDemoFieldSeeder.php @@ -0,0 +1,170 @@ + 'omega-demo']); + $tenant->organization_name = 'Омега (демо поля)'; + $tenant->contact_email = 'omega-demo@liderra.local'; + $tenant->status = 'active'; + $tenant->balance_rub = '50000.00'; + $tenant->delivered_in_month = 0; + $tenant->save(); + + $user = User::firstOrNew(['email' => 'omega-demo@liderra.local']); + $user->tenant_id = $tenant->id; + $user->first_name = 'Омега'; + $user->last_name = 'Демо'; + $user->password_hash = Hash::make('omega12345'); + $user->email_verified_at = now(); + $user->is_active = true; + $user->save(); + + // 2) Включить фичу + тарифы доп.услуг + SystemSetting::updateOrCreate(['key' => 'autopodbor_enabled'], ['value' => '1', 'type' => 'bool']); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '300', 'type' => 'decimal']); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '50', 'type' => 'decimal']); + + DB::statement('SET app.current_tenant_id = '.$tenant->id); + + // чистим прошлый демо-прогон (идемпотентность) + $oldRuns = AutopodborRun::where('tenant_id', $tenant->id)->pluck('id'); + AutopodborSource::where('tenant_id', $tenant->id)->delete(); + AutopodborCompetitor::where('tenant_id', $tenant->id)->delete(); + AutopodborRun::whereIn('id', $oldRuns)->delete(); + + $run = AutopodborRun::create([ + 'tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', + 'region_code' => 24, 'params' => ['region' => 'Красноярский край'], + ]); + + // 3) Реальные конкуренты Омеги (прогон 28-29.06). box=field — клиент отобрал в поле. + $gis = 'https://2gis.ru/krasnoyarsk/firm/0'; + $ya = 'https://yandex.ru/maps/org/0'; + $field = [ + ['КрасЛомбард', 'kraslombard24.ru', false, 95, 'Сеть ломбардов, займы под залог авто и техники', [$gis, $ya], [ + ['site', 'kraslombard24.ru', null, null], + ['call', '73912771717', 'real', 'city'], + ]], + ['Голд Авто Инвест', 'goldautoinvest.ru', false, 90, 'Займы под залог автомобилей, Красноярск', [$gis, $ya], [ + ['site', 'goldautoinvest.ru', null, null], + ['call', '73912000111', 'substitute', 'city'], + ['call', '79130000222', 'real', 'mobile'], + ]], + ['Финео', 'fineo24.ru', true, 80, 'Федеральный сервис займов под ПТС', [$gis], [ + ['site', 'fineo24.ru', null, null], + ['call', '78005000333', 'real', 'tollfree'], + ]], + ['Cashmotor', 'cashmotor.ru', true, 78, 'Федеральный автоломбард, залог авто', [], [ + ['site', 'cashmotor.ru', null, null], + ]], + ['Локо-Банк', 'lockobank.ru', true, 72, 'Автокредиты и займы под залог авто', [$ya], [ + ['site', 'lockobank.ru', null, null], + ]], + ]; + + // box=proposal — найдено движком, ещё не отобрано в поле + $proposals = [ + ['Автоломбард Экспресс', 'avtolombard-express.ru', false, 85, 'Срочные займы под залог авто, Красноярск'], + ['Caranga', 'caranga.ru', true, 70, 'Федеральный автоломбanд'], + ['Драйвзайм', 'drivezaim.ru', true, 65, 'Займы под залог ПТС онлайн'], + ['Залог24', 'zalog24h.ru', true, 60, 'Круглосуточные займы под залог'], + ['Кредди', 'creddy.ru', true, 55, 'Микрозаймы под залог авто'], + ]; + + foreach ($field as $i => [$name, $site, $federal, $rel, $desc, $dirs, $srcs]) { + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'study_run_id' => $run->id, + 'studied_at' => now(), 'name' => $name, 'description' => $desc, 'is_federal' => $federal, + 'relevance_pct' => $rel, 'origin' => 'auto', 'box' => 'field', 'site_url' => $site, + 'directory_urls' => $dirs, 'dedup_key' => 'site:'.$site, + ]); + foreach ($srcs as [$type, $ident, $kind, $ptype]) { + AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, + 'signal_type' => $type, 'identifier' => $ident, 'phone_kind' => $kind, 'phone_type' => $ptype, + 'box' => 'field', 'provenance_label' => $type === 'site' ? 'сайт компании' : 'карточка в 2ГИС', + 'dedup_key' => $type.':'.$ident, + ]); + } + } + + foreach ($proposals as [$name, $site, $federal, $rel, $desc]) { + AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'search_run_id' => $run->id, + 'name' => $name, 'description' => $desc, 'is_federal' => $federal, + 'relevance_pct' => $rel, 'origin' => 'auto', 'box' => 'proposal', 'site_url' => $site, + 'dedup_key' => 'site:'.$site, + ]); + } + + // 4) Демо-проекты: один «в работе», один «на паузе» — чтобы показать счётчики, + // паузу/возобновление и смену источника на живых данных. Минимальная строка + // (только обязательные поля), без джобов поставщика. + $linkProject = function (string $compName, bool $active) use ($tenant) { + $comp = AutopodborCompetitor::where('tenant_id', $tenant->id)->where('name', $compName)->first(); + if (! $comp) { + return; + } + $src = AutopodborSource::where('competitor_id', $comp->id)->where('signal_type', 'site')->first(); + if (! $src) { + return; + } + $p = Project::firstOrNew(['tenant_id' => $tenant->id, 'name' => $compName]); + $p->signal_identifier = $src->identifier; + $p->signal_type = 'site'; + $p->signal_identifier = $src->identifier; + $p->is_active = $active; + $p->paused_at = $active ? null : now(); + $p->daily_limit_target = 20; + $p->delivery_days_mask = 127; + $p->save(); + $src->update(['created_project_id' => $p->id]); + }; + $linkProject('КрасЛомбард', true); // в работе + $linkProject('Голд Авто Инвест', false); // на паузе + + // источники-предложения у КрасЛомбарда (результат «собрать источники» — до переноса в работу) + $krl = AutopodborCompetitor::where('tenant_id', $tenant->id)->where('name', 'КрасЛомбард')->first(); + if ($krl) { + foreach ([ + ['site', 'kraslombard-new.ru', null, null, '2ГИС — сайт в карточке компании'], + ['call', '73912001100', 'real', 'city', '2ГИС — карточка компании'], + ] as [$type, $ident, $kind, $ptype, $prov]) { + AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $krl->id, 'study_run_id' => $run->id, + 'signal_type' => $type, 'identifier' => $ident, 'phone_kind' => $kind, 'phone_type' => $ptype, + 'box' => 'proposal', 'provenance_label' => $prov, 'dedup_key' => $type.':'.$ident.':sug', + ]); + } + } + + $this->command?->info('Омега-демо готова: '.count($field).' в поле (2 с проектами), '.count($proposals).' в предложениях. Логин omega-demo@liderra.local / omega12345'); + } +} diff --git a/app/resources/js/api/autopodbor.ts b/app/resources/js/api/autopodbor.ts index 0c34fdc3..9e7df709 100644 --- a/app/resources/js/api/autopodbor.ts +++ b/app/resources/js/api/autopodbor.ts @@ -21,6 +21,9 @@ export interface RunDto { competitor_id: number | null; } +export type Box = 'proposal' | 'field'; +export type PhoneType = 'city' | 'mobile' | 'tollfree' | null; + export interface CompetitorDto { id: number; name: string; @@ -28,6 +31,7 @@ export interface CompetitorDto { is_federal: boolean; relevance_pct: number | null; origin: 'auto' | 'manual' | 'resolve'; + box: Box; site_url: string | null; directory_urls: string[]; studied_at: string | null; @@ -41,12 +45,44 @@ export interface SourceDto { 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; } +/** Статус проекта, привязанного к источнику (для рабочего места «поле»). */ +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[]; @@ -65,8 +101,12 @@ export async function fetchRun(id: number): Promise { return data.data; } -export async function fetchCompetitor(id: number): Promise<{ competitor: CompetitorDto; sources: SourceDto[] }> { - const { data } = await apiClient.get<{ data: CompetitorDto; sources: SourceDto[] }>(`/api/autopodbor/competitors/${id}`); +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 }; } @@ -121,3 +161,104 @@ export async function fetchRunCompetitors(runId: number): Promise(`/api/autopodbor/runs/${runId}/competitors`); return data.data; } + +// ——— «Конкурентное поле»: рабочее место (два ящика) ——— + +/** Конкуренты в поле с источниками в работе и счётчиками. */ +export async function fetchField(): Promise { + const { data } = await apiClient.get<{ competitors: FieldCompetitorDto[] }>('/api/autopodbor/field'); + return data.competitors; +} + +/** Конкуренты в ящике «предложения» (сорт по похожести). */ +export async function fetchProposals(): Promise { + const { data } = await apiClient.get<{ data: CompetitorDto[] }>('/api/autopodbor/proposals'); + return data.data; +} + +export async function setCompetitorBox(id: number, box: Box): Promise { + 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 { + 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 { + const { data } = await apiClient.patch<{ data: CompetitorDto }>(`/api/autopodbor/competitors/${id}`, patch); + return data.data; +} + +export async function deleteCompetitor(id: number): Promise { + 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 { + const { data } = await apiClient.patch<{ data: SourceDto }>(`/api/autopodbor/sources/${id}`, patch); + return data.data; +} + +export async function deleteSource(id: number): Promise { + 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 { + 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 { + await apiClient.patch(`/api/projects/${projectId}/toggle-active`, { is_active: active }); +} + +/** + * Сменить источник проекта (адрес/номер) через ГОТОВУЮ ручку проектов — это и есть + * change_source со всеми гвардами §14.10 (тип источника не меняется). Возвращает + * сообщение о сроках вступления в силу. + */ +export async function changeProjectSource(projectId: number, signalIdentifier: string): Promise { + const { data } = await apiClient.patch(`/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 { + const { data } = await apiClient.patch(`/api/projects/${projectId}`, p); + return data ?? {}; +} diff --git a/app/resources/js/components/billing/AutopodborServicesPanel.vue b/app/resources/js/components/billing/AutopodborServicesPanel.vue new file mode 100644 index 00000000..dc79c788 --- /dev/null +++ b/app/resources/js/components/billing/AutopodborServicesPanel.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/app/resources/js/components/layout/AppSidebar.vue b/app/resources/js/components/layout/AppSidebar.vue index 6493f4be..6c1bb44f 100644 --- a/app/resources/js/components/layout/AppSidebar.vue +++ b/app/resources/js/components/layout/AppSidebar.vue @@ -46,7 +46,7 @@ const navGroups = computed(() => [ eyebrow: 'Работа', items: [ { title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' }, - ...(autopodbor.enabled ? [{ title: 'Автоподбор конкурентов', icon: 'mdi-radar', to: '/autopodbor', badge: 'NEW' }] : []), + ...(autopodbor.enabled ? [{ title: 'Конкурентное поле', icon: 'mdi-radar', to: '/autopodbor', badge: 'NEW' }] : []), // B2: count из dealsCount-store; null → undefined (NavItem.count — number|undefined), // resolveCount затем → 0 и v-if скрывает бейдж пока счётчик не загружен. { diff --git a/app/resources/js/router/index.ts b/app/resources/js/router/index.ts index c88e45ea..dfad8bdc 100644 --- a/app/resources/js/router/index.ts +++ b/app/resources/js/router/index.ts @@ -144,7 +144,7 @@ const routes: RouteRecordRaw[] = [ path: '/autopodbor', name: 'autopodbor', component: () => import('../views/autopodbor/AutopodborView.vue'), - meta: { layout: 'app', title: 'Автоподбор конкурентов', requiresAuth: true, transition: 'ld-route-fadeup', devLabel: 'Автоподбор' }, + meta: { layout: 'app', title: 'Конкурентное поле', requiresAuth: true, transition: 'ld-route-fadeup', devLabel: 'Конкурентное поле' }, }, { path: '/billing', diff --git a/app/resources/js/stores/autopodborStore.ts b/app/resources/js/stores/autopodborStore.ts index dbfeca2e..31ddbb7c 100644 --- a/app/resources/js/stores/autopodborStore.ts +++ b/app/resources/js/stores/autopodborStore.ts @@ -11,9 +11,27 @@ import { addManualSource, createProjects, fetchRunCompetitors, + fetchField, + fetchProposals, + setCompetitorBox, + setSourceBox, + updateCompetitor, + deleteCompetitor, + updateSource, + deleteSource, + createManualCompetitor, + toggleProjectActive as apiToggleProjectActive, + changeProjectSource as apiChangeProjectSource, + updateProjectSettings as apiUpdateProjectSettings, type RunDto, + type ChangeSourceResult, type CompetitorDto, type SourceDto, + type Box, + type CompetitorPatch, + type SourcePatch, + type FieldCompetitorDto, + type FieldSourceDto, } from '../api/autopodbor'; /** Задержка между тиками опроса (вынесена для тестируемости). */ @@ -27,8 +45,10 @@ export const useAutopodborStore = defineStore('autopodbor', () => { const runs = ref([]); const currentRun = ref(null); const competitor = ref(null); - const sources = ref([]); + const sources = ref([]); const runCompetitors = ref([]); + const field = ref([]); + const proposals = ref([]); const loading = ref(false); // Internal poll handle — not exposed as reactive state. @@ -93,7 +113,7 @@ export const useAutopodborStore = defineStore('autopodbor', () => { async function addSource(p: { competitor_id: number; raw: string }): Promise { const source = await addManualSource(p); - sources.value.push(source); + sources.value.push({ ...source, project: null }); return source; } @@ -154,6 +174,99 @@ export const useAutopodborStore = defineStore('autopodbor', () => { runCompetitors.value = await fetchRunCompetitors(runId); } + // ——— «Конкурентное поле»: рабочее место (два ящика) ——— + + async function loadField(): Promise { + field.value = (await fetchField()) ?? []; + } + + async function loadProposals(): Promise { + proposals.value = (await fetchProposals()) ?? []; + } + + /** Перенос конкурента предложение↔поле; уход из поля убирает карточку из списка. */ + async function moveCompetitorToBox(id: number, box: Box): Promise { + await setCompetitorBox(id, box); + if (box !== 'field') { + field.value = field.value.filter((c) => c.id !== id); + } + } + + async function editCompetitor(id: number, patch: CompetitorPatch): Promise { + const updated = await updateCompetitor(id, patch); + const idx = field.value.findIndex((c) => c.id === id); + if (idx !== -1) { + field.value[idx] = { ...field.value[idx], ...updated }; + } + } + + async function removeCompetitor(id: number): Promise { + await deleteCompetitor(id); + field.value = field.value.filter((c) => c.id !== id); + } + + async function addFieldCompetitor(p: { + name: string; + description?: string; + site_url?: string; + directory?: string; + is_federal?: boolean; + }): Promise { + const created = await createManualCompetitor(p); + field.value.push({ + ...created, + counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, + sources: [], + }); + return created; + } + + /** Перенос источника предложение↔в работу внутри карточки конкурента. */ + async function moveSourceToBox(competitorId: number, sourceId: number, box: Box): Promise { + await setSourceBox(sourceId, box); + const comp = field.value.find((c) => c.id === competitorId); + if (comp && box !== 'field') { + comp.sources = comp.sources.filter((s) => s.id !== sourceId); + } + } + + async function editSource(competitorId: number, sourceId: number, patch: SourcePatch): Promise { + const updated = await updateSource(sourceId, patch); + const comp = field.value.find((c) => c.id === competitorId); + if (comp) { + const idx = comp.sources.findIndex((s) => s.id === sourceId); + if (idx !== -1) { + comp.sources[idx] = { ...comp.sources[idx], ...updated }; + } + } + } + + async function removeSource(competitorId: number, sourceId: number): Promise { + await deleteSource(sourceId); + const comp = field.value.find((c) => c.id === competitorId); + if (comp) { + comp.sources = comp.sources.filter((s) => s.id !== sourceId); + } + } + + /** Управление проектом источника через готовую ручку проектов (все гварды там). */ + async function toggleProjectActive(projectId: number, active: boolean): Promise { + await apiToggleProjectActive(projectId, active); + } + + /** Смена источника проекта (change_source, §14.10) — через готовую ручку проектов. */ + async function changeProjectSource(projectId: number, identifier: string): Promise { + return await apiChangeProjectSource(projectId, identifier); + } + + /** Настройки проекта (лимит/регионы/дни) — через готовую ручку проектов. */ + async function updateProjectSettings( + projectId: number, + p: { daily_limit_target?: number; regions?: number[]; delivery_days_mask?: number }, + ): Promise { + return await apiUpdateProjectSettings(projectId, p); + } + return { // State enabled, @@ -163,6 +276,8 @@ export const useAutopodborStore = defineStore('autopodbor', () => { competitor, sources, runCompetitors, + field, + proposals, loading, // Actions loadState, @@ -176,5 +291,18 @@ export const useAutopodborStore = defineStore('autopodbor', () => { pollRun, stopPolling, loadRunCompetitors, + // «Конкурентное поле» + loadField, + loadProposals, + moveCompetitorToBox, + editCompetitor, + removeCompetitor, + addFieldCompetitor, + moveSourceToBox, + editSource, + removeSource, + toggleProjectActive, + changeProjectSource, + updateProjectSettings, }; }); diff --git a/app/resources/js/views/BillingView.vue b/app/resources/js/views/BillingView.vue index 0ff6b4fe..e7bd323c 100644 --- a/app/resources/js/views/BillingView.vue +++ b/app/resources/js/views/BillingView.vue @@ -12,6 +12,7 @@ import { ref, computed, onMounted } from 'vue'; import BalanceCard from '../components/billing/BalanceCard.vue'; import TierPricesPanel from '../components/billing/TierPricesPanel.vue'; +import AutopodborServicesPanel from '../components/billing/AutopodborServicesPanel.vue'; import TransactionsTable from '../components/billing/TransactionsTable.vue'; import InvoicesTable from '../components/billing/InvoicesTable.vue'; import TopupDialog from '../components/billing/TopupDialog.vue'; @@ -131,6 +132,8 @@ defineExpose({ loadWallet, wallet, topupOpen }); + + diff --git a/app/resources/js/views/autopodbor/AutopodborView.vue b/app/resources/js/views/autopodbor/AutopodborView.vue index 0141df79..554f97db 100644 --- a/app/resources/js/views/autopodbor/AutopodborView.vue +++ b/app/resources/js/views/autopodbor/AutopodborView.vue @@ -1,6 +1,10 @@  + + + + diff --git a/app/resources/js/views/autopodbor/screens/FieldManualCompetitorScreen.vue b/app/resources/js/views/autopodbor/screens/FieldManualCompetitorScreen.vue new file mode 100644 index 00000000..b4e9bb8d --- /dev/null +++ b/app/resources/js/views/autopodbor/screens/FieldManualCompetitorScreen.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue b/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue new file mode 100644 index 00000000..4bd2e54a --- /dev/null +++ b/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue @@ -0,0 +1,358 @@ + + + + + diff --git a/app/resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue b/app/resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue new file mode 100644 index 00000000..7c2abbe0 --- /dev/null +++ b/app/resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue @@ -0,0 +1,449 @@ + + + + + diff --git a/app/resources/js/views/autopodbor/screens/field-shared.css b/app/resources/js/views/autopodbor/screens/field-shared.css new file mode 100644 index 00000000..554a96e6 --- /dev/null +++ b/app/resources/js/views/autopodbor/screens/field-shared.css @@ -0,0 +1,693 @@ +/* Общий стиль рабочего места «Конкурентное поле» (Forest). Классы ld-* — без коллизий. */ + +.ld-field__title, +.ld-h1 { + font-size: 24px; + font-weight: 700; + margin: 0 0 5px; + color: #012019; +} +.ld-field__sub, +.ld-sub { + color: #7a8a82; + font-size: 14px; + margin: 0 0 18px; +} +.ld-field__sub b, +.ld-sub b { + color: #1a2420; +} +.ld-back { + color: #7a8a82; + font-size: 13px; + cursor: pointer; + margin: 0 0 10px; + display: inline-block; + background: none; + border: none; + padding: 0; +} +.ld-back:hover { + color: #0f6e56; +} + +.ld-field__acts, +.ld-acts { + display: flex; + gap: 11px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.ld-btn { + border: none; + border-radius: 11px; + padding: 11px 18px; + font: 600 14px Inter, system-ui, Arial; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; + white-space: nowrap; +} +.ld-btn:active { + transform: translateY(1px); +} +.ld-btn:disabled { + opacity: 0.5; + cursor: default; +} +.ld-btn.primary { + background: #0f6e56; + color: #fff; +} +.ld-btn.ghost { + background: #fff; + color: #0f6e56; + border: 1.5px solid #0f6e56; +} +.ld-btn.gray { + background: #eef0f2; + color: #55606b; +} +.ld-btn.warn { + background: #fff; + color: #a05a1a; + border: 1.5px solid #e6c98a; +} +.ld-btn.danger { + background: #b3422e; + color: #fff; +} +.ld-btn.sm { + padding: 7px 13px; + font-size: 13px; + border-radius: 9px; +} + +.ld-banner { + background: #eef7f3; + border: 1px solid #cfe6dc; + border-radius: 13px; + padding: 13px 18px; + margin-bottom: 20px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; +} +.ld-banner__txt { + font-size: 13.8px; +} +.ld-banner__txt b { + color: #0f6e56; +} +.ld-banner__link { + color: #0f6e56; + font-weight: 700; + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} +.ld-banner--amber { + background: #fffaf0; + border-color: #e6c98a; +} +.ld-banner--amber .ld-banner__txt b { + color: #8a6a12; +} + +.ld-tabs { + display: flex; + gap: 6px; + border-bottom: 1px solid #e6e1d6; + margin-bottom: 18px; +} +.ld-tab { + padding: 10px 16px; + font-size: 14px; + color: #7a8a82; + cursor: pointer; + border: none; + background: none; + border-bottom: 2px solid transparent; + font-weight: 500; +} +.ld-tab--on { + color: #0f6e56; + border-bottom-color: #0f6e56; + font-weight: 700; +} +.ld-tab__c { + font-size: 12px; + background: #eef0f2; + color: #55606b; + border-radius: 10px; + padding: 0 7px; + margin-left: 6px; +} +.ld-tab--on .ld-tab__c { + background: #e7f0ec; + color: #0f6e56; +} + +.ld-selrow { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 12px; + padding: 8px 14px; + background: #fff; + border: 1px solid #e6e1d6; + border-radius: 11px; +} +.ld-selall { + display: flex; + align-items: center; + gap: 8px; + font-size: 13.5px; + font-weight: 600; + color: #1a2420; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; +} +.ld-selall input { + flex-shrink: 0; + width: 17px; + height: 17px; + accent-color: #0f6e56; + margin: 0; +} +.ld-selcnt { + font-size: 13px; + color: #1a2420; +} +.ld-selcnt--mut { + color: #7a8a82; +} + +.ld-empty { + background: #fff; + border: 1px dashed #e0d9c8; + border-radius: 14px; + padding: 40px 24px; + text-align: center; +} +.ld-empty__t { + font-size: 16px; + font-weight: 700; + color: #012019; + margin: 0 0 6px; +} +.ld-empty__s { + font-size: 13.5px; + color: #7a8a82; + margin: 0; +} + +.ld-card { + background: #fff; + border: 1px solid #e6e1d6; + border-radius: 14px; + padding: 16px 18px; + display: flex; + flex-direction: column; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); +} +.ld-card--picked { + border-color: #0f6e56; + box-shadow: + 0 0 0 1.5px #0f6e56, + 0 1px 2px rgba(0, 0, 0, 0.03); +} +.ld-card--sug { + border-color: #e6c98a; + background: #fffaf0; +} +.ld-card__top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} +.ld-card__nm { + font-weight: 700; + font-size: 16px; + display: flex; + align-items: center; + gap: 7px; +} +.ld-pick { + width: 17px; + height: 17px; + accent-color: #0f6e56; + flex-shrink: 0; +} +.ld-bdgs { + display: flex; + gap: 6px; + margin-top: 6px; + flex-wrap: wrap; +} +.ld-bdg { + font-size: 11px; + padding: 2px 9px; + border-radius: 20px; + font-weight: 600; +} +.ld-bdg--fed { + background: #e7f0ec; + color: #0f6e56; +} +.ld-bdg--loc { + background: #eef0f2; + color: #55606b; +} +.ld-bdg--man { + background: #ece7fb; + color: #5b46a0; +} +.ld-bdg--sug { + background: #f6e4b8; + color: #8a6a12; +} +.ld-pctwrap { + text-align: right; +} +.ld-pct { + font: 700 22px 'JetBrains Mono', monospace; + color: #0f6e56; + line-height: 1; + text-align: right; +} +.ld-pct--mid { + color: #6a8a3a; +} +.ld-pct--lo { + color: #9aa6a0; +} +.ld-pct--man { + color: #b6aed0; + font-size: 15px; +} +.ld-pct__u { + font-size: 11px; +} +.ld-pl { + font-size: 9.5px; + color: #7a8a82; + text-transform: uppercase; + letter-spacing: 0.06em; + text-align: right; + margin-top: 2px; +} +.ld-desc { + color: #46524c; + font-size: 13px; + margin: 10px 0 8px; + min-height: 18px; +} +.ld-mrow { + font-size: 13px; + color: #55606b; + margin: 3px 0; +} +.ld-lbl { + color: #7a8a82; +} +.ld-mrow a { + color: #0f6e56; + text-decoration: none; + cursor: pointer; +} +.ld-na { + color: #b3a; +} +.ld-srcline { + display: flex; + align-items: center; + gap: 7px; + font-size: 12.5px; + color: #55606b; + margin-top: 8px; + flex-wrap: wrap; +} +.ld-tagrun { + background: #dff0e6; + color: #1a7a3a; + font-size: 11px; + padding: 1px 8px; + border-radius: 20px; + font-weight: 600; +} +.ld-tagwait { + background: #f3eee2; + color: #9a7a2a; + font-size: 11px; + padding: 1px 8px; + border-radius: 20px; + font-weight: 600; +} +.ld-cfoot { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 13px; + padding-top: 12px; + border-top: 1px solid #f0ece3; + gap: 8px; +} +.ld-link { + color: #0f6e56; + cursor: pointer; + font-size: 12.5px; + font-weight: 600; +} +.ld-link--del { + color: #b3422e; + margin-left: 10px; +} + +.ld-bulkbar { + position: fixed; + left: 50%; + bottom: 18px; + transform: translateX(-50%); + width: min(1040px, calc(100% - 280px)); + background: #fff; + border: 1px solid #e6e1d6; + border-radius: 12px; + padding: 11px 16px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + z-index: 40; +} +.ld-bulkbar__acts { + display: flex; + gap: 7px; + align-items: center; + flex-wrap: wrap; +} + +/* ——— источники (карточка конкурента) ——— */ +.ld-srccard { + display: flex; + gap: 12px; + align-items: flex-start; + background: #fff; + border: 1px solid #e6e1d6; + border-radius: 12px; + padding: 13px 15px; + margin-bottom: 10px; +} +.ld-srccard--sug { + border-color: #e6c98a; + background: #fffaf0; +} +.ld-srccard--picked { + border-color: #0f6e56; + box-shadow: 0 0 0 1.5px #0f6e56; +} +.ld-srcicon { + font-size: 18px; + width: 22px; + text-align: center; + padding-top: 1px; +} +.ld-srcmain { + flex: 1; + min-width: 0; +} +.ld-srctitle { + font-weight: 600; + font-size: 14px; + display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap; +} +.ld-srcprov { + color: #7a8a82; + font-size: 12px; + font-style: italic; + margin-top: 2px; +} +.ld-srcproj { + font-size: 12.5px; + margin-top: 5px; + color: #55606b; +} +.ld-srcctl { + display: flex; + gap: 6px; + align-items: flex-start; + flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; + max-width: 320px; +} +.ld-b-run { + background: #dff0e6; + color: #1a7a3a; +} +.ld-b-stop { + background: #f0e6e6; + color: #a05050; +} +.ld-b-none { + background: #eef0f2; + color: #7a8a82; +} +.ld-note { + color: #7a8a82; + font-size: 13px; + margin: 2px 0 14px; +} + +/* ——— модалки / формы ——— */ +.ld-ovl { + position: fixed; + inset: 0; + background: rgba(1, 20, 15, 0.45); + display: flex; + align-items: flex-start; + justify-content: center; + z-index: 50; + overflow: auto; + padding: 40px 16px; +} +.ld-modal { + background: #fff; + border-radius: 16px; + padding: 22px 24px; + width: 540px; + max-width: 94vw; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3); +} +.ld-modal__h { + margin: 0 0 4px; + font-size: 18px; + color: #012019; +} +.ld-modal__m { + color: #7a8a82; + font-size: 13px; + margin: 0 0 10px; +} +.ld-modal__warn { + color: #a05a1a; + font-size: 13px; + margin: 8px 0 0; +} +.ld-modal__ok { + color: #1a7a3a; + background: #eef7f3; + border: 1px solid #cfe6dc; + border-radius: 9px; + padding: 10px 12px; + font-size: 13px; + margin: 8px 0 0; + line-height: 1.45; +} +.ld-modal__foot { + display: flex; + justify-content: flex-end; + gap: 9px; + margin-top: 18px; + flex-wrap: wrap; +} +.ld-rules { + background: #eef7f3; + border: 1px solid #cfe6dc; + border-radius: 11px; + padding: 13px 16px; + margin: 6px 0 16px; +} +.ld-rules h4 { + margin: 0 0 7px; + font-size: 13.5px; + color: #0f6e56; +} +.ld-rules ul { + margin: 0; + padding-left: 18px; +} +.ld-rules li { + font-size: 12.8px; + color: #3a463f; + margin: 4px 0; +} +.ld-price { + background: #fff5e9; + border: 1px solid #f0d9b5; + border-radius: 10px; + padding: 9px 13px; + font-size: 12.8px; + color: #8a6a12; + margin: 10px 0; +} +.ld-price--go { + background: #eef7f3; + border-color: #cfe6dc; + color: #1a7a3a; +} +.ld-fld { + margin: 11px 0; +} +.ld-fld label { + display: block; + font-size: 12.5px; + color: #3a463f; + margin-bottom: 4px; + font-weight: 500; +} +.ld-req { + color: #b3422e; + margin-left: 2px; +} +.ld-in { + width: 100%; + border: 1px solid #e6e1d6; + border-radius: 9px; + padding: 9px 11px; + font: 14px Inter, system-ui, Arial; + background: #fff; + color: #1a2420; +} +.ld-in:focus { + outline: none; + border-color: #0f6e56; +} +.ld-hint { + font-size: 11.5px; + color: #7a8a82; + margin-top: 4px; +} +.ld-err { + font-size: 13px; + color: #b3422e; + background: #fdecea; + border-radius: 8px; + padding: 8px 10px; + margin: 10px 0 0; +} +.ld-ex { + display: flex; + gap: 8px; + margin-bottom: 8px; +} +.ld-ex .ld-in { + flex: 1; +} +.ld-lockfld { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 11px; + border: 1px solid #e6e1d6; + border-radius: 9px; + background: #f4f2ec; + color: #3a463f; + font-weight: 600; +} +.ld-lockbadge { + font-size: 11px; + font-weight: 600; + color: #8a6a12; + background: #fff5e9; + border: 1px solid #f0d9b5; + border-radius: 20px; + padding: 1px 9px; +} +.ld-days { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.ld-day { + border: 1px solid #e6e1d6; + background: #fff; + border-radius: 8px; + padding: 7px 13px; + cursor: pointer; + font: 600 13px Inter, system-ui, Arial; + color: #55606b; +} +.ld-day--on { + background: #0f6e56; + color: #fff; + border-color: #0f6e56; +} + +.ld-spin { + display: inline-block; + width: 16px; + height: 16px; + border: 2.5px solid #cfe0d8; + border-top-color: #0f6e56; + border-radius: 50%; + animation: ld-sp 1s linear infinite; + vertical-align: -3px; +} +@keyframes ld-sp { + to { + transform: rotate(360deg); + } +} + +.ld-toast { + position: fixed; + bottom: 22px; + left: 50%; + transform: translateX(-50%); + background: #0c3a2e; + color: #fff; + padding: 12px 20px; + border-radius: 11px; + font-size: 13.5px; + z-index: 60; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); +} + +.ld-bar-enter-active, +.ld-bar-leave-active, +.ld-toast-enter-active, +.ld-toast-leave-active { + transition: + opacity 0.22s ease, + transform 0.22s ease; +} +.ld-bar-enter-from, +.ld-bar-leave-to { + opacity: 0; + transform: translate(-50%, 12px); +} +.ld-toast-enter-from, +.ld-toast-leave-to { + opacity: 0; + transform: translate(-50%, 16px); +} diff --git a/app/routes/web.php b/app/routes/web.php index b932974f..1c5c98ea 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -333,6 +333,8 @@ Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/projec // Автоподбор конкурентов — клиентский API (Task 17a). Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/autopodbor')->group(function () { Route::get('/state', 'App\Http\Controllers\Api\AutopodborController@state'); + Route::get('/field', 'App\Http\Controllers\Api\AutopodborController@field'); + Route::get('/proposals', 'App\Http\Controllers\Api\AutopodborController@proposals'); Route::get('/runs/{run}', 'App\Http\Controllers\Api\AutopodborController@run')->where('run', '[0-9]+'); Route::get('/runs/{run}/competitors', 'App\Http\Controllers\Api\AutopodborController@runCompetitors')->where('run', '[0-9]+'); Route::get('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@competitor')->where('competitor', '[0-9]+'); @@ -342,6 +344,13 @@ Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/autopo Route::post('/projects', 'App\Http\Controllers\Api\AutopodborController@createProjects'); Route::post('/manual-study', 'App\Http\Controllers\Api\AutopodborController@manualStudy'); Route::post('/sources/manual', 'App\Http\Controllers\Api\AutopodborController@addManualSource'); + Route::post('/competitors/manual', 'App\Http\Controllers\Api\AutopodborController@manualCompetitor'); + Route::patch('/competitors/{competitor}/box', 'App\Http\Controllers\Api\AutopodborController@competitorBox')->where('competitor', '[0-9]+'); + Route::patch('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@updateCompetitor')->where('competitor', '[0-9]+'); + Route::delete('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@destroyCompetitor')->where('competitor', '[0-9]+'); + Route::patch('/sources/{source}/box', 'App\Http\Controllers\Api\AutopodborController@sourceBox')->where('source', '[0-9]+'); + Route::patch('/sources/{source}', 'App\Http\Controllers\Api\AutopodborController@updateSource')->where('source', '[0-9]+'); + Route::delete('/sources/{source}', 'App\Http\Controllers\Api\AutopodborController@destroySource')->where('source', '[0-9]+'); }); // Supplier-integration webhook (Plan 2/5, spec §5.1). diff --git a/app/tests/Feature/Autopodbor/AutopodborBoxApiTest.php b/app/tests/Feature/Autopodbor/AutopodborBoxApiTest.php new file mode 100644 index 00000000..1ec0c3c1 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborBoxApiTest.php @@ -0,0 +1,81 @@ +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]); + $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Окна Комфорт', 'dedup_key' => 'okna']); + + return [$tenant, $user, $run, $comp]; +} + +it('PATCH competitors/{id}/box — переносит конкурента в поле и обратно', function () { + [$tenant, $user, $run, $comp] = boxSetup(); + + $this->actingAs($user)->patchJson("/api/autopodbor/competitors/{$comp->id}/box", ['box' => 'field']) + ->assertOk() + ->assertJsonPath('data.box', 'field'); + + expect($comp->fresh()->box)->toBe('field'); + + $this->actingAs($user)->patchJson("/api/autopodbor/competitors/{$comp->id}/box", ['box' => 'proposal']) + ->assertOk() + ->assertJsonPath('data.box', 'proposal'); +}); + +it('PATCH competitors/{id}/box — отвергает чужое значение ящика (422)', function () { + [$tenant, $user, $run, $comp] = boxSetup(); + + $this->actingAs($user)->patchJson("/api/autopodbor/competitors/{$comp->id}/box", ['box' => 'garbage']) + ->assertStatus(422); +}); + +it('PATCH competitors/{id}/box — чужой тенант не видит конкурента (404)', function () { + [$tenant, $user, $run, $comp] = boxSetup(); + $other = User::factory()->create(['tenant_id' => Tenant::factory()->create()->id]); + + $this->actingAs($other)->patchJson("/api/autopodbor/competitors/{$comp->id}/box", ['box' => 'field']) + ->assertStatus(404); +}); + +it('PATCH sources/{id}/box — переносит источник в работу и обратно', function () { + [$tenant, $user, $run, $comp] = boxSetup(); + $src = AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, + 'signal_type' => 'site', 'identifier' => 'okna-komfort.ru', 'dedup_key' => 'site:okna-komfort.ru', + ]); + + $this->actingAs($user)->patchJson("/api/autopodbor/sources/{$src->id}/box", ['box' => 'field']) + ->assertOk() + ->assertJsonPath('data.box', 'field'); + + expect($src->fresh()->box)->toBe('field'); +}); + +it('PATCH sources/{id}/box — чужой тенант не видит источник (404)', function () { + [$tenant, $user, $run, $comp] = boxSetup(); + $src = AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, + 'signal_type' => 'site', 'identifier' => 'okna-komfort.ru', 'dedup_key' => 'site:okna-komfort.ru', + ]); + $other = User::factory()->create(['tenant_id' => Tenant::factory()->create()->id]); + + $this->actingAs($other)->patchJson("/api/autopodbor/sources/{$src->id}/box", ['box' => 'field']) + ->assertStatus(404); +}); diff --git a/app/tests/Feature/Autopodbor/AutopodborBoxStateTest.php b/app/tests/Feature/Autopodbor/AutopodborBoxStateTest.php new file mode 100644 index 00000000..b94c30e4 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborBoxStateTest.php @@ -0,0 +1,65 @@ +create(); + DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create([ + 'tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', + 'region_code' => 16, 'params' => [], + ]); + + return [$tenant, $run]; +} + +it('конкурент по умолчанию в ящике «предложение» и переводится в «поле»', function () { + [$tenant, $run] = bsTenantRun(); + + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'search_run_id' => $run->id, + 'name' => 'Окна Комфорт', 'dedup_key' => 'okna-komfort', + ]); + + expect($comp->fresh()->box)->toBe('proposal'); + + $comp->update(['box' => 'field']); + expect($comp->fresh()->box)->toBe('field'); +}); + +it('источник по умолчанию в ящике «предложение» и переводится в «поле»', function () { + [$tenant, $run] = bsTenantRun(); + + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'search_run_id' => $run->id, + 'name' => 'Окна Комфорт', 'dedup_key' => 'okna-komfort', + ]); + $src = AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, + 'signal_type' => 'site', 'identifier' => 'okna-komfort.ru', 'dedup_key' => 'site:okna-komfort.ru', + ]); + + expect($src->fresh()->box)->toBe('proposal'); + + $src->update(['box' => 'field']); + expect($src->fresh()->box)->toBe('field'); +}); + +it('ящик конкурента не принимает чужое значение (CHECK)', function () { + [$tenant, $run] = bsTenantRun(); + + expect(fn () => AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'search_run_id' => $run->id, + 'name' => 'X', 'dedup_key' => 'x', 'box' => 'garbage', + ]))->toThrow(QueryException::class); +}); diff --git a/app/tests/Feature/Autopodbor/AutopodborCompetitorCrudApiTest.php b/app/tests/Feature/Autopodbor/AutopodborCompetitorCrudApiTest.php new file mode 100644 index 00000000..b80957d2 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborCompetitorCrudApiTest.php @@ -0,0 +1,95 @@ +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]); + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'search_run_id' => $run->id, + 'name' => 'Старое имя', 'dedup_key' => 'old', 'relevance_pct' => 50, 'box' => 'proposal', + ]); + + return [$tenant, $user, $run, $comp]; +} + +it('PATCH competitors/{id} — правит поля карточки', function () { + [$tenant, $user, $run, $comp] = compCrudSetup(); + + $this->actingAs($user)->patchJson("/api/autopodbor/competitors/{$comp->id}", [ + 'name' => 'Окна Премиум', + 'description' => 'Премиальные окна', + 'is_federal' => true, + 'relevance_pct' => 88, + 'site_url' => 'okna-premium.ru', + ])->assertOk() + ->assertJsonPath('data.name', 'Окна Премиум') + ->assertJsonPath('data.relevance_pct', 88) + ->assertJsonPath('data.is_federal', true); + + $fresh = $comp->fresh(); + expect($fresh->name)->toBe('Окна Премиум') + ->and($fresh->site_url)->toBe('okna-premium.ru'); +}); + +it('PATCH competitors/{id} — отвергает похожесть вне 0..100 (422)', function () { + [$tenant, $user, $run, $comp] = compCrudSetup(); + + $this->actingAs($user)->patchJson("/api/autopodbor/competitors/{$comp->id}", [ + 'relevance_pct' => 150, + ])->assertStatus(422); +}); + +it('PATCH competitors/{id} — чужой тенант не правит (404)', function () { + [$tenant, $user, $run, $comp] = compCrudSetup(); + $other = User::factory()->create(['tenant_id' => Tenant::factory()->create()->id]); + + $this->actingAs($other)->patchJson("/api/autopodbor/competitors/{$comp->id}", ['name' => 'Взлом']) + ->assertStatus(404); +}); + +it('DELETE competitors/{id} — удаляет конкурента и его источники', function () { + [$tenant, $user, $run, $comp] = compCrudSetup(); + $src = AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, + 'signal_type' => 'site', 'identifier' => 'okna.ru', 'dedup_key' => 'site:okna.ru', + ]); + + $this->actingAs($user)->deleteJson("/api/autopodbor/competitors/{$comp->id}") + ->assertStatus(204); + + expect(AutopodborCompetitor::find($comp->id))->toBeNull() + ->and(AutopodborSource::find($src->id))->toBeNull(); +}); + +it('DELETE competitors/{id} — блок, если у источника активный проект (409)', function () { + [$tenant, $user, $run, $comp] = compCrudSetup(); + $project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'preflight_blocked_at' => null]); + AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, + 'signal_type' => 'site', 'identifier' => 'okna.ru', 'dedup_key' => 'site:okna.ru', + 'created_project_id' => $project->id, + ]); + + $this->actingAs($user)->deleteJson("/api/autopodbor/competitors/{$comp->id}") + ->assertStatus(409) + ->assertJsonPath('error', 'has_active_projects'); + + expect(AutopodborCompetitor::find($comp->id))->not->toBeNull(); +}); diff --git a/app/tests/Feature/Autopodbor/AutopodborCompetitorSourcesApiTest.php b/app/tests/Feature/Autopodbor/AutopodborCompetitorSourcesApiTest.php new file mode 100644 index 00000000..77cf5416 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborCompetitorSourcesApiTest.php @@ -0,0 +1,49 @@ +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]); + $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Окна', 'dedup_key' => 'okna']); + + $project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'preflight_blocked_at' => null]); + + // источник в работе с активным проектом + AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, + 'signal_type' => 'call', 'identifier' => '78432001122', 'phone_kind' => 'real', 'phone_type' => 'city', + 'dedup_key' => 'call:78432001122', 'box' => 'field', 'created_project_id' => $project->id, + ]); + // источник-предложение без проекта + AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, + 'signal_type' => 'site', 'identifier' => 'okna.ru', 'dedup_key' => 'site:okna.ru', 'box' => 'proposal', + ]); + + $resp = $this->actingAs($user)->getJson("/api/autopodbor/competitors/{$comp->id}") + ->assertOk() + ->assertJsonStructure(['data' => ['id', 'name'], 'sources' => [['id', 'box', 'phone_type', 'project']]]); + + $byId = collect($resp->json('sources'))->keyBy('identifier'); + expect($byId['78432001122']['box'])->toBe('field') + ->and($byId['78432001122']['phone_type'])->toBe('city') + ->and($byId['78432001122']['project'])->not->toBeNull() + ->and($byId['78432001122']['project']['is_active'])->toBeTrue() + ->and($byId['okna.ru']['box'])->toBe('proposal') + ->and($byId['okna.ru']['project'])->toBeNull(); +}); diff --git a/app/tests/Feature/Autopodbor/AutopodborFieldApiTest.php b/app/tests/Feature/Autopodbor/AutopodborFieldApiTest.php new file mode 100644 index 00000000..e6d1d55f --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborFieldApiTest.php @@ -0,0 +1,120 @@ +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]); + + // конкурент в поле + $fieldComp = AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'search_run_id' => $run->id, + 'name' => 'Окна Комфорт', 'dedup_key' => 'okna', 'box' => 'field', 'relevance_pct' => 90, + ]); + // конкурент в предложениях — НЕ должен попасть + AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'search_run_id' => $run->id, + 'name' => 'Предложенный', 'dedup_key' => 'prop', 'box' => 'proposal', + ]); + + // активный проект для источника A + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, 'is_active' => true, 'preflight_blocked_at' => null, + ]); + + // источник A — в поле, с активным проектом + AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $fieldComp->id, 'study_run_id' => $run->id, + 'signal_type' => 'site', 'identifier' => 'okna-a.ru', 'dedup_key' => 'site:okna-a.ru', + 'box' => 'field', 'created_project_id' => $project->id, + ]); + // источник B — в поле, без проекта + AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $fieldComp->id, 'study_run_id' => $run->id, + 'signal_type' => 'site', 'identifier' => 'okna-b.ru', 'dedup_key' => 'site:okna-b.ru', + 'box' => 'field', + ]); + // источник C — в предложениях, не считаем в поле + AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $fieldComp->id, 'study_run_id' => $run->id, + 'signal_type' => 'site', 'identifier' => 'okna-c.ru', 'dedup_key' => 'site:okna-c.ru', + 'box' => 'proposal', + ]); + + $resp = $this->actingAs($user)->getJson('/api/autopodbor/field') + ->assertOk() + ->assertJsonStructure([ + 'competitors' => [[ + 'id', 'name', 'box', + 'counters' => ['sources', 'projects_created', 'projects_in_work'], + 'sources' => [['id', 'identifier', 'box', 'project']], + ]], + ]); + + $data = $resp->json('competitors'); + expect($data)->toHaveCount(1) + ->and($data[0]['name'])->toBe('Окна Комфорт'); + + // счётчики: 2 источника в поле, 1 проект создан, 1 в работе + expect($data[0]['counters']['sources'])->toBe(2) + ->and($data[0]['counters']['projects_created'])->toBe(1) + ->and($data[0]['counters']['projects_in_work'])->toBe(1); + + // источник A несёт статус проекта, источник B — null + $ids = collect($data[0]['sources'])->keyBy('identifier'); + expect($ids['okna-a.ru']['project'])->not->toBeNull() + ->and($ids['okna-a.ru']['project']['is_active'])->toBeTrue() + ->and($ids['okna-b.ru']['project'])->toBeNull(); +}); + +it('GET /api/autopodbor/field — заблокированный по балансу проект не считается «в работе»', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]); + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'search_run_id' => $run->id, + 'name' => 'X', 'dedup_key' => 'x', 'box' => 'field', + ]); + $blocked = Project::factory()->create([ + 'tenant_id' => $tenant->id, 'is_active' => true, 'preflight_blocked_at' => now(), + ]); + AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, + 'signal_type' => 'site', 'identifier' => 'x.ru', 'dedup_key' => 'site:x.ru', + 'box' => 'field', 'created_project_id' => $blocked->id, + ]); + + $data = $this->actingAs($user)->getJson('/api/autopodbor/field')->assertOk()->json('competitors'); + expect($data[0]['counters']['projects_created'])->toBe(1) + ->and($data[0]['counters']['projects_in_work'])->toBe(0); +}); + +it('GET /api/autopodbor/field — чужой тенант своих в поле не видит', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]); + AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'search_run_id' => $run->id, + 'name' => 'Чужой', 'dedup_key' => 'alien', 'box' => 'field', + ]); + + $other = User::factory()->create(['tenant_id' => Tenant::factory()->create()->id]); + $data = $this->actingAs($other)->getJson('/api/autopodbor/field')->assertOk()->json('competitors'); + expect($data)->toHaveCount(0); +}); diff --git a/app/tests/Feature/Autopodbor/AutopodborManualCompetitorApiTest.php b/app/tests/Feature/Autopodbor/AutopodborManualCompetitorApiTest.php new file mode 100644 index 00000000..35a40df7 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborManualCompetitorApiTest.php @@ -0,0 +1,55 @@ +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + + $this->actingAs($user)->postJson('/api/autopodbor/competitors/manual', [ + 'name' => 'Окна Ромашка', + 'site_url' => 'romashka.ru', + 'description' => 'Местный конкурент', + ])->assertCreated() + ->assertJsonPath('data.name', 'Окна Ромашка') + ->assertJsonPath('data.box', 'field') + ->assertJsonPath('data.origin', 'manual'); + + $comp = AutopodborCompetitor::where('tenant_id', $tenant->id)->where('name', 'Окна Ромашка')->first(); + expect($comp)->not->toBeNull() + ->and($comp->box)->toBe('field') + ->and($comp->origin)->toBe('manual') + ->and($comp->search_run_id)->toBeNull() + ->and($comp->study_run_id)->toBeNull(); // изучение НЕ запускалось +}); + +it('POST competitors/manual — имя обязательно (422)', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + + $this->actingAs($user)->postJson('/api/autopodbor/competitors/manual', [ + 'site_url' => 'romashka.ru', + ])->assertStatus(422); +}); + +it('POST competitors/manual — конкурент привязан к своему тенанту', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + + $this->actingAs($user)->postJson('/api/autopodbor/competitors/manual', ['name' => 'Берёзка']) + ->assertCreated(); + + expect(AutopodborCompetitor::where('name', 'Берёзка')->first()->tenant_id)->toBe($tenant->id); +}); diff --git a/app/tests/Feature/Autopodbor/AutopodborPhoneTypeTest.php b/app/tests/Feature/Autopodbor/AutopodborPhoneTypeTest.php new file mode 100644 index 00000000..4ec31c9a --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborPhoneTypeTest.php @@ -0,0 +1,77 @@ +create(); + DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]); + $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'X', 'dedup_key' => 'x']); + + return [$tenant, $run, $comp]; +} + +it('источник хранит тип номера рядом с меткой коллтрекинга', function () { + [$tenant, $run, $comp] = ptSetup(); + + $src = AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, + 'signal_type' => 'call', 'identifier' => '78432001122', + 'phone_kind' => 'real', 'phone_type' => 'city', 'dedup_key' => 'call:78432001122', + ]); + + expect($src->fresh()->phone_type)->toBe('city') + ->and($src->fresh()->phone_kind)->toBe('real'); // метка коллтрекинга осталась + + foreach (['city', 'mobile', 'tollfree'] as $i => $t) { + $s = AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, + 'signal_type' => 'call', 'identifier' => '7900000000'.$i, + 'phone_kind' => 'real', 'phone_type' => $t, 'dedup_key' => 'call:t'.$i, + ]); + expect($s->fresh()->phone_type)->toBe($t); + } +}); + +it('у сайта тип номера пустой', function () { + [$tenant, $run, $comp] = ptSetup(); + $src = AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, + 'signal_type' => 'site', 'identifier' => 'okna.ru', 'dedup_key' => 'site:okna.ru', + ]); + expect($src->fresh()->phone_type)->toBeNull(); +}); + +it('тип номера отвергает чужое значение (CHECK)', function () { + [$tenant, $run, $comp] = ptSetup(); + expect(fn () => AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, + 'signal_type' => 'call', 'identifier' => '78432001100', + 'phone_type' => 'garbage', 'dedup_key' => 'call:garbage', + ]))->toThrow(QueryException::class); +}); + +it('FakeCompetitorAgent отдаёт у телефонов и тип номера, и метку коллтрекинга', function () { + $agent = new FakeCompetitorAgent; + $res = $agent->studyCompetitor(new StudyCompetitorRequest(['name' => 'X'], 16)); + $calls = array_values(array_filter($res->sources, fn ($s) => $s['signal_type'] === 'call')); + + expect($calls)->not->toBeEmpty(); + foreach ($calls as $c) { + expect($c)->toHaveKey('phone_type') + ->and(in_array($c['phone_type'], ['city', 'mobile', 'tollfree'], true))->toBeTrue() + ->and($c)->toHaveKey('phone_kind'); // метка коллтрекинга тоже есть + } +}); diff --git a/app/tests/Feature/Autopodbor/AutopodborPriceDefaultsTest.php b/app/tests/Feature/Autopodbor/AutopodborPriceDefaultsTest.php new file mode 100644 index 00000000..f511f3bc --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborPriceDefaultsTest.php @@ -0,0 +1,13 @@ +toBe('300') + ->and((string) SystemSettings::get('autopodbor_price_study_rub'))->toBe('50'); +}); diff --git a/app/tests/Feature/Autopodbor/AutopodborProposalsApiTest.php b/app/tests/Feature/Autopodbor/AutopodborProposalsApiTest.php new file mode 100644 index 00000000..c3e98949 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborProposalsApiTest.php @@ -0,0 +1,46 @@ +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]); + + AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Низкая', 'dedup_key' => 'low', 'box' => 'proposal', 'relevance_pct' => 40]); + AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Высокая', 'dedup_key' => 'high', 'box' => 'proposal', 'relevance_pct' => 95]); + // в поле — не предложение, не должен попасть + AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'В поле', 'dedup_key' => 'fld', 'box' => 'field', 'relevance_pct' => 100]); + + $data = $this->actingAs($user)->getJson('/api/autopodbor/proposals') + ->assertOk() + ->assertJsonStructure(['data' => [['id', 'name', 'box', 'relevance_pct']]]) + ->json('data'); + + expect($data)->toHaveCount(2) + ->and($data[0]['name'])->toBe('Высокая') // сорт по похожести + ->and($data[1]['name'])->toBe('Низкая'); +}); + +it('GET /api/autopodbor/proposals — чужой тенант своих не видит', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]); + AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Чужой', 'dedup_key' => 'a', 'box' => 'proposal']); + + $other = User::factory()->create(['tenant_id' => Tenant::factory()->create()->id]); + $data = $this->actingAs($other)->getJson('/api/autopodbor/proposals')->assertOk()->json('data'); + expect($data)->toHaveCount(0); +}); diff --git a/app/tests/Feature/Autopodbor/AutopodborSourceCrudApiTest.php b/app/tests/Feature/Autopodbor/AutopodborSourceCrudApiTest.php new file mode 100644 index 00000000..68f7d3c0 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborSourceCrudApiTest.php @@ -0,0 +1,123 @@ +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]); + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'search_run_id' => $run->id, + 'name' => 'Окна', 'dedup_key' => 'okna', 'box' => 'field', + ]); + + return [$tenant, $user, $run, $comp]; +} + +function makeSource(Tenant $t, AutopodborRun $run, AutopodborCompetitor $comp, array $attrs = []): AutopodborSource +{ + return AutopodborSource::create(array_merge([ + 'tenant_id' => $t->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, + 'signal_type' => 'site', 'identifier' => 'okna.ru', 'dedup_key' => 'site:okna.ru', 'box' => 'proposal', + ], $attrs)); +} + +it('PATCH sources/{id} — правит значение, провенанс и ящик', function () { + [$tenant, $user, $run, $comp] = srcCrudSetup(); + $src = makeSource($tenant, $run, $comp); + + $this->actingAs($user)->patchJson("/api/autopodbor/sources/{$src->id}", [ + 'identifier' => 'okna-new.ru', + 'provenance_label' => 'Сайт компании', + 'box' => 'field', + ])->assertOk() + ->assertJsonPath('data.identifier', 'okna-new.ru') + ->assertJsonPath('data.box', 'field'); + + $fresh = $src->fresh(); + expect($fresh->identifier)->toBe('okna-new.ru') + ->and($fresh->provenance_label)->toBe('Сайт компании'); +}); + +it('PATCH sources/{id} — тип источника неизменяем (signal_type игнорируется)', function () { + [$tenant, $user, $run, $comp] = srcCrudSetup(); + $src = makeSource($tenant, $run, $comp, ['signal_type' => 'site']); + + $this->actingAs($user)->patchJson("/api/autopodbor/sources/{$src->id}", [ + 'signal_type' => 'call', + 'identifier' => 'okna2.ru', + ])->assertOk(); + + expect($src->fresh()->signal_type)->toBe('site'); // тип не сменился +}); + +it('PATCH sources/{id} — смена значения блокируется при активном проекте (409)', function () { + [$tenant, $user, $run, $comp] = srcCrudSetup(); + $project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'preflight_blocked_at' => null]); + $src = makeSource($tenant, $run, $comp, ['created_project_id' => $project->id]); + + $this->actingAs($user)->patchJson("/api/autopodbor/sources/{$src->id}", [ + 'identifier' => 'changed.ru', + ])->assertStatus(409) + ->assertJsonPath('error', 'manage_via_project'); + + expect($src->fresh()->identifier)->toBe('okna.ru'); +}); + +it('PATCH sources/{id} — провенанс/ящик можно править даже при активном проекте', function () { + [$tenant, $user, $run, $comp] = srcCrudSetup(); + $project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'preflight_blocked_at' => null]); + $src = makeSource($tenant, $run, $comp, ['created_project_id' => $project->id, 'box' => 'field']); + + $this->actingAs($user)->patchJson("/api/autopodbor/sources/{$src->id}", [ + 'provenance_label' => 'Из 2ГИС', + ])->assertOk(); + + expect($src->fresh()->provenance_label)->toBe('Из 2ГИС'); +}); + +it('PATCH sources/{id} — чужой тенант не правит (404)', function () { + [$tenant, $user, $run, $comp] = srcCrudSetup(); + $src = makeSource($tenant, $run, $comp); + $other = User::factory()->create(['tenant_id' => Tenant::factory()->create()->id]); + + $this->actingAs($other)->patchJson("/api/autopodbor/sources/{$src->id}", ['identifier' => 'x.ru']) + ->assertStatus(404); +}); + +it('DELETE sources/{id} — удаляет источник без проекта (204)', function () { + [$tenant, $user, $run, $comp] = srcCrudSetup(); + $src = makeSource($tenant, $run, $comp); + + $this->actingAs($user)->deleteJson("/api/autopodbor/sources/{$src->id}") + ->assertStatus(204); + + expect(AutopodborSource::find($src->id))->toBeNull(); +}); + +it('DELETE sources/{id} — блок при активном проекте (409)', function () { + [$tenant, $user, $run, $comp] = srcCrudSetup(); + $project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'preflight_blocked_at' => null]); + $src = makeSource($tenant, $run, $comp, ['created_project_id' => $project->id]); + + $this->actingAs($user)->deleteJson("/api/autopodbor/sources/{$src->id}") + ->assertStatus(409) + ->assertJsonPath('error', 'has_active_project'); + + expect(AutopodborSource::find($src->id))->not->toBeNull(); +}); diff --git a/app/tests/Feature/Autopodbor/RunAutopodborStudyJobTest.php b/app/tests/Feature/Autopodbor/RunAutopodborStudyJobTest.php index 497586c9..786d2006 100644 --- a/app/tests/Feature/Autopodbor/RunAutopodborStudyJobTest.php +++ b/app/tests/Feature/Autopodbor/RunAutopodborStudyJobTest.php @@ -2,17 +2,23 @@ declare(strict_types=1); -use App\Models\{AutopodborRun, AutopodborCompetitor, AutopodborSource, Tenant, SystemSetting}; use App\Jobs\Autopodbor\RunAutopodborStudyJob; +use App\Models\AutopodborCompetitor; +use App\Models\AutopodborRun; +use App\Models\AutopodborSource; +use App\Models\SystemSetting; +use App\Models\Tenant; use App\Services\Autopodbor\Agent\CompetitorAgent; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; +use Tests\Concerns\SharesSupplierPdo; +use Tests\Doubles\EmptyCompetitorAgent; -uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); it('успешное изучение: источники + конкурент изучен + списание', function () { $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '900', 'type' => 'decimal']); $searchRun = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]); $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $searchRun->id, 'name' => 'Окна Комфорт', 'dedup_key' => 'site:okna-komfort-kzn.ru', 'site_url' => 'okna-komfort-kzn.ru']); @@ -29,14 +35,15 @@ it('успешное изучение: источники + конкурент // источники нормализованы (телефоны 7xxxxxxxxxx) $phone = AutopodborSource::where('competitor_id', $comp->id)->where('signal_type', 'call')->first(); if ($phone) { - expect($phone->identifier)->toMatch('/^7\d{10}$/'); + expect($phone->identifier)->toMatch('/^7\d{10}$/') + ->and(in_array($phone->phone_type, ['city', 'mobile', 'tollfree'], true))->toBeTrue(); // тип номера сохранён } }); it('пустой результат: status=empty, без списания', function () { - app()->bind(CompetitorAgent::class, \Tests\Doubles\EmptyCompetitorAgent::class); + app()->bind(CompetitorAgent::class, EmptyCompetitorAgent::class); $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '900', 'type' => 'decimal']); $searchRun = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]); $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $searchRun->id, 'name' => 'Пусто', 'dedup_key' => 'site:empty.ru', 'site_url' => 'empty.ru']); diff --git a/app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts b/app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts new file mode 100644 index 00000000..1290cdee --- /dev/null +++ b/app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { createVuetify } from 'vuetify'; +import { reactive, ref } from 'vue'; + +vi.mock('../../resources/js/api/autopodbor'); +import FieldCompetitorScreen from '../../resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue'; +import { useAutopodborStore } from '../../resources/js/stores/autopodborStore'; + +const vuetify = createVuetify(); + +function makeNav(competitorId: number | null = 3) { + return { + go: vi.fn(), + ctx: reactive({ competitorId, editProjectId: null, selectedSourceIds: [] as number[] }), + screen: ref('fieldcompetitor'), + }; +} + +function src(over: Partial = {}) { + return { + id: 10, + competitor_id: 3, + signal_type: 'site', + identifier: 'okna.ru', + phone_kind: null, + phone_type: null, + box: 'field', + provenance_url: null, + provenance_label: null, + created_project_id: null, + project: null, + ...over, + }; +} + +function proj(over: Partial = {}) { + return { + id: 100, name: 'P', signal_identifier: 'okna.ru', is_active: true, paused_at: null, + preflight_blocked_at: null, daily_limit_target: 5, delivered_in_month: 0, delivery_days_mask: 127, regions: [24], + ...over, + }; +} + +function seed(store: any, sources: any[], comp: Partial = {}) { + vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => { + store.competitor = { id: 3, name: 'Окна Комфорт', is_federal: false, relevance_pct: 90, ...comp } as any; + store.sources = sources as any; + }); +} + +function mountFc(nav: any) { + return mount(FieldCompetitorScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } }); +} + +describe('FieldCompetitorScreen', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it('грузит конкурента и источники в работе', async () => { + const store = useAutopodborStore(); + seed(store, [src({ id: 10, identifier: 'okna.ru' })]); + const w = mountFc(makeNav(3)); + await new Promise((r) => setTimeout(r, 0)); + expect(store.loadCompetitor).toHaveBeenCalledWith(3); + expect(w.text()).toContain('Окна Комфорт'); + expect(w.text()).toContain('okna.ru'); + }); + + it('источник без проекта показывает «Создать проект» и открывает окно создания', async () => { + const store = useAutopodborStore(); + seed(store, [src({ id: 10, project: null })]); + const nav = makeNav(3); + const w = mountFc(nav); + await new Promise((r) => setTimeout(r, 0)); + const btn = w.findAll('button').find((b) => b.text() === 'Создать проект'); + expect(btn).toBeTruthy(); + await btn!.trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + expect(w.text()).toContain('Создать проект из источника'); + expect(w.text()).toContain('Дни недели приёма'); + }); + + it('активный проект → «Приостановить» зовёт toggleProjectActive(false)', async () => { + const store = useAutopodborStore(); + seed(store, [src({ id: 10, project: proj({ id: 100, is_active: true }) })]); + const toggleSpy = vi.spyOn(store, 'toggleProjectActive').mockResolvedValue(); + const w = mountFc(makeNav(3)); + await new Promise((r) => setTimeout(r, 0)); + const btn = w.findAll('button').find((b) => b.text() === 'Приостановить'); + await btn!.trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + expect(toggleSpy).toHaveBeenCalledWith(100, false); + }); + + it('телефон показывает значок и тип номера', async () => { + const store = useAutopodborStore(); + seed(store, [src({ id: 11, signal_type: 'call', identifier: '78432001122', phone_kind: 'substitute', phone_type: 'city' })]); + const w = mountFc(makeNav(3)); + await new Promise((r) => setTimeout(r, 0)); + expect(w.text()).toContain('🎭'); + expect(w.text()).toContain('городской'); + }); + + it('вкладка «Предложения» показывает источники-предложения и переносит «В работу»', async () => { + const store = useAutopodborStore(); + seed(store, [src({ id: 12, box: 'proposal', identifier: 'prop.ru' })]); + const moveSpy = vi.spyOn(store, 'moveSourceToBox').mockResolvedValue(); + const w = mountFc(makeNav(3)); + await new Promise((r) => setTimeout(r, 0)); + const propTab = w.findAll('button').find((b) => b.text().includes('Предложения')); + await propTab!.trigger('click'); + expect(w.text()).toContain('prop.ru'); + const btn = w.findAll('button').find((b) => b.text().includes('В источники')); + await btn!.trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + expect(moveSpy).toHaveBeenCalledWith(3, 12, 'field'); + }); + + it('источник с проектом показывает живой источник проекта и меняет через change_source', async () => { + const store = useAutopodborStore(); + seed(store, [ + src({ id: 10, signal_type: 'site', identifier: 'old.ru', project: proj({ id: 100, signal_identifier: 'live.ru', is_active: true }) }), + ]); + const changeSpy = vi.spyOn(store, 'changeProjectSource').mockResolvedValue({ source_change_message: 'Лиды дойдут.' }); + const w = mountFc(makeNav(3)); + await new Promise((r) => setTimeout(r, 0)); + // карточка показывает источник проекта, а не old.ru + expect(w.text()).toContain('live.ru'); + + const editBtn = w.findAll('.ld-link').find((b) => b.text().includes('Изменить источник')); + await editBtn!.trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + expect(w.text()).toContain('Сменить источник?'); + + const input = w.find('.ld-modal input'); + await input.setValue('new.ru'); + // первый клик «Сохранить» — показывает подтверждение + let save = w.findAll('.ld-modal button').find((b) => b.text() === 'Сохранить'); + await save!.trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + expect(w.text()).toContain('Подтвердите смену источника'); + // второй клик «Сменить источник» — выполняет change_source + save = w.findAll('.ld-modal button').find((b) => b.text() === 'Сменить источник'); + await save!.trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + expect(changeSpy).toHaveBeenCalledWith(100, 'new.ru'); + }); + + it('массовое «Приостановить выбранные» паузит проекты выбранных источников', async () => { + const store = useAutopodborStore(); + seed(store, [ + src({ id: 10, project: proj({ id: 100, signal_identifier: 'a.ru', is_active: true }) }), + src({ id: 11, identifier: 'b.ru', project: proj({ id: 101, signal_identifier: 'b.ru', is_active: true }) }), + ]); + const toggleSpy = vi.spyOn(store, 'toggleProjectActive').mockResolvedValue(); + const w = mountFc(makeNav(3)); + await new Promise((r) => setTimeout(r, 0)); + const boxes = w.findAll('.ld-pick'); + await boxes[0].trigger('change'); + await boxes[1].trigger('change'); + const btn = w.findAll('.ld-bulkbar button').find((b) => b.text().includes('Приостановить')); + await btn!.trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + expect(toggleSpy).toHaveBeenCalledWith(100, false); + expect(toggleSpy).toHaveBeenCalledWith(101, false); + }); + + it('окно «Изменить источник» открывается с залоченным типом', async () => { + const store = useAutopodborStore(); + seed(store, [src({ id: 10, signal_type: 'site', identifier: 'okna.ru' })]); + const w = mountFc(makeNav(3)); + await new Promise((r) => setTimeout(r, 0)); + const btn = w.findAll('.ld-link').find((b) => b.text().includes('Изменить источник')); + await btn!.trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + expect(w.text()).toContain('тип не меняется'); + }); +}); diff --git a/app/tests/Frontend/AutopodborFieldManualCompetitorScreen.spec.ts b/app/tests/Frontend/AutopodborFieldManualCompetitorScreen.spec.ts new file mode 100644 index 00000000..24d979fe --- /dev/null +++ b/app/tests/Frontend/AutopodborFieldManualCompetitorScreen.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { createVuetify } from 'vuetify'; +import { reactive, ref } from 'vue'; + +vi.mock('../../resources/js/api/autopodbor'); +import FieldManualCompetitorScreen from '../../resources/js/views/autopodbor/screens/FieldManualCompetitorScreen.vue'; +import { useAutopodborStore } from '../../resources/js/stores/autopodborStore'; + +const vuetify = createVuetify(); + +function makeNav() { + return { go: vi.fn(), ctx: reactive({ competitorId: null }), screen: ref('field-manual-competitor') }; +} + +function mountM(nav: any) { + return mount(FieldManualCompetitorScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } }); +} + +describe('FieldManualCompetitorScreen', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it('пустое имя показывает ошибку и не отправляет', async () => { + const store = useAutopodborStore(); + const addSpy = vi.spyOn(store, 'addFieldCompetitor'); + const w = mountM(makeNav()); + const btn = w.findAll('button').find((b) => b.text() === 'Добавить в поле'); + await btn!.trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + expect(addSpy).not.toHaveBeenCalled(); + expect(w.text()).toContain('Укажите название'); + }); + + it('заполнение и отправка добавляют конкурента и открывают его карточку', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'addFieldCompetitor').mockResolvedValue({ id: 42, name: 'Ромашка' } as any); + const nav = makeNav(); + const w = mountM(nav); + await w.find('input').setValue('Ромашка'); + const btn = w.findAll('button').find((b) => b.text() === 'Добавить в поле'); + await btn!.trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + expect(store.addFieldCompetitor).toHaveBeenCalled(); + expect(nav.ctx.competitorId).toBe(42); + expect(nav.go).toHaveBeenCalledWith('fieldcompetitor'); + }); +}); diff --git a/app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts b/app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts new file mode 100644 index 00000000..815b28bf --- /dev/null +++ b/app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { createVuetify } from 'vuetify'; +import { reactive, ref } from 'vue'; + +vi.mock('../../resources/js/api/autopodbor'); +import FieldProposalsScreen from '../../resources/js/views/autopodbor/screens/FieldProposalsScreen.vue'; +import { useAutopodborStore } from '../../resources/js/stores/autopodborStore'; + +const vuetify = createVuetify(); + +function makeNav() { + return { go: vi.fn(), ctx: reactive({ competitorId: null }), screen: ref('field-proposals') }; +} + +function comp(over: Partial = {}) { + return { + id: 1, + name: 'Окна', + description: null, + is_federal: false, + relevance_pct: 80, + origin: 'auto', + box: 'proposal', + site_url: 'okna.ru', + directory_urls: [], + studied_at: null, + study_run_id: null, + search_run_id: 5, + ...over, + }; +} + +function mountP(nav: any) { + return mount(FieldProposalsScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } }); +} + +describe('FieldProposalsScreen', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it('грузит предложения и показывает их', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadProposals').mockImplementation(async () => { + store.proposals = [comp({ id: 1, name: 'Окна Комфорт' })] as any; + }); + const w = mountP(makeNav()); + await new Promise((r) => setTimeout(r, 0)); + expect(store.loadProposals).toHaveBeenCalled(); + expect(w.text()).toContain('Окна Комфорт'); + }); + + it('«В поле» по конкуренту зовёт moveCompetitorToBox(field)', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadProposals').mockImplementation(async () => { + store.proposals = [comp({ id: 7 })] as any; + }); + const moveSpy = vi.spyOn(store, 'moveCompetitorToBox').mockResolvedValue(); + const w = mountP(makeNav()); + await new Promise((r) => setTimeout(r, 0)); + const btn = w.find('.ld-comp__act button'); + await btn.trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + expect(moveSpy).toHaveBeenCalledWith(7, 'field'); + }); + + it('пусто показывает заглушку', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadProposals').mockImplementation(async () => { + store.proposals = [] as any; + }); + const w = mountP(makeNav()); + await new Promise((r) => setTimeout(r, 0)); + expect(w.text()).toContain('Предложений пока нет'); + }); +}); diff --git a/app/tests/Frontend/AutopodborFieldWorkspaceScreen.spec.ts b/app/tests/Frontend/AutopodborFieldWorkspaceScreen.spec.ts new file mode 100644 index 00000000..e6b4434c --- /dev/null +++ b/app/tests/Frontend/AutopodborFieldWorkspaceScreen.spec.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { createVuetify } from 'vuetify'; +import { reactive, ref } from 'vue'; + +vi.mock('../../resources/js/api/autopodbor'); +import FieldWorkspaceScreen from '../../resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue'; +import { useAutopodborStore } from '../../resources/js/stores/autopodborStore'; + +const vuetify = createVuetify(); + +function makeNav() { + return { go: vi.fn(), ctx: reactive({ competitorId: null }), screen: ref('field') }; +} + +function field(over: Partial = {}) { + return { + id: 1, + name: 'Окна Комфорт', + description: 'd', + is_federal: false, + relevance_pct: 90, + origin: 'auto', + box: 'field', + site_url: 'okna.ru', + directory_urls: [], + studied_at: null, + study_run_id: null, + search_run_id: 5, + counters: { sources: 2, projects_created: 1, projects_in_work: 1 }, + sources: [], + ...over, + }; +} + +function mountWs(nav: any) { + return mount(FieldWorkspaceScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } }); +} + +describe('FieldWorkspaceScreen', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it('грузит поле и показывает конкурентов со счётчиками', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadField').mockImplementation(async () => { + store.field = [field()] as any; + }); + const w = mountWs(makeNav()); + await new Promise((r) => setTimeout(r, 0)); + expect(store.loadField).toHaveBeenCalled(); + expect(w.text()).toContain('Окна Комфорт'); + expect(w.text()).toContain('создано проектов'); + }); + + it('сортирует по похожести: 100% сверху', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadField').mockImplementation(async () => { + store.field = [ + field({ id: 1, name: 'Низкая', relevance_pct: 40 }), + field({ id: 2, name: 'Высокая', relevance_pct: 100 }), + ] as any; + }); + const w = mountWs(makeNav()); + await new Promise((r) => setTimeout(r, 0)); + const names = w.findAll('.ld-card__nm').map((n) => n.text()); + expect(names[0]).toContain('Высокая'); + }); + + it('пустое поле показывает заглушку', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadField').mockImplementation(async () => { + store.field = [] as any; + }); + const w = mountWs(makeNav()); + await new Promise((r) => setTimeout(r, 0)); + expect(w.text()).toContain('В поле пока пусто'); + }); + + it('«Собрать конкурентов для меня» открывает окно сбора с ценой', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadField').mockResolvedValue(); + store.prices = { search: '300', study: '50' }; + const w = mountWs(makeNav()); + await new Promise((r) => setTimeout(r, 0)); + const btn = w.findAll('button').find((b) => b.text().includes('Собрать конкурентов для меня')); + await btn!.trigger('click'); + expect(w.find('.ld-ovl').exists()).toBe(true); + expect(w.text()).toContain('Сбор конкурентов'); + expect(w.text()).toContain('300 ₽'); + }); + + it('«Открыть конкурента» открывает карточку', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadField').mockImplementation(async () => { + store.field = [field({ id: 7 })] as any; + }); + const nav = makeNav(); + const w = mountWs(nav); + await new Promise((r) => setTimeout(r, 0)); + const btn = w.findAll('button').find((b) => b.text().includes('Открыть конкурента')); + await btn!.trigger('click'); + expect(nav.ctx.competitorId).toBe(7); + expect(nav.go).toHaveBeenCalledWith('fieldcompetitor'); + }); + + it('всплывающая панель показывается при ≥2 выбранных и массово включает проекты', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadField').mockImplementation(async () => { + store.field = [ + field({ id: 1, sources: [{ id: 10, project: { id: 100, is_active: false } }], counters: { sources: 1, projects_created: 1, projects_in_work: 0 } }), + field({ id: 2, sources: [{ id: 11, project: { id: 101, is_active: false } }], counters: { sources: 1, projects_created: 1, projects_in_work: 0 } }), + ] as any; + }); + const toggleSpy = vi.spyOn(store, 'toggleProjectActive').mockResolvedValue(); + const w = mountWs(makeNav()); + await new Promise((r) => setTimeout(r, 0)); + + const boxes = w.findAll('.ld-pick'); + await boxes[0].trigger('change'); + await boxes[1].trigger('change'); + + expect(w.find('.ld-bulkbar').exists()).toBe(true); + + const btn = w.findAll('.ld-bulkbar button').find((b) => b.text().includes('Включить')); + await btn!.trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + + expect(toggleSpy).toHaveBeenCalledWith(100, true); + expect(toggleSpy).toHaveBeenCalledWith(101, true); + }); +}); diff --git a/app/tests/Frontend/AutopodborFlowSmoke.spec.ts b/app/tests/Frontend/AutopodborFlowSmoke.spec.ts index 593addf2..0b7d36f9 100644 --- a/app/tests/Frontend/AutopodborFlowSmoke.spec.ts +++ b/app/tests/Frontend/AutopodborFlowSmoke.spec.ts @@ -23,12 +23,13 @@ describe('Autopodbor сквозной smoke — все 9 экранов монт setActivePinia(createPinia()); vi.clearAllMocks(); (api.fetchState as any).mockResolvedValue({ enabled: true, prices: { search: '500', study: '300' }, runs: [] }); + (api.fetchField as any).mockResolvedValue([]); (api.fetchRunCompetitors as any).mockResolvedValue([]); (api.fetchCompetitor as any).mockResolvedValue({ competitor: { id: 3, name: 'Окна Комфорт', is_federal: false, relevance_pct: 100, origin: 'auto', site_url: 'okna.ru', directory_urls: [], description: 'd', studied_at: '2026-06-28', study_run_id: 9, search_run_id: 5 }, sources: [] }); (axios.get as any).mockResolvedValue({ data: { data: { id: 99, name: 'Окна Комфорт 🎭', regions: [16], daily_limit_target: 20, delivery_days_mask: 127 } } }); }); - const screens = ['entry', 'autoform', 'manualform', 'loading', 'list', 'detail', 'editproject', 'create', 'done'] as const; + const screens = ['field', 'entry', 'autoform', 'manualform', 'loading', 'list', 'detail', 'editproject', 'create', 'done'] as const; it('все экраны переключаются и монтируются без ошибок', async () => { const w = mount(AutopodborView, { global: { plugins: [vuetify] } }); @@ -55,6 +56,8 @@ describe('Autopodbor сквозной smoke — все 9 экранов монт const w = mount(AutopodborView, { global: { plugins: [vuetify] } }); await new Promise(r => setTimeout(r, 0)); const vm = w.vm as any; + vm.go('entry'); + await nextTick(); await new Promise(r => setTimeout(r, 0)); expect(w.text()).toContain('Подобрать конкурентов'); expect(w.text()).toContain('Указать своего конкурента'); vm.ctx.createdCount = 3; vm.ctx.launched = true; diff --git a/app/tests/Frontend/AutopodborView.spec.ts b/app/tests/Frontend/AutopodborView.spec.ts index 8c8ad02a..ffb59cc4 100644 --- a/app/tests/Frontend/AutopodborView.spec.ts +++ b/app/tests/Frontend/AutopodborView.spec.ts @@ -18,23 +18,23 @@ describe('AutopodborView', () => { setActivePinia(createPinia()); vi.clearAllMocks(); (api.fetchState as any).mockResolvedValue({ enabled: true, prices: { search: '500', study: '300' }, runs: [] }); + (api.fetchField as any).mockResolvedValue([]); }); - it('по умолчанию показывает экран входа с двумя дверями', async () => { + it('по умолчанию показывает рабочее место «Конкурентное поле»', async () => { const w = mountView(); await new Promise((r) => setTimeout(r, 0)); - expect(w.text()).toContain('Подобрать конкурентов'); - expect(w.text()).toContain('Указать своего конкурента'); + expect(w.text()).toContain('Конкурентное поле'); + expect(w.text()).toContain('Собрать конкурентов'); }); - it('клик «Подобрать конкурентов» переключает на форму подбора (autoform)', async () => { + it('«Собрать конкурентов для меня» открывает окно сбора', async () => { const w = mountView(); await new Promise((r) => setTimeout(r, 0)); - // найди кнопку двери подбора и кликни - const btn = w.findAll('button').find((b) => b.text().includes('Подобрать')); + const btn = w.findAll('button').find((b) => b.text().includes('Собрать конкурентов для меня')); expect(btn).toBeTruthy(); await btn!.trigger('click'); - // screen стал autoform: т.к. AutoFormScreen ещё не создан, контент дверей пропал - expect(w.text()).not.toContain('Указать своего конкурента'); + // открылось модальное окно сбора с правилами заполнения + expect(w.text()).toContain('Как заполнить, чтобы результат был точным'); }); }); diff --git a/app/tests/Frontend/autopodborStore.spec.ts b/app/tests/Frontend/autopodborStore.spec.ts index a4721f0c..16204897 100644 --- a/app/tests/Frontend/autopodborStore.spec.ts +++ b/app/tests/Frontend/autopodborStore.spec.ts @@ -70,4 +70,88 @@ describe('autopodborStore', () => { }); expect(res).toHaveLength(1); }); + + // ——— «Конкурентное поле»: рабочее место (два ящика) ——— + + it('loadField кладёт конкурентов поля', async () => { + (api.fetchField as ReturnType).mockResolvedValue([ + { id: 7, name: 'Окна', box: 'field', counters: { sources: 2, projects_created: 1, projects_in_work: 1 }, sources: [] }, + ]); + const s = useAutopodborStore(); + await s.loadField(); + expect(s.field).toHaveLength(1); + expect(s.field[0].counters.projects_in_work).toBe(1); + }); + + it('moveCompetitorToBox в proposal убирает конкурента из поля', async () => { + (api.fetchField as ReturnType).mockResolvedValue([ + { id: 7, name: 'Окна', box: 'field', counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] }, + ]); + (api.setCompetitorBox as ReturnType).mockResolvedValue({ id: 7, box: 'proposal' }); + const s = useAutopodborStore(); + await s.loadField(); + await s.moveCompetitorToBox(7, 'proposal'); + expect(api.setCompetitorBox).toHaveBeenCalledWith(7, 'proposal'); + expect(s.field.find((c) => c.id === 7)).toBeUndefined(); + }); + + it('removeCompetitor убирает конкурента из поля', async () => { + (api.fetchField as ReturnType).mockResolvedValue([ + { id: 7, name: 'Окна', box: 'field', counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] }, + ]); + (api.deleteCompetitor as ReturnType).mockResolvedValue(undefined); + const s = useAutopodborStore(); + await s.loadField(); + await s.removeCompetitor(7); + expect(api.deleteCompetitor).toHaveBeenCalledWith(7); + expect(s.field).toHaveLength(0); + }); + + it('editCompetitor обновляет поля конкурента на месте', async () => { + (api.fetchField as ReturnType).mockResolvedValue([ + { id: 7, name: 'Старое', box: 'field', counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] }, + ]); + (api.updateCompetitor as ReturnType).mockResolvedValue({ id: 7, name: 'Новое', relevance_pct: 88 }); + const s = useAutopodborStore(); + await s.loadField(); + await s.editCompetitor(7, { name: 'Новое', relevance_pct: 88 }); + expect(s.field[0].name).toBe('Новое'); + expect(s.field[0].relevance_pct).toBe(88); + }); + + it('addFieldCompetitor добавляет нового конкурента в поле', async () => { + (api.createManualCompetitor as ReturnType).mockResolvedValue({ + id: 99, name: 'Ромашка', box: 'field', origin: 'manual', + }); + const s = useAutopodborStore(); + const c = await s.addFieldCompetitor({ name: 'Ромашка' }); + expect(c.id).toBe(99); + expect(s.field.find((x) => x.id === 99)?.name).toBe('Ромашка'); + }); + + it('changeProjectSource зовёт ручку проектов и возвращает сообщение', async () => { + (api.changeProjectSource as ReturnType).mockResolvedValue({ + source_change_message: 'Лиды по старому источнику придут до 30.06, дальше — по новому.', + }); + const s = useAutopodborStore(); + const res = await s.changeProjectSource(100, 'new.ru'); + expect(api.changeProjectSource).toHaveBeenCalledWith(100, 'new.ru'); + expect(res.source_change_message).toContain('по новому'); + }); + + it('removeSource убирает источник из карточки конкурента в поле', async () => { + (api.fetchField as ReturnType).mockResolvedValue([ + { + id: 7, name: 'Окна', box: 'field', + counters: { sources: 1, projects_created: 0, projects_in_work: 0 }, + sources: [{ id: 50, competitor_id: 7, signal_type: 'site', identifier: 'a.ru', box: 'field', project: null }], + }, + ]); + (api.deleteSource as ReturnType).mockResolvedValue(undefined); + const s = useAutopodborStore(); + await s.loadField(); + await s.removeSource(7, 50); + expect(api.deleteSource).toHaveBeenCalledWith(50); + expect(s.field[0].sources).toHaveLength(0); + }); }); From 47a9ab49b449fd62e85ef50e36572576b3cf745f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 04:20:25 +0300 Subject: [PATCH 045/417] =?UTF-8?q?docs(=D0=9A=D0=BE=D0=BD=D0=BA=D1=83?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D1=82=D0=BD=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5):=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D1=82=D0=B8=D0=BF,=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8,=20=D1=8D=D1=81=D1=82=D0=B0=D1=84?= =?UTF-8?q?=D0=B5=D1=82=D0=B0=20=D0=B8=20Playwright-=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20=D1=81=20=D0=9E=D0=BC=D0=B5=D0=B3=D0=BE?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HANDOFF (состояние/решения/окружение), impl-plan (фазы+догрузки), кликабельный прототип 2026-06-29-konkurentnoe-pole-proto.html (визуал-эталон), omega-visual-check (живая сверка рабочего места с реальными конкурентами Омеги, скрины FIELD-*/PROTO-*). Co-Authored-By: Claude Opus 4.8 --- ...29-konkurentnoe-pole-omega-visual-check.md | 96 +++ .../2026-06-29-konkurentnoe-pole-HANDOFF.md | 213 ++++++ .../2026-06-29-konkurentnoe-pole-impl-plan.md | 104 +++ .../2026-06-29-konkurentnoe-pole-proto.html | 631 ++++++++++++++++++ 4 files changed, 1044 insertions(+) create mode 100644 docs/superpowers/findings/2026-06-29-konkurentnoe-pole-omega-visual-check.md create mode 100644 docs/superpowers/plans/2026-06-29-konkurentnoe-pole-HANDOFF.md create mode 100644 docs/superpowers/plans/2026-06-29-konkurentnoe-pole-impl-plan.md create mode 100644 docs/superpowers/prototypes/2026-06-29-konkurentnoe-pole-proto.html diff --git a/docs/superpowers/findings/2026-06-29-konkurentnoe-pole-omega-visual-check.md b/docs/superpowers/findings/2026-06-29-konkurentnoe-pole-omega-visual-check.md new file mode 100644 index 00000000..1a1b3b73 --- /dev/null +++ b/docs/superpowers/findings/2026-06-29-konkurentnoe-pole-omega-visual-check.md @@ -0,0 +1,96 @@ +# Визуальная проверка «Конкурентного поля» глазами клиента (Омега) — 29.06.2026 + +Прогон живого приложения через Playwright: вошёл как свежий клиент Омега, прошёл рабочее +место «Конкурентное поле», сверил с зафиксированной реальностью Омеги (прогоны движка 28–29.06). + +> **Важно про данные.** Движок изучения (шаг 2) — пока заглушка (`FakeCompetitorAgent`), поэтому +> поле наполнено **реальными конкурентами Омеги из зафиксированных прогонов движка 28–29.06** +> (сид `OmegaDemoFieldSeeder`), а не выдумано и не «найдено» заглушкой. Это честная демонстрация +> рабочего места на настоящих данных рынка Омеги. Живой ИИ-движок подключается отдельно (шаг 2), +> сменой строки в `AutopodborServiceProvider` — UI/данные при этом не меняются. + +## Как проверял +- Свежий тенант «Омега (демо)», вход `omega-demo@liderra.local` / `omega12345`, баланс 50 000 ₽. +- Сборка фронта (`vite build`) + `php artisan serve` на :8001 + Playwright (реальный браузер). +- Фича включена флагом `autopodbor_enabled`, тарифы 300/50. + +## Что показал интерфейс (скриншоты в корне репо) + +| Скриншот | Экран | Что видно | +|---|---|---| +| `FIELD-1-workspace-omega.png` | Поле | 5 конкурентов в поле, сорт по похожести, счётчики, метки «федеральный» | +| `FIELD-2-competitor-card-omega.png` | Карточка КрасЛомбард | источники: сайт + телефон со значком ✓ и типом «городской», провенанс, кнопки | +| `FIELD-3-edit-source-locked-type.png` | Изменить источник | «🔒 Тип источника не меняется (телефон)» — тип залочен | +| `FIELD-4-proposals-omega.png` | Предложения | 5 найденных конкурентов с «В поле» | +| `FIELD-5-billing-dop-uslugi.png` | Биллинг | «Дополнительные услуги»: Сбор конкурентов 300 ₽ / Сбор источников 50 ₽ | +| `FIELD-6-workspace-with-projects.png` | Поле (с проектами) | КрасЛомбард «создано проектов: 1 · в работе: 1», Голд «1 · 0» (на паузе); заголовок «Конкурентное поле» | +| `FIELD-7-smenit-istochnik-dialog.png` | Сменить источник? | у источника «в работе» — окно с залоченным типом и живым адресом проекта | +| `FIELD-8-smenit-confirm.png` | Сменить источник? (подтверждение) | текст «…смена источника проекта. Старые лиды дойдут, дальше — по новому…» + «Подтвердить смену» | +| `FIELD-9-card-live-project.png` | Карточка (после смены) | источник стал `kraslombard-new.ru`, статус «в работе» | + +## Сквозной контрольный путь, проверенный ВЖИВУЮ (не только рендер) +- Создание проекта из источника → счётчики обновились: КрасЛомбард «в работе: 1». +- Пауза/возобновление проекта прямо с карточки источника (☑ → «на паузе» → «Возобновить») — работает. +- **Смена источника** у живого проекта: ввёл новый адрес → подтверждение «Сменить источник?» с + правилом §14.10 → реальный `PATCH /api/projects/{id}` (тот же гвард слепка, что у проектов) → + карточка показала новый источник. Тип всё время залочен. + +## Сверка с реальностью Омеги (записано в `2026-06-28-autopodbor-konkurentov-brainstorm-notes.md` §9–§12 + RESULT/LIVE-omega.html) + +**Вход Омеги (зафиксировано):** ниша — займы под залог авто/спецтехники, регион Красноярский край, +сайт lkomega.ru, примеры: automoney-krsk.ru, вашзаем.рф, vashinvestor.ru, carmoney.ru. + +**Конкуренты, которых движок реально нашёл (зафиксировано):** КрасЛомбард (kraslombard24.ru), +Голд Авто Инвест (goldautoinvest.ru), Финео (fineo24.ru), Cashmotor (cashmotor.ru), +Локо-Банк (lockobank.ru), Автоломбард Экспресс, Caranga, Драйвзайм, Залог24, Кредди и др. +(всего 22–67 по версиям прогона; 27 из них Омега уже «на прозвоне» — Канал 0). + +**Что показывает рабочее место (сверка 1:1):** + +| Конкурент Омеги (реальность) | В поле | Похожесть | Тип | Источники | +|---|---|---|---|---| +| КрасЛомбард (kraslombard24.ru) | ✅ | 95% | региональный | сайт + ☎ городской ✓ | +| Голд Авто Инвест (goldautoinvest.ru) | ✅ | 90% | региональный | сайт + 2 телефона (подменный 🎭 + мобильный ✓) | +| Финео (fineo24.ru) | ✅ | 80% | федеральный | сайт + ☎ 8-800 | +| Cashmotor (cashmotor.ru) | ✅ | 78% | федеральный | сайт | +| Локо-Банк (lockobank.ru) | ✅ | 72% | федеральный | сайт | +| Автоломбард Экспресс, Caranga, Драйвзайм, Залог24, Кредди | в «Предложениях» | 85–55% | смешанно | — | + +**Вывод сверки:** рабочее место отображает РОВНО тех конкурентов, которых движок нашёл для Омеги, +с правильными признаками (региональный/федеральный, похожесть, тип телефона городской/мобильный/8-800, +настоящий/подменный). Деление «в поле / предложения» работает; счётчики (источников / проектов / +в работе) считаются; правила (тип источника неизменяем, удаление/смена под гвардами) — на месте. + +## Контрольный клиентский путь (пройдено вживую) +Вход → меню «Конкурентное поле NEW» → поле с конкурентами → карточка КрасЛомбард → источники +(сайт + телефон) → «Изменить источник» (тип залочен) → назад → вкладка «Предложения» → Биллинг +с доп.услугами. Всё открывается, данные настоящие, ошибок рантайма нет. + +## ОБНОВЛЕНИЕ 30.06 — фронт доведён ДО ПРОТОТИПА (копирайт + функционал 1:1) +По замечанию владельца («мы проектировали и дизайн, и главное функционал») живые экраны приведены +к прототипу `2026-06-29-konkurentnoe-pole-proto.html` дословно. Скрины `PROTO-1..3-*.png`: +- **Поле:** подзаголовок «Ваши конкуренты: N · проектов в работе: M. Поле ведёте вы — Лидерра подсказывает»; + баннер-черновик «Лидерра подготовила N в предложениях»; на карточке — Сайт, **Справочник: 2ГИС · Яндекс.Карты**, + «источников/создано проектов/[N в работе]», ссылки «Изменить/Удалить» + «Открыть конкурента →». Сетка 2 колонки. +- **Карточка конкурента:** подзаголовок с похожестью; баннер «Лидерра нашла N источника»; **баннер правил + времени (18:00 МСК)**; источник показывает «откуда:», «проект: ▶ активен · 20/день · 0 заявок». +- **Окно «Собрать источники»:** правила «Чтобы нашлось как можно больше», «Что известно о конкуренте сейчас: + Сайт · 2ГИС: есть · Яндекс: есть», ценник **50 ₽** «спишется только если найдём». +- Полные формы: «Добавить конкурента» (Тип/Похожесть/2ГИС/Яндекс/Описание), «Собрать конкурентов» (направление/ + регион/примеры/федералы/300 ₽), «Создать проект» и «Настройки проекта» (регион/лимит/дни Пн–Вс + слепок), + «Изменить источник» (тип залочен) и «Сменить источник?» (подтверждение). Общий стиль — `field-shared.css` (Forest). +- Бэкенд под это: `projectStatus()` += `daily_limit_target/delivered_in_month/delivery_days_mask/regions`; + `manualCompetitor` принимает `directory_urls` (2ГИС+Яндекс); новый `GET /proposals`; store-действия + `loadProposals/updateProjectSettings`. Автоподбор: backend **80/80**, фронт **49/49** зелёные; `vite build` ✅; eslint чист. + +## Честные оговорки (не скрываю) +1. **Движок шага 2 — заглушка.** Поле наполнено сидом из реальных прогонов, а не «собрано» вживую + кнопкой. Живой ИИ-движок — отдельный шаг (розетка готова, меняется одной строкой + в `AutopodborServiceProvider`). Кнопки «Собрать конкурентов/источники» сейчас дернут заглушку. +2. **Демо-данные в dev-БД.** Создан тенант «Омега (демо)» в dev-базе `liderra` (+ 2 демо-проекта). + На прод не влияет (прод — отдельный кластер), сид идемпотентный. Прод НЕ трогал, **ничего не коммитил**. +3. **Массовые действия «в работе»** (Приостановить/Возобновить/Удалить выбранных) — построены и + покрыты тестом; вживую через Playwright прокликал одиночные + смену источника, массовые — по спекам. + +(Ранее открытые хвосты — создание проекта из источника, пауза/возобновление, полноценное окно +«Сменить источник?» — ЗАКРЫТЫ и проверены вживую, см. раздел «Сквозной контрольный путь» выше.) diff --git a/docs/superpowers/plans/2026-06-29-konkurentnoe-pole-HANDOFF.md b/docs/superpowers/plans/2026-06-29-konkurentnoe-pole-HANDOFF.md new file mode 100644 index 00000000..e22b1aca --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-konkurentnoe-pole-HANDOFF.md @@ -0,0 +1,213 @@ +# ПЕРЕДАЧА: «Конкурентное поле» — состояние сборки на 29.06.2026 + +Документ-эстафета для следующей сессии. Вверху — **готовый промт** (скопировать целиком в новую +сессию). Ниже — состояние, решения, окружение, что дальше. + +--- + +## 📋 ПРОМТ ДЛЯ СЛЕДУЮЩЕЙ СЕССИИ (скопировать как есть) + +``` +Продолжаем сборку фичи «Конкурентное поле» на dev (НЕ прод, без коммитов без моего «эскейп»). +Прежде чем что-то делать — подними контекст, прочитай по порядку и ничего не упусти: + +1. docs/superpowers/plans/2026-06-29-konkurentnoe-pole-HANDOFF.md ← этот файл: состояние, решения, окружение, что дальше +2. docs/superpowers/plans/2026-06-29-konkurentnoe-pole-impl-plan.md ← план по фазам + §3bis (7 догрузок после сверки) +3. docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-brainstorm-notes.md §14 (14.0–14.11) ← дизайн: два ящика, потоки, вход агента, правила времени/блокировок/смены источника/удаления, тарифы +4. docs/superpowers/prototypes/2026-06-29-konkurentnoe-pole-proto.html ← кликабельный визуал-эталон (поднять: node docs/superpowers/prototypes/serve.js → localhost:8123) + +Где строим: воркстри `.claude/worktrees/avtopodbor` (ветка worktree-avtopodbor), код под app/. +Бэкенд автоподбора уже построен прошлой сессией и переиспользуется (3 таблицы+RLS, розетка +CompetitorAgent+FakeCompetitorAgent, сервисы Run/Charge/ProjectCreator/Normalizer/Dedup, 10 ручек, +9 Vue-визард-экранов, 53 теста). Фаза 1 (слой данных) уже СДЕЛАНА и зелёная — см. HANDOFF. + +Качество — обязательно: TDD (тест→падение→код), laravel-backend-patterns при бэкенде, +rls-reviewer на миграциях, regression на проверку. Прогон тестов: + cd .claude/worktrees/avtopodbor/app + DB_DATABASE=liderra_testing php artisan migrate --force + php vendor/bin/pest tests/Feature/Autopodbor tests/Unit/Autopodbor (должно быть зелёно) +Тесты с записью через pgsql_supplier ОБЯЗАНЫ uses(SharesSupplierPdo::class) + SET LOCAL app.current_tenant_id. + +Прод не трогать. Коммит/пуш — только когда я скажу «эскейп». Шаг 2 (движок изучения) — пока +заглушка FakeCompetitorAgent, доводим позже. Говори простым русским, без жаргона. +Начни с Фазы 2 (API: переключение ящиков, /field со счётчиками, правка/удаление конкурентов и +источников, увязка смены источника с change_source проекта) — по TDD. +``` + +--- + +## ✅ ЧТО СДЕЛАНО (Фаза 1 — слой данных, проверено) + +По TDD, всё в воркстри `.claude/worktrees/avtopodbor/app`. **53/53 теста зелёные** (Feature+Unit +автоподбора, 164 проверки), Pint прогнан. НЕ закоммичено. + +**Два ящика (предложение / в поле):** +- Миграция `database/migrations/2026_06_29_120000_add_box_to_autopodbor.php` — колонка + `box VARCHAR(16) NOT NULL DEFAULT 'proposal'` CHECK in ('proposal','field') на + `autopodbor_competitors` и `autopodbor_sources` + индексы (tenant_id,box) / (competitor_id,box). +- Модели `AutopodborCompetitor`, `AutopodborSource` — `box` в fillable. +- Тест `tests/Feature/Autopodbor/AutopodborBoxStateTest.php` (дефолт proposal, перевод в field, CHECK). + +**Тип номера (городской/мобильный/8-800) РЯДОМ с меткой коллтрекинга (✓/🎭):** +- РЕШЕНИЕ владельца: вариант C — показываем И тип номера, И настоящий/подменный. phone_kind + (real/substitute, ✓/🎭) НЕ трогали; ДОБАВИЛИ phone_type. +- Миграция `2026_06_29_120100_add_phone_type_to_autopodbor_sources.php` — `phone_type VARCHAR(12)` + NULL, CHECK (NULL OR in ('city','mobile','tollfree')). Для сайта = NULL. +- `AutopodborSource` fillable += `phone_type`. DTO `StudyCompetitorResult` phpdoc += phone_type. +- `FakeCompetitorAgent::studyCompetitor` отдаёт phone_type (city/mobile) у телефонов. +- `RunAutopodborStudyJob` сохраняет phone_type. +- Тесты `tests/Feature/Autopodbor/AutopodborPhoneTypeTest.php` (новый) + проверка в + `RunAutopodborStudyJobTest.php`. + +## ✅ ЧТО СДЕЛАНО (Фаза 2 — API, по TDD, проверено) + +Все эндпоинты в `AutopodborController` + маршруты в `routes/web.php` (prefix `/api/autopodbor`, +middleware `auth:sanctum`+`tenant`). **76/76 автоподбор-тестов зелёные** (53 Фаза 1 + 23 новых), +Pint прогнан. НЕ закоммичено. Новые тест-файлы: `AutopodborBoxApiTest`, `AutopodborFieldApiTest`, +`AutopodborCompetitorCrudApiTest`, `AutopodborSourceCrudApiTest`, `AutopodborManualCompetitorApiTest`. + +- `PATCH /competitors/{id}/box` и `PATCH /sources/{id}/box` — перенос предложение↔поле (валидация in:proposal,field; tenant-изоляция 404). +- `GET /field` — конкуренты с `box='field'` (сорт по похожести), их источники `box='field'` со + статусом проекта (`is_active/paused_at/preflight_blocked_at`) + счётчики на карточке: + `sources` / `projects_created` / `projects_in_work` (в работе = проект активен И не заблокирован по балансу). +- `PATCH /competitors/{id}` — правка имени/описания/федерал/похожести(0..100)/сайта/справочников/box. +- `DELETE /competitors/{id}` — каскад на источники; блок 409 `has_active_projects`, если у источника активный проект. +- `PATCH /sources/{id}` — правка значения/провенанса/box; `signal_type` неизменяем (молча игнорим); + смена `identifier` при активном проекте → 409 `manage_via_project` (это смена источника проекта, §14.10). +- `DELETE /sources/{id}` — блок 409 `has_active_project` при активном проекте, иначе 204. +- `POST /competitors/manual` — завести конкурента сразу в поле (`box='field'`, `origin='manual'`, без study). +- В ресурсах добавлены `box` (оба) и `phone_type` (source). + +**Осталось из Фазы 2 на фронт (Фаза 4):** увязка «Изменить источник» с проектом — фронт зовёт +`PATCH /api/projects/{id}` (change_source, окно «Сменить источник?»); бэкенд-гвард `manage_via_project` уже стоит. + +## ✅ ЧТО СДЕЛАНО (Фаза 3 — тарифы доп. услуг, по TDD) + +- Дефолты цен проставлены миграцией `2026_06_29_130000_set_autopodbor_default_prices.php`: + `autopodbor_price_search_rub=300`, `autopodbor_price_study_rub=50` — идемпотентно, только если + значение всё ещё `'0'` (не затирает правки админа). Тест `AutopodborPriceDefaultsTest`. +- **Админ-эндпоинт правки тарифов отдельный НЕ нужен** — уже есть общий + `PUT /api/admin/system-settings/{key}` (`AdminSystemSettingsController`): reason ≥30, audit-log, + type-validation decimal. Ключи `autopodbor_price_*` подходят под него 1:1. Фронт-админка (Фаза 4) + зовёт его. Цены для клиента уже отдаёт `GET /api/autopodbor/state` (`prices.search`/`prices.study`). +- Сетка тарифа ЛИДОВ — НЕ трогали (живёт в `pricing_tiers`, на проде свои значения, §14.11). + +Итого автоподбор-тестов: **77/77 зелёные** (Фаза 1: 53, Фаза 2: 23, Фаза 3: 1), Pint чистый. + +## 🔨 ФАЗА 4 — ФРОНТ (в работе) + +**Слой данных готов и протестирован (Vitest):** +- `resources/js/api/autopodbor.ts` — DTO `CompetitorDto`+`box`, `SourceDto`+`box`/`phone_type`, + новые `FieldCompetitorDto`/`FieldSourceDto`/`SourceProjectDto`; функции `fetchField`, + `setCompetitorBox`, `setSourceBox`, `updateCompetitor`, `deleteCompetitor`, `updateSource`, + `deleteSource`, `createManualCompetitor`. +- `resources/js/stores/autopodborStore.ts` — стейт `field` + действия `loadField`, + `moveCompetitorToBox`, `editCompetitor`, `removeCompetitor`, `addFieldCompetitor`, + `moveSourceToBox`, `editSource`, `removeSource`. Оптимистичные обновления списка. +- Тесты: `tests/Frontend/autopodborStore.spec.ts` (+6) — 11/11; весь автоподбор-фронт 29/29; eslint чист. + +**Сделано (Vue):** +- `screens/FieldWorkspaceScreen.vue` — рабочее место «В поле»: карточки конкурентов со + счётчиками/типом/похожестью, авто-сортировка по похожести, мультивыбор + «Выбрать всех» + + ВСПЛЫВАЮЩАЯ нижняя панель (≥2): «Выключить все проекты» / «Включить все созданные проекты» + (через `store.toggleProjectActive` → готовая ручка проектов со всеми гвардами). Forest-стиль. +- `AutopodborView.vue` — новый дефолт-экран `field` (старый визард-`entry` остаётся доп-входом). +- Пункт меню переименован «Автоподбор конкурентов» → **«Конкурентное поле»** (`AppSidebar.vue`). +- Спеки: `AutopodborFieldWorkspaceScreen.spec.ts` (6), правки `AutopodborView.spec`/`AutopodborFlowSmoke` + под новый дефолт. Весь автоподбор-фронт **35/35 зелёный**, eslint новых .ts чист (в .vue остаётся + пред-существующий `any`-долг визарда — снимется при ребейзе на main). + +- `screens/FieldCompetitorScreen.vue` — карточка конкурента (экран `fieldcompetitor`): вкладки + «Источники в работе / Предложения»; на источнике в работе — Приостановить/Возобновить + (`toggleProjectActive`), «Настройки проекта» (→ `editproject`), «Создать проект» (→ `create`), + «Изменить источник» (окно, тип залочен; при активном проекте — подсказка «смена = смена источника + проекта»), «Удалить» (гвард 409 → дружелюбный текст); вкладка «Предложения» — «В работу» + (`moveSourceToBox`); значок телефона ✓/🎭 + тип городской/мобильный/8-800; всплывающая панель ≥2. +- Бэкенд: `GET /competitors/{id}` обогащён — у каждого источника `box` + `project` (статус), helper + `projectStatus()` (тест `AutopodborCompetitorSourcesApiTest`). `fetchCompetitor`/`store.sources` → `FieldSourceDto`. +- Спеки: `AutopodborFieldCompetitorScreen.spec.ts` (6). Бэкенд автоподбор **78/78**, фронт автоподбор **41/41**. + +**Сделано (доп. экраны + бэкенд + Биллинг):** +- `screens/FieldProposalsScreen.vue` (`field-proposals`) — конкуренты-предложения, сорт по похожести, + «В поле» поштучно + «Перенести выбранных в поле» (≥2). Бэкенд `GET /api/autopodbor/proposals` + (тест `AutopodborProposalsApiTest`). store `loadProposals`. +- `screens/FieldManualCompetitorScreen.vue` (`field-manual-competitor`) — форма «Добавить вручную» + (`addFieldCompetitor`), сразу открывает карточку нового конкурента. +- Карточка конкурента: «Собрать источники» (inline study+poll) и «+ Добавить источник вручную» + (inline-модалка `addSource`) — без отдельных экранов. +- Биллинг: `components/billing/AutopodborServicesPanel.vue` — карточка «Дополнительные услуги» + (Сбор конкурентов 300 ₽ / Сбор источников 50 ₽), встроена в `BillingView.vue`. +- Пункт меню и заголовок маршрута → «Конкурентное поле» (`AppSidebar.vue` + `router/index.ts`). +- Спеки: `AutopodborFieldProposalsScreen.spec.ts` (3), `AutopodborFieldManualCompetitorScreen.spec.ts` (2). + +**Визуальная проверка (Playwright, живое приложение) — ✅ см. `docs/superpowers/findings/2026-06-29-konkurentnoe-pole-omega-visual-check.md`** +Свежий тенант Омега (`omega-demo@liderra.local`/`omega12345`, сид `OmegaDemoFieldSeeder`), `vite build` ++ `php artisan serve :8001`. Прошёл клиентский путь, наполнил поле РЕАЛЬНЫМИ конкурентами Омеги из +прогонов 28-29.06 (КрасЛомбард/Голд Авто Инвест/Финео/Cashmotor/Локо-Банк + предложения). Скрины +`FIELD-1..5-*.png` в корне репо. Сверка 1:1 с зафиксированной реальностью — совпадает. + +**ИТОГ ТЕСТОВ:** backend autopodbor **80/80** (+ billing/projects 209/209 без регрессий); frontend +autopodbor+billing — все зелёные; полный фронт 1015 passed / 22 failed (22 — пред-существующие +протухшие НЕ-автоподбор спеки, снимутся ребейзом на main). `vite build` ✅. Новые экраны eslint-чистые. + +**Хвосты ЗАКРЫТЫ (29.06, проверено вживую через Playwright):** +- ✅ Полноценное окно «Сменить источник?» — `changeProjectSource` → реальный `PATCH /api/projects/{id}` + (change_source, гвард слепка §14.10), с подтверждением и сообщением о сроках; тип залочен. + Источник-карточка показывает живой источник проекта (`project.signal_identifier`). +- ✅ Сквозное «Создать проект из источника» (`CreateScreen`) — счётчики «создано проектов / в работе» обновляются. +- ✅ Пауза/возобновление с карточки источника (`toggleProjectActive`). +- ✅ Массовые действия «в работе» (Приостановить/Возобновить/Удалить выбранных) — построены + тест. +- Бэкенд: `projectStatus()` += `signal_identifier`. Новые store-действия `loadProposals`, + `changeProjectSource`. Скрины `FIELD-6..9-*.png`. + +**ИТОГ (после хвостов):** backend **209/209**; frontend autopodbor+billing — всё зелёное (полный +фронт 1018 passed / 22 pre-existing non-autopodbor fail / 3 skip); `vite build` ✅; eslint новых .ts/.vue чист. + +**Осталось (НЕ блок):** +- Реальный ИИ-движок (шаг 2) — отдельно, сменой binding в `AutopodborServiceProvider`. +- Демо-сид `OmegaDemoFieldSeeder` — только для визуальной проверки (НЕ катить на прод). +- Перед коммитом (по «эскейп»): canon-sync `db/schema.sql` + CHANGELOG (v8.59) + rls-reviewer + larastan. + +⚠️ **Тест-стенд фронта на этой ветке:** ~22 пред-существующих протухших спека (Settings/Deal/Legal/ +Kanban/Admin/Projects/Error/ChangePassword — НЕ автоподбор), зелёный-фикс лежит на main (gitea +`84dfbc85`) и подтянется при ребейзе (Фаза 6). Автоподбор-спеки — все зелёные. + +## ⏳ ХВОСТ ПЕРЕД КОММИТОМ (не блокирует разработку, сделать до «эскейп») +- Canon-sync `db/schema.sql` под 3 новые миграции (box, phone_type, дефолты цен) + запись в + `db/CHANGELOG_schema.md` (v8.58 → v8.59). NB: `set_autopodbor_default_prices` — данные, не DDL. +- `rls-reviewer` на новые миграции (колонки под существующей tenant_isolation, новых таблиц нет). +- larastan по проекту (новые поля/методы контроллера). Pint уже зелёный. + +## 🔒 РЕШЕНИЯ (закрыты, не переоткрывать) +- Два ящика на обоих уровнях (конкуренты + источники). Approach A: state-флаг на существующих таблицах. +- Цены доп.услуг: **сбор конкурентов 300 ₽, сбор источников 50 ₽**, списание только при успехе, + создание проектов бесплатно. (В коде ставятся в system_settings autopodbor_price_search_rub/_study_rub.) +- Телефон: тип номера (city/mobile/tollfree) + метка коллтрекинга (real/substitute, ✓/🎭) — оба. +- Тип источника (signal_type сайт/звонок) НЕизменяем; смена адреса/номера у источника с проектом = + change_source проекта (реальный гвард §14.10, окно «Сменить источник?»). +- Система универсальна для всех клиентов (омега — лишь тестовый). +- Сетка тарифа ЛИДОВ — НЕ трогаем; на проде свои значения (берём из боевого pricing_tiers, §14.11). + +## 🧭 ЧТО ДАЛЬШЕ (фазы 2–6) — детали в impl-plan.md, догрузки в §3bis +- **Фаза 2 — API:** PATCH /competitors/{id} (все поля + box), DELETE /competitors/{id}; + PATCH/DELETE /sources/{id} (правка значения/box, удаление с гвардом активного проекта); + GET /field (конкуренты+источники box=field + счётчики: источников/создано проектов/N в работе); + «+ Добавить вручную» конкурента сразу в поле без study; управление проектом из источника — через + существующие /api/projects/* (toggle-active, PATCH, DELETE — там все гварды §14.9/14.10). +- **Фаза 3 — Тарифы:** дефолты 300/50 + админ-эндпоинт правки autopodbor_price_*; Биллинг показывает + доп.услуги; сетка лидов — из боевого pricing_tiers. +- **Фаза 4 — Фронт:** пересборка визард-экранов в рабочее место «Конкурентное поле» по прототипу + (два ящика, всплывающие массовые действия ≥2, «Изменить источник» с залоченным типом, «Сменить + источник?», биллинг+админка). Reuse api/autopodbor.ts + Pinia store. +- **Фаза 5 — Тесты + регрессия** (/regression quick по ходу, full перед прод-сборкой). +- **Фаза 6 — Сборка к проду (по «эскейп»):** ⚠️ ветка разошлась с main (21/769) — ребейз/мёрдж на + актуальный main, sync схемы, prod-deploy-validator GO/NO-GO, штатный деплой. Реальный движок + (RealCompetitorAgent) — отдельно, сменой binding в AutopodborServiceProvider. + +## ⚙️ ОКРУЖЕНИЕ (Windows, грабли) +- Тест-БД `liderra_testing` (phpunit.xml). Перед прогоном после новых миграций: + `DB_DATABASE=liderra_testing php artisan migrate --force`. +- Запись в тестах через pgsql_supplier → `uses(SharesSupplierPdo::class)` + `SET LOCAL app.current_tenant_id = `. +- vendor есть в воркстри; node_modules может быть junction. Pint: `php vendor/bin/pint --dirty`. +- Прототип-сервер с no-cache: `node docs/superpowers/prototypes/serve.js` (или из scratchpad). +- ⚠️ scratchpad самочистится — durable-копии всего в docs/superpowers/. diff --git a/docs/superpowers/plans/2026-06-29-konkurentnoe-pole-impl-plan.md b/docs/superpowers/plans/2026-06-29-konkurentnoe-pole-impl-plan.md new file mode 100644 index 00000000..f4b329cb --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-konkurentnoe-pole-impl-plan.md @@ -0,0 +1,104 @@ +# План реализации «Конкурентное поле» (на dev, поверх worktree-avtopodbor) + +**Дата:** 29.06.2026. **Ветка сборки:** `worktree-avtopodbor` (`.claude/worktrees/avtopodbor`), чистая. +**Дизайн-источник:** прототип `docs/superpowers/prototypes/2026-06-29-konkurentnoe-pole-proto.html` + заметки +`docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-brainstorm-notes.md` §14 (14.0–14.11). +**Правила:** прод НЕ трогаю; коммит/пуш только по «эскейп»; тест-БД, не прод; шаг 2 (движок study) — +заглушка `FakeCompetitorAgent`, реальный ИИ-движок позже сменой binding. + +## 0. Что переиспользуем КАК ЕСТЬ (готово в воркстри) +3 таблицы + RLS (`autopodbor_runs/competitors/sources`, схема v8.58); розетка `CompetitorAgent` + 6 DTO + +`FakeCompetitorAgent` + `AutopodborServiceProvider`; сервисы `AutopodborRunService`, `AutopodborChargeService` +(bcmath, идемпотентно), `AutopodborProjectCreator`, `AutopodborNormalizer`, `AutopodborDedup`; 3 джоба +(Search/Study/Resolve); 10 API-эндпоинтов; цены в `system_settings` +(`autopodbor_price_search_rub`/`_study_rub`); `balance_transactions.type='autopodbor_charge'`; 30+ тестов. +ВАЖНО: фича в проде НЕ стоит (только воркстри), что бы ни говорили старые отчёты. + +## 1. Чего НЕ хватает (дельта под реврейм «поле») +Воркстри сделал **визард** (entry→форма→загрузка→список→карточка→создание→готово). Реврейм — **постоянное +рабочее место** с двумя ящиками и управлением проектами из карточек источника. Бэк дорабатываем точечно, +фронт пересобираем под прототип. + +## 2. Фазы (последовательно, каждая с тестами) + +### Фаза 1 — Данные: два ящика (state-флаг, Approach A из §14.1) +- Миграция: `autopodbor_competitors` += `box VARCHAR(16) NOT NULL DEFAULT 'proposal'` CHECK in ('proposal','field'). +- Миграция: `autopodbor_sources` += `box VARCHAR(16) NOT NULL DEFAULT 'proposal'` CHECK in ('proposal','field'). + (archived — позже, не сейчас.) +- RLS уже есть (`tenant_isolation`) — новые колонки под ней; запись в `db/CHANGELOG_schema.md`; прогон `rls-reviewer`. +- Модели: добавить `box` в fillable/casts. Тесты: схема + дефолты + переход proposal→field. + +### Фаза 2 — API: состояние + поле + биллинг-данные +- `PATCH /api/autopodbor/competitors/{id}/box` — proposal↔field (tenant-isolated, тест изоляции). +- `PATCH /api/autopodbor/sources/{id}/box` — proposal↔field. +- `GET /api/autopodbor/field` — конкуренты и источники с `box='field'` + по каждому источнику статус его + проекта (join `projects`: is_active/paused_at/preflight_blocked_at, лимит, доставлено). +- Управление проектом из карточки источника — **переиспользуем существующие** `/api/projects/*` + (`toggle-active`, `PATCH`, `DELETE`): там уже все гварды (слепок, баланс, сделки), которые мы изучили + (§14.9/14.10). Новый бэкенд тут не пишем — фронт зовёт готовые ручки по `source.created_project_id`. +- Цены доп.услуг для UI — уже в `/state`; добавить отдачу обоих значений. + +### Фаза 3 — Тарифы доп.услуг + админка +- Дефолты цен: search=**300 ₽**, study=**50 ₽** (миграция-сид меняет '0'→'300'/'50' ИЛИ ставится в админке). +- Эндпоинт админки на правку `autopodbor_price_search_rub`/`_study_rub` (если нет — добавить, admin-guard). +- ⚠️ Сетка тарифа ЛИДОВ — НЕ трогаем (живёт в `pricing_tiers`, на проде свои значения — §14.11). + +### Фаза 4 — Фронт: пересборка визарда → «Конкурентное поле» (по прототипу) +Переиспользуем `api/autopodbor.ts` + Pinia store, добавляем новые ручки. Экраны под прототип: +- Поле: вкладки «В поле / Предложения» (конкуренты), карточки с типом/похожестью/счётчиками, выбор+массовые + «Выключить/Включить все проекты», авто-сортировка по похожести. +- Карточка конкурента: «Источники в работе / Предложения», на источнике — Приостановить/Возобновить, + «Настройки проекта», «Создать проект», «Изменить источник» (тип залочен, смена адреса у проекта в сборе → + «Сменить источник?»), «Удалить» (гвард слепка); выбор + всплывающие массовые действия (≥2); + «без проектов» → массовое «Создать проекты». +- Окна сбора: шаг 1 (300 ₽) и шаг 2 (50 ₽), оплата только при успехе. +- Биллинг: сетка лидов (из боевого тарифа) + «Дополнительные услуги» (300/50). +- Все тексты/правила времени — дословно из §14.9–14.11; «Лидерра», не «робот»; без жаргона. + +### Фаза 5 — Тесты + регрессия +- Бэк: тесты на `box`-переходы и `/field`. Фронт: спеки нового рабочего места. +- `/regression quick` по ходу; `/regression full` перед сборкой к проду. + +### Фаза 6 — Сборка к проду (позже, по «эскейп») +- Риск: ветка разошлась с main (21/769) — стратегия мёрджа/ребейза на актуальный main, sync схемы, + `prod-deploy-validator` (GO/NO-GO), деплой штатным флоу. Реальный движок (`RealCompetitorAgent`) — + отдельно (шаг 2 / движок), сменой binding в `AutopodborServiceProvider`. + +## 3. Отложено (явно) +- Шаг 2 (study) — довести позже; пока `FakeCompetitorAgent`. +- archived-ящик источников; «Общие настройки» (убраны из прототипа); В1/В2/В3 у создания проекта. + +## 3bis. ДОБАВЛЕНИЯ ПОСЛЕ СВЕРКИ СО «СМЕТОЙ» (§14 + прототип) — чтобы не выронить +Сверка прошла по §14.0–14.11 и по всем экранам/окнам прототипа. Доводки к фазам: + +1. **🔴 phone_kind — КЛЮЧЕВОЕ.** В воркстри `autopodbor_sources.phone_kind` = `'real'|'substitute'` + (коллтрекинг). По §14.5 это та самая нестыковка B, которую закрыли: показываем + **городской/мобильный/8-800** (что даёт движок через DaData), НЕ настоящий/подменный. + → Фаза 1: сменить enum/значения `phone_kind` на `городской|мобильный|8-800` (+ поправить + `FakeCompetitorAgent` и StudyResult, и UI-бейдж). Тесты схемы обновить. +2. **PATCH /competitors/{id}** — правка ВСЕХ полей карточки (имя, тип, **похожесть %**, сайт, + справочники 2ГИС/Яндекс, описание) + `box`. Сейчас в воркстри ручки правки конкурента НЕТ. + → Фаза 2. Авто-сортировка по похожести — на фронте (§14.4). +3. **DELETE /competitors/{id}** — удалить предложение/конкурента (в прототипе «✕ Удалить»; + предупредить, если есть проекты в работе). Сейчас ручки нет. → Фаза 2. +4. **PATCH /sources/{id}** (правка значения/провенанса + `box`) и **DELETE /sources/{id}** + (с гвардом: если у источника активный проект — блок, как при удалении проекта, §14.10). + Сейчас есть только POST sources/manual. → Фаза 2. +5. **Счётчики на карточке конкурента** — `/field` обязан вернуть по каждому конкуренту: + **источников / создано проектов / N в работе** (join `projects`). → Фаза 2. +6. **Правка источника, у которого УЖЕ есть проект = смена источника проекта.** Если + `source.created_project_id != null` и меняется адрес/номер — это `change_source` через готовый + `PATCH /api/projects/{id}` (реальный гвард §14.10, «Сменить источник?»). Тип залочен + (`signal_type` immutable). → Фаза 4 wiring + Фаза 2. +7. **«+ Добавить вручную» конкурента сразу В ПОЛЕ** (без шага 2/без списания) — нужен путь + создания конкурента с `box='field'`, `origin='manual'` БЕЗ study (в воркстри manual-study + сразу запускает изучение). → Фаза 2 (отдельный create-competitor или флаг no-study). + +Плюс сохранить из воркстри: **feature-flag `autopodbor_enabled`** (раскатка) и +`autopodbor_max_competitors` — пригодятся для безопасного go-live на проде. + +## 4. Решения, уже зафиксированные (не открыты) +- Два ящика на обоих уровнях (§14.1, владелец: «два ящика везде»). +- Цены: подбор 300 ₽, источники 50 ₽; создание проектов — бесплатно. +- Тип источника неизменяем; смена адреса/удаление — по правилам §14.10. +- Система универсальна (омега — лишь тестовый клиент). diff --git a/docs/superpowers/prototypes/2026-06-29-konkurentnoe-pole-proto.html b/docs/superpowers/prototypes/2026-06-29-konkurentnoe-pole-proto.html new file mode 100644 index 00000000..96918ca9 --- /dev/null +++ b/docs/superpowers/prototypes/2026-06-29-konkurentnoe-pole-proto.html @@ -0,0 +1,631 @@ + +Лидерра — Конкурентное поле (прототип) + + +
+ +
+
+
Конкурентное поле
+
🔍1 839 600 ₽🔔ЛК
+
+
+
+
+
+
+ + + From 77dda5964b536a3014ee116c94da00db0e1b1e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 04:57:58 +0300 Subject: [PATCH 046/417] =?UTF-8?q?feat(=D0=BA=D0=BE=D0=BD=D0=BA=D1=83?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D1=82=D0=BD=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5):=20=D0=B4=D0=BE=D0=B2=D0=BE=D0=B4=D0=BA=D0=B0=20=D1=84?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D1=82=D0=B0=20=D0=B4=D0=BE=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D1=82=D0=B8=D0=BF=D0=B0=20=E2=80=94=20F1/F2/?= =?UTF-8?q?F3=20+=20=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20M2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сверка прототипа с реализацией показала расхождения — закрыты по TDD (dev, фронт): - F1: экран «Предложения» (FieldProposalsScreen) переписан под вид «Поля» — карточки-плитки field-shared, тип+«предложение», крупная похожесть, Сайт + Справочник 2ГИС·Яндекс, править/удалять в карточке, массовый перенос; кнопка «Собрать конкурентов» открывает единое окно сбора 300 ₽ вместо старого autoform. - F2: новый дружелюбный админ-экран AdminAutopodborPricingView (правка цен доп.услуг через PUT /api/admin/system-settings/{key} с обоснованием для аудита, сетка лидов для справки) + маршрут /admin/autopodbor-pricing + пункт меню. - F3: колонка «когда списывается» в панели доп.услуг биллинга. - M2: удалён мёртвый экран FieldManualCompetitorScreen (+ спека) — на него не было переходов; ручное добавление живёт окном на «Поле». Тесты автоподбор+админ 43/43 зелёные, продакшен-вёрстка eslint-чистая, vite build ✅. НЕ на проде. M1 (18:00/21:00 МСК) — не баг, реальный инвариант продукта, не трогал. Co-Authored-By: Claude Opus 4.8 --- .../billing/AutopodborServicesPanel.vue | 9 + app/resources/js/layouts/AdminLayout.vue | 1 + app/resources/js/router/index.ts | 12 + .../admin/AdminAutopodborPricingView.vue | 237 ++++++++ .../js/views/autopodbor/AutopodborView.vue | 3 - .../screens/FieldManualCompetitorScreen.vue | 211 ------- .../screens/FieldProposalsScreen.vue | 542 +++++++++--------- .../AdminAutopodborPricingView.spec.ts | 81 +++ ...opodborFieldManualCompetitorScreen.spec.ts | 51 -- .../AutopodborFieldProposalsScreen.spec.ts | 65 ++- 10 files changed, 674 insertions(+), 538 deletions(-) create mode 100644 app/resources/js/views/admin/AdminAutopodborPricingView.vue delete mode 100644 app/resources/js/views/autopodbor/screens/FieldManualCompetitorScreen.vue create mode 100644 app/tests/Frontend/AdminAutopodborPricingView.spec.ts delete mode 100644 app/tests/Frontend/AutopodborFieldManualCompetitorScreen.spec.ts diff --git a/app/resources/js/components/billing/AutopodborServicesPanel.vue b/app/resources/js/components/billing/AutopodborServicesPanel.vue index dc79c788..db7ad563 100644 --- a/app/resources/js/components/billing/AutopodborServicesPanel.vue +++ b/app/resources/js/components/billing/AutopodborServicesPanel.vue @@ -29,6 +29,7 @@ onMounted(() => {
Сбор конкурентов
Подбор похожих конкурентов по вашим примерам и региону
+
при успешном подборе
{{ searchPrice }} ₽
@@ -37,6 +38,7 @@ onMounted(() => {
Сбор источников
Все источники одного конкурента (сайты и телефоны) для проектов
+
при успешном изучении
{{ studyPrice }} ₽
@@ -52,6 +54,13 @@ onMounted(() => { } .ap-row__name { min-width: 0; + flex: 1; +} +.ap-row__when { + flex-shrink: 0; + white-space: nowrap; + text-align: right; + min-width: 140px; } .ap-row__price { font-family: 'JetBrains Mono', ui-monospace, monospace; diff --git a/app/resources/js/layouts/AdminLayout.vue b/app/resources/js/layouts/AdminLayout.vue index a9b78cf6..ed539b0b 100644 --- a/app/resources/js/layouts/AdminLayout.vue +++ b/app/resources/js/layouts/AdminLayout.vue @@ -30,6 +30,7 @@ const navItems: NavItem[] = [ { title: 'Лиды', icon: 'mdi-target', to: '/admin/leads' }, { title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' }, { title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' }, + { title: 'Тарифы «Конкурентного поля»', icon: 'mdi-bullseye-arrow', to: '/admin/autopodbor-pricing' }, { title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' }, { title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents' }, { title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' }, diff --git a/app/resources/js/router/index.ts b/app/resources/js/router/index.ts index dfad8bdc..45afeeb0 100644 --- a/app/resources/js/router/index.ts +++ b/app/resources/js/router/index.ts @@ -270,6 +270,18 @@ const routes: RouteRecordRaw[] = [ devLabel: 'Admin Pricing Tiers', }, }, + { + path: '/admin/autopodbor-pricing', + name: 'admin-autopodbor-pricing', + component: () => import('../views/admin/AdminAutopodborPricingView.vue'), + meta: { + layout: 'admin', + title: 'Тарифы «Конкурентного поля»', + requiresAuth: true, + devIndex: 28, + devLabel: 'Admin Autopodbor Pricing', + }, + }, { path: '/admin/supplier-prices', name: 'admin-supplier-prices', diff --git a/app/resources/js/views/admin/AdminAutopodborPricingView.vue b/app/resources/js/views/admin/AdminAutopodborPricingView.vue new file mode 100644 index 00000000..d023a697 --- /dev/null +++ b/app/resources/js/views/admin/AdminAutopodborPricingView.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/app/resources/js/views/autopodbor/AutopodborView.vue b/app/resources/js/views/autopodbor/AutopodborView.vue index 554f97db..652399c5 100644 --- a/app/resources/js/views/autopodbor/AutopodborView.vue +++ b/app/resources/js/views/autopodbor/AutopodborView.vue @@ -4,7 +4,6 @@ import { useAutopodborStore } from '../../stores/autopodborStore'; import FieldWorkspaceScreen from './screens/FieldWorkspaceScreen.vue'; import FieldCompetitorScreen from './screens/FieldCompetitorScreen.vue'; import FieldProposalsScreen from './screens/FieldProposalsScreen.vue'; -import FieldManualCompetitorScreen from './screens/FieldManualCompetitorScreen.vue'; import EntryScreen from './screens/EntryScreen.vue'; import AutoFormScreen from './screens/AutoFormScreen.vue'; import ManualFormScreen from './screens/ManualFormScreen.vue'; @@ -19,7 +18,6 @@ type ScreenName = | 'field' | 'fieldcompetitor' | 'field-proposals' - | 'field-manual-competitor' | 'entry' | 'autoform' | 'manualform' @@ -56,7 +54,6 @@ const screens: Partial> = { field: FieldWorkspaceScreen, fieldcompetitor: FieldCompetitorScreen, 'field-proposals': FieldProposalsScreen, - 'field-manual-competitor': FieldManualCompetitorScreen, entry: EntryScreen, autoform: AutoFormScreen, manualform: ManualFormScreen, diff --git a/app/resources/js/views/autopodbor/screens/FieldManualCompetitorScreen.vue b/app/resources/js/views/autopodbor/screens/FieldManualCompetitorScreen.vue deleted file mode 100644 index b4e9bb8d..00000000 --- a/app/resources/js/views/autopodbor/screens/FieldManualCompetitorScreen.vue +++ /dev/null @@ -1,211 +0,0 @@ - - - - - diff --git a/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue b/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue index 4bd2e54a..7bff402a 100644 --- a/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue +++ b/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue @@ -1,7 +1,8 @@ diff --git a/app/tests/Frontend/AdminAutopodborPricingView.spec.ts b/app/tests/Frontend/AdminAutopodborPricingView.spec.ts new file mode 100644 index 00000000..8e1e8158 --- /dev/null +++ b/app/tests/Frontend/AdminAutopodborPricingView.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +vi.mock('../../resources/js/api/admin'); +import AdminAutopodborPricingView from '../../resources/js/views/admin/AdminAutopodborPricingView.vue'; +import { listSystemSettings, updateSystemSetting, getPricingTiers } from '../../resources/js/api/admin'; + +const vuetify = createVuetify(); + +function settings(search = '300', study = '50') { + return [ + { key: 'autopodbor_price_search_rub', value: search, type: 'decimal', description: null, updated_at: '', updated_by: null }, + { key: 'autopodbor_price_study_rub', value: study, type: 'decimal', description: null, updated_at: '', updated_by: null }, + { key: 'other_key', value: '1', type: 'int', description: null, updated_at: '', updated_by: null }, + ]; +} +function tiers() { + return { + active: [{ tier_no: 1, leads_in_tier: 100, price_per_lead_kopecks: 50000, effective_from: '2026-06-01' }], + scheduled: {}, + }; +} + +function mountV() { + return mount(AdminAutopodborPricingView, { global: { plugins: [vuetify] } }); +} + +describe('AdminAutopodborPricingView', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(listSystemSettings).mockResolvedValue(settings() as any); + vi.mocked(getPricingTiers).mockResolvedValue(tiers() as any); + vi.mocked(updateSystemSetting).mockResolvedValue({} as any); + }); + + it('грузит текущие тарифы доп.услуг из system-settings', async () => { + const w = mountV(); + await new Promise((r) => setTimeout(r, 0)); + expect(listSystemSettings).toHaveBeenCalled(); + expect((w.vm as any).searchPrice).toBe('300'); + expect((w.vm as any).studyPrice).toBe('50'); + }); + + it('показывает сетку лидов для справки', async () => { + const w = mountV(); + await new Promise((r) => setTimeout(r, 0)); + expect(w.text()).toContain('Тариф на лиды'); + expect(w.text()).toContain('500'); // 50000 коп = 500 ₽ + }); + + it('сохранение изменённой цены зовёт updateSystemSetting с value и reason', async () => { + const w = mountV(); + await new Promise((r) => setTimeout(r, 0)); + (w.vm as any).searchPrice = '350'; + await (w.vm as any).save(); + expect(updateSystemSetting).toHaveBeenCalledWith( + 'autopodbor_price_search_rub', + expect.objectContaining({ value: '350' }), + ); + expect(updateSystemSetting).not.toHaveBeenCalledWith('autopodbor_price_study_rub', expect.anything()); + }); + + it('причина короче 30 символов блокирует сохранение', async () => { + const w = mountV(); + await new Promise((r) => setTimeout(r, 0)); + (w.vm as any).searchPrice = '350'; + (w.vm as any).reason = 'мало'; + await (w.vm as any).save(); + expect(updateSystemSetting).not.toHaveBeenCalled(); + expect((w.vm as any).errorMessage).toContain('30'); + }); + + it('без изменений не зовёт сохранение', async () => { + const w = mountV(); + await new Promise((r) => setTimeout(r, 0)); + await (w.vm as any).save(); + expect(updateSystemSetting).not.toHaveBeenCalled(); + expect((w.vm as any).errorMessage).toBeTruthy(); + }); +}); diff --git a/app/tests/Frontend/AutopodborFieldManualCompetitorScreen.spec.ts b/app/tests/Frontend/AutopodborFieldManualCompetitorScreen.spec.ts deleted file mode 100644 index 24d979fe..00000000 --- a/app/tests/Frontend/AutopodborFieldManualCompetitorScreen.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { mount } from '@vue/test-utils'; -import { createPinia, setActivePinia } from 'pinia'; -import { createVuetify } from 'vuetify'; -import { reactive, ref } from 'vue'; - -vi.mock('../../resources/js/api/autopodbor'); -import FieldManualCompetitorScreen from '../../resources/js/views/autopodbor/screens/FieldManualCompetitorScreen.vue'; -import { useAutopodborStore } from '../../resources/js/stores/autopodborStore'; - -const vuetify = createVuetify(); - -function makeNav() { - return { go: vi.fn(), ctx: reactive({ competitorId: null }), screen: ref('field-manual-competitor') }; -} - -function mountM(nav: any) { - return mount(FieldManualCompetitorScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } }); -} - -describe('FieldManualCompetitorScreen', () => { - beforeEach(() => { - setActivePinia(createPinia()); - vi.clearAllMocks(); - }); - - it('пустое имя показывает ошибку и не отправляет', async () => { - const store = useAutopodborStore(); - const addSpy = vi.spyOn(store, 'addFieldCompetitor'); - const w = mountM(makeNav()); - const btn = w.findAll('button').find((b) => b.text() === 'Добавить в поле'); - await btn!.trigger('click'); - await new Promise((r) => setTimeout(r, 0)); - expect(addSpy).not.toHaveBeenCalled(); - expect(w.text()).toContain('Укажите название'); - }); - - it('заполнение и отправка добавляют конкурента и открывают его карточку', async () => { - const store = useAutopodborStore(); - vi.spyOn(store, 'addFieldCompetitor').mockResolvedValue({ id: 42, name: 'Ромашка' } as any); - const nav = makeNav(); - const w = mountM(nav); - await w.find('input').setValue('Ромашка'); - const btn = w.findAll('button').find((b) => b.text() === 'Добавить в поле'); - await btn!.trigger('click'); - await new Promise((r) => setTimeout(r, 0)); - expect(store.addFieldCompetitor).toHaveBeenCalled(); - expect(nav.ctx.competitorId).toBe(42); - expect(nav.go).toHaveBeenCalledWith('fieldcompetitor'); - }); -}); diff --git a/app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts b/app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts index 815b28bf..a41da91d 100644 --- a/app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts +++ b/app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts @@ -18,13 +18,13 @@ function comp(over: Partial = {}) { return { id: 1, name: 'Окна', - description: null, + description: 'Окна ПВХ под ключ', is_federal: false, relevance_pct: 80, origin: 'auto', box: 'proposal', site_url: 'okna.ru', - directory_urls: [], + directory_urls: ['https://2gis.ru/firm/1', 'https://yandex.ru/maps/1'], studied_at: null, study_run_id: null, search_run_id: 5, @@ -42,18 +42,23 @@ describe('FieldProposalsScreen', () => { vi.clearAllMocks(); }); - it('грузит предложения и показывает их', async () => { + it('грузит предложения и показывает карточку-плитку с похожестью и Справочником', async () => { const store = useAutopodborStore(); vi.spyOn(store, 'loadProposals').mockImplementation(async () => { - store.proposals = [comp({ id: 1, name: 'Окна Комфорт' })] as any; + store.proposals = [comp({ id: 1, name: 'Окна Комфорт', relevance_pct: 80 })] as any; }); const w = mountP(makeNav()); await new Promise((r) => setTimeout(r, 0)); expect(store.loadProposals).toHaveBeenCalled(); + expect(w.find('.ld-card').exists()).toBe(true); expect(w.text()).toContain('Окна Комфорт'); + expect(w.text()).toContain('80'); + expect(w.text()).toContain('Справочник'); + expect(w.text()).toContain('2ГИС'); + expect(w.text()).toContain('Яндекс.Карты'); }); - it('«В поле» по конкуренту зовёт moveCompetitorToBox(field)', async () => { + it('«В поле →» по конкуренту зовёт moveCompetitorToBox(field)', async () => { const store = useAutopodborStore(); vi.spyOn(store, 'loadProposals').mockImplementation(async () => { store.proposals = [comp({ id: 7 })] as any; @@ -61,7 +66,7 @@ describe('FieldProposalsScreen', () => { const moveSpy = vi.spyOn(store, 'moveCompetitorToBox').mockResolvedValue(); const w = mountP(makeNav()); await new Promise((r) => setTimeout(r, 0)); - const btn = w.find('.ld-comp__act button'); + const btn = w.find('.ld-cfoot button'); await btn.trigger('click'); await new Promise((r) => setTimeout(r, 0)); expect(moveSpy).toHaveBeenCalledWith(7, 'field'); @@ -76,4 +81,52 @@ describe('FieldProposalsScreen', () => { await new Promise((r) => setTimeout(r, 0)); expect(w.text()).toContain('Предложений пока нет'); }); + + it('«Собрать конкурентов» открывает окно сбора с ценой 300 ₽ (не уходит на старую форму)', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadProposals').mockResolvedValue(); + store.prices = { search: '300', study: '50' }; + const nav = makeNav(); + const w = mountP(nav); + await new Promise((r) => setTimeout(r, 0)); + const btn = w.findAll('button').find((b) => b.text().includes('Собрать конкурентов')); + await btn!.trigger('click'); + expect(w.find('.ld-ovl').exists()).toBe(true); + expect(w.text()).toContain('Сбор конкурентов'); + expect(w.text()).toContain('300 ₽'); + expect(nav.go).not.toHaveBeenCalledWith('autoform'); + }); + + it('массово переносит выбранных в поле при ≥2', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadProposals').mockImplementation(async () => { + store.proposals = [comp({ id: 1 }), comp({ id: 2 })] as any; + }); + const moveSpy = vi.spyOn(store, 'moveCompetitorToBox').mockResolvedValue(); + const w = mountP(makeNav()); + await new Promise((r) => setTimeout(r, 0)); + const boxes = w.findAll('.ld-pick'); + await boxes[0].trigger('change'); + await boxes[1].trigger('change'); + expect(w.find('.ld-bulkbar').exists()).toBe(true); + const btn = w.findAll('.ld-bulkbar button').find((b) => b.text().includes('Перенести')); + await btn!.trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + expect(moveSpy).toHaveBeenCalledWith(1, 'field'); + expect(moveSpy).toHaveBeenCalledWith(2, 'field'); + }); + + it('«Изменить» открывает окно правки карточки конкурента', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadProposals').mockImplementation(async () => { + store.proposals = [comp({ id: 1, name: 'Окна Комфорт' })] as any; + }); + const w = mountP(makeNav()); + await new Promise((r) => setTimeout(r, 0)); + const link = w.findAll('.ld-link').find((b) => b.text().includes('Изменить')); + await link!.trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + expect(w.find('.ld-ovl').exists()).toBe(true); + expect(w.text()).toContain('карточку конкурента'); + }); }); From ab612a025b4458c9d171615182160ff00a4ad9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 06:42:33 +0300 Subject: [PATCH 047/417] =?UTF-8?q?fix(=D0=BA=D0=BE=D0=BD=D0=BA=D1=83?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D1=82=D0=BD=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5):=206=20=D0=BD=D0=B0=D1=85=D0=BE=D0=B4=D0=BE=D0=BA=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=B0=20=C2=AB=D1=82=D1=83=D0=BF=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0=C2=BB?= =?UTF-8?q?=20=E2=80=94=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8,=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B3=D0=B8=D0=BE=D0=BD,=20=D0=B4=D0=B5=D0=B4=D1=83?= =?UTF-8?q?=D0=BF,=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - адресные сообщения в окнах сбора/изучения (маппер 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 --- .../Autopodbor/RunAutopodborSearchJob.php | 16 ++++++++ .../Autopodbor/Agent/FakeCompetitorAgent.php | 1 + ...26_06_28_100000_create_autopodbor_runs.php | 8 ++++ ...8_100100_create_autopodbor_competitors.php | 6 +++ ...06_28_100200_create_autopodbor_sources.php | 6 +++ app/resources/js/api/admin.ts | 2 +- app/resources/js/api/autopodbor.ts | 25 ++++++++++++ .../admin/AdminAutopodborPricingView.vue | 5 ++- .../screens/FieldCompetitorScreen.vue | 9 +++-- .../screens/FieldWorkspaceScreen.vue | 10 ++--- .../Autopodbor/RunAutopodborSearchJobTest.php | 26 +++++++++++++ .../AutopodborFieldCompetitorScreen.spec.ts | 17 +++++++++ .../Frontend/autopodborErrorMessage.spec.ts | 38 +++++++++++++++++++ .../Autopodbor/FakeCompetitorAgentTest.php | 17 +++++++++ 14 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 app/tests/Frontend/autopodborErrorMessage.spec.ts diff --git a/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php index d26d7c6f..746de320 100644 --- a/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php +++ b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php @@ -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; diff --git a/app/app/Services/Autopodbor/Agent/FakeCompetitorAgent.php b/app/app/Services/Autopodbor/Agent/FakeCompetitorAgent.php index 96f57ee0..2c490732 100644 --- a/app/app/Services/Autopodbor/Agent/FakeCompetitorAgent.php +++ b/app/app/Services/Autopodbor/Agent/FakeCompetitorAgent.php @@ -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 на сайте'], ]); } diff --git a/app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php b/app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php index 3e8f28fe..f995c1c7 100644 --- a/app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php +++ b/app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php @@ -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'); diff --git a/app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php b/app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php index a6773711..3a267f5d 100644 --- a/app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php +++ b/app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php @@ -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'); diff --git a/app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php b/app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php index 1cb84d83..a482d5ed 100644 --- a/app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php +++ b/app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php @@ -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'); diff --git a/app/resources/js/api/admin.ts b/app/resources/js/api/admin.ts index a0d2d340..1a0f0cb3 100644 --- a/app/resources/js/api/admin.ts +++ b/app/resources/js/api/admin.ts @@ -315,7 +315,7 @@ export async function listSystemSettings(): Promise { 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 { diff --git a/app/resources/js/api/autopodbor.ts b/app/resources/js/api/autopodbor.ts index 9e7df709..1d9936b5 100644 --- a/app/resources/js/api/autopodbor.ts +++ b/app/resources/js/api/autopodbor.ts @@ -1,5 +1,30 @@ +import axios from 'axios'; import { apiClient } from './client'; +/** + * Адресные сообщения по коду ошибки автоподбора (бэкенд кладёт `{ error: 'code' }`). + * Общий `extractErrorMessage` читает `message`, поэтому для наших кодов нужен отдельный маппер — + * иначе клиент видит общий текст «Проверьте баланс» на ЛЮБУЮ ошибку. + */ +const AUTOPODBOR_ERROR_MESSAGES: Record = { + 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'; diff --git a/app/resources/js/views/admin/AdminAutopodborPricingView.vue b/app/resources/js/views/admin/AdminAutopodborPricingView.vue index d023a697..05a42c1b 100644 --- a/app/resources/js/views/admin/AdminAutopodborPricingView.vue +++ b/app/resources/js/views/admin/AdminAutopodborPricingView.vue @@ -173,11 +173,12 @@ async function save(): Promise { 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; diff --git a/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue b/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue index 61745fea..2fd8173c 100644 --- a/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue +++ b/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue @@ -1,7 +1,7 @@ ' + .'x'; + $r = (new HtmlPhoneScanner)->scan($html); + expect($r['code']['78432032533'])->toContain('schema') + ->and($r['code']['78432452533'])->toContain('microdata'); +}); + +it('считает вхождения в тело и берёт e-mail-цифры', function () { + $html = 'тел 8(843)203-25-33, ещё 8(843)203-25-33. почта 2032533@mail.ru'; + $r = (new HtmlPhoneScanner)->scan($html); + expect($r['body']['78432032533'])->toBe(2) + ->and($r['emails'])->toContain('2032533'); +}); From ee62ed789f8877b131b30453717ad5d6ce642fb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 10:16:35 +0300 Subject: [PATCH 050/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20CalltrackingDetector=20+=20D?= =?UTF-8?q?TO=20CollectedSource=20=E2=80=94=20=D0=B4=D0=B5=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=20=D0=BF=D0=BE=D0=B4=D0=BC=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=20?= =?UTF-8?q?=D1=81=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D0=BD=D0=B0=D0=BD=D1=81?= =?UTF-8?q?=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../Autopodbor/Agent/Dto/CollectedSource.php | 23 +++++++++++++++++++ .../Agent/Extract/CalltrackingDetector.php | 23 +++++++++++++++++++ .../Extract/CalltrackingDetectorTest.php | 9 ++++++++ .../Extract/CollectedSourceTest.php | 12 ++++++++++ 4 files changed, 67 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Dto/CollectedSource.php create mode 100644 app/app/Services/Autopodbor/Agent/Extract/CalltrackingDetector.php create mode 100644 app/tests/Unit/Autopodbor/Extract/CalltrackingDetectorTest.php create mode 100644 app/tests/Unit/Autopodbor/Extract/CollectedSourceTest.php diff --git a/app/app/Services/Autopodbor/Agent/Dto/CollectedSource.php b/app/app/Services/Autopodbor/Agent/Dto/CollectedSource.php new file mode 100644 index 00000000..6f124262 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Dto/CollectedSource.php @@ -0,0 +1,23 @@ + $sources */ + public function __construct( + public readonly string $signalType, // call | site + public readonly string $identifier, // 7XXXXXXXXXX | домен + public readonly ?string $phoneKind, // real | substitute | null + public readonly ?string $phoneType, // city | mobile | tollfree | null + public readonly ?string $office, // подпись филиала | null + public readonly array $sources, // «где нашли» + ) {} + + public function confirmations(): int + { + return count($this->sources); + } +} diff --git a/app/app/Services/Autopodbor/Agent/Extract/CalltrackingDetector.php b/app/app/Services/Autopodbor/Agent/Extract/CalltrackingDetector.php new file mode 100644 index 00000000..298df891 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Extract/CalltrackingDetector.php @@ -0,0 +1,23 @@ + */ + public function detect(string $html): array + { + $found = []; + foreach (self::PROVIDERS as $p) { + if (stripos($html, $p) !== false) { + $found[] = $p; + } + } + + return array_values(array_unique($found)); + } +} diff --git a/app/tests/Unit/Autopodbor/Extract/CalltrackingDetectorTest.php b/app/tests/Unit/Autopodbor/Extract/CalltrackingDetectorTest.php new file mode 100644 index 00000000..c1fe63e5 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Extract/CalltrackingDetectorTest.php @@ -0,0 +1,9 @@ +detect(''))->toBe(['roistat']); + expect((new CalltrackingDetector)->detect('
'))->toBe(['callibri']); + expect((new CalltrackingDetector)->detect('чисто'))->toBe([]); +}); diff --git a/app/tests/Unit/Autopodbor/Extract/CollectedSourceTest.php b/app/tests/Unit/Autopodbor/Extract/CollectedSourceTest.php new file mode 100644 index 00000000..37bedf1a --- /dev/null +++ b/app/tests/Unit/Autopodbor/Extract/CollectedSourceTest.php @@ -0,0 +1,12 @@ + 'в коде сайта', 'url' => 'https://x.ru/'], ['label' => '2ГИС', 'url' => 'https://2gis.ru/1']], + ); + expect($s->confirmations())->toBe(2)->and($s->phoneKind)->toBe('real'); +}); From 12c76d3fef759669f7047080fb2788aa416f1408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 10:16:48 +0300 Subject: [PATCH 051/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20SourceAggregator=20=E2=80=94?= =?UTF-8?q?=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF,=20=D0=BA=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D1=81=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F,=20?= =?UTF-8?q?=D1=81=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D0=BF=D1=83=D0=BB?= =?UTF-8?q?=D0=B0,=20=D1=81=D0=BE=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20+=20live-=D1=84=D0=B8=D0=BA=D1=81=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../Agent/Extract/SourceAggregator.php | 70 +++++++++++++++++++ .../Extract/SourceAggregatorLiveTest.php | 21 ++++++ .../Extract/SourceAggregatorTest.php | 40 +++++++++++ 3 files changed, 131 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php create mode 100644 app/tests/Unit/Autopodbor/Extract/SourceAggregatorLiveTest.php create mode 100644 app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php diff --git a/app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php b/app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php new file mode 100644 index 00000000..a0cc7331 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php @@ -0,0 +1,70 @@ + $candidates + * @return list + */ + public function aggregate(array $candidates): array + { + /** @var array,kinds:list,office:?string,tracker:bool}> $by */ + $by = []; + foreach ($candidates as $c) { + $n = $c['number']; + $by[$n] ??= ['sources' => [], 'kinds' => [], 'office' => null, 'tracker' => false]; + $by[$n]['kinds'][] = $c['kind']; + $by[$n]['tracker'] = $by[$n]['tracker'] || $c['tracker']; + $by[$n]['office'] ??= $c['office']; + if ($c['kind'] !== 'pool') { + $key = $c['label']; + if (! isset($by[$n]['sources'][$key])) { + $by[$n]['sources'][$key] = ['label' => $c['label'], 'url' => $c['url']]; + } + } + } + + $out = []; + foreach ($by as $n => $info) { + $n = (string) $n; // PHP приводит числовые ключи массива к int — возвращаем строку + $kinds = $info['kinds']; + $hasTrusted = (bool) array_intersect(self::TRUSTED, $kinds); + $onlyHidden = ! $hasTrusted && ! in_array('displayed', $kinds, true); // только pool + if ($onlyHidden) { + continue; // пул-свалка — не выводим + } + $kind = $hasTrusted ? 'real' : 'substitute'; + $out[] = new CollectedSource( + signalType: 'call', + identifier: $n, + phoneKind: $kind, + phoneType: PhoneType::of($n), + office: $info['office'], + sources: array_values($info['sources']), + ); + } + + usort($out, function (CollectedSource $a, CollectedSource $b): int { + $sa = $a->phoneKind === 'substitute' ? 1 : 0; + $sb = $b->phoneKind === 'substitute' ? 1 : 0; + if ($sa !== $sb) { + return $sa <=> $sb; // подменные — вниз + } + if ($a->confirmations() !== $b->confirmations()) { + return $b->confirmations() <=> $a->confirmations(); // больше подтверждений — выше + } + + return strcmp($a->identifier, $b->identifier); + }); + + return $out; + } +} diff --git a/app/tests/Unit/Autopodbor/Extract/SourceAggregatorLiveTest.php b/app/tests/Unit/Autopodbor/Extract/SourceAggregatorLiveTest.php new file mode 100644 index 00000000..6caefe78 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Extract/SourceAggregatorLiveTest.php @@ -0,0 +1,21 @@ + '73912920000', 'kind' => 'code', 'label' => 'код', 'url' => null, 'office' => 'Единая справочная', 'tracker' => true], + ['number' => '73912920000', 'kind' => 'contacts', 'label' => 'контакты', 'url' => null, 'office' => null, 'tracker' => true], + ['number' => '73912920000', 'kind' => 'directory', 'label' => '2ГИС', 'url' => 'https://2gis.ru/1', 'office' => null, 'tracker' => true], + ['number' => '73912817070', 'kind' => 'code', 'label' => 'код', 'url' => null, 'office' => 'Кр. рабочий, 61', 'tracker' => true], + ['number' => '73912500000', 'kind' => 'contacts', 'label' => 'контакты', 'url' => null, 'office' => 'Калинина, 185', 'tracker' => true], + ['number' => '79012414545', 'kind' => 'displayed', 'label' => 'Callibri', 'url' => null, 'office' => null, 'tracker' => true], + ['number' => '70489624563', 'kind' => 'pool', 'label' => 'пул', 'url' => null, 'office' => null, 'tracker' => true], + ]; + $out = (new SourceAggregator)->aggregate($cand); + $ids = array_map(fn ($s) => $s->identifier, $out); + + expect($ids)->not->toContain('70489624563'); // пул скрыт + expect($ids[0])->toBe('73912920000'); // 3 подтверждения — сверху + expect(end($out)->phoneKind)->toBe('substitute'); // подменный — внизу +}); diff --git a/app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php b/app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php new file mode 100644 index 00000000..9ae98e8b --- /dev/null +++ b/app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php @@ -0,0 +1,40 @@ +aggregate([ + ['number' => '73912920000', 'kind' => 'code', 'label' => 'в коде сайта', 'url' => 'https://k.ru/', 'office' => null, 'tracker' => true], + ['number' => '73912920000', 'kind' => 'contacts', 'label' => 'страница «Контакты»', 'url' => 'https://k.ru/c', 'office' => 'Единая справочная', 'tracker' => true], + ]); + expect($out)->toHaveCount(1); + expect($out[0]->identifier)->toBe('73912920000'); + expect($out[0]->confirmations())->toBe(2); + expect($out[0]->office)->toBe('Единая справочная'); + expect($out[0]->phoneKind)->toBe('real'); + expect($out[0]->phoneType)->toBe('city'); +}); + +it('скрывает пул ротации и помечает видимый подменный', function () { + $out = (new SourceAggregator)->aggregate([ + ['number' => '79012414545', 'kind' => 'displayed', 'label' => 'Callibri (на сайте)', 'url' => 'https://k.ru/', 'office' => null, 'tracker' => true], + ['number' => '70489624563', 'kind' => 'pool', 'label' => 'пул', 'url' => null, 'office' => null, 'tracker' => true], + ]); + expect($out)->toHaveCount(1); // пул скрыт + expect($out[0]->identifier)->toBe('79012414545'); + expect($out[0]->phoneKind)->toBe('substitute'); +}); + +it('сортирует: больше подтверждений — выше, подменные — в самый низ', function () { + $out = (new SourceAggregator)->aggregate([ + // 1 подтверждение + ['number' => '73912500000', 'kind' => 'contacts', 'label' => 'контакты', 'url' => null, 'office' => null, 'tracker' => true], + // 2 подтверждения + ['number' => '73912920000', 'kind' => 'code', 'label' => 'код', 'url' => null, 'office' => null, 'tracker' => true], + ['number' => '73912920000', 'kind' => 'contacts', 'label' => 'контакты', 'url' => null, 'office' => null, 'tracker' => true], + // подменный + ['number' => '79012414545', 'kind' => 'displayed', 'label' => 'Callibri', 'url' => null, 'office' => null, 'tracker' => true], + ]); + expect(array_map(fn ($s) => $s->identifier, $out)) + ->toBe(['73912920000', '73912500000', '79012414545']); +}); From a4c98c4e5d27efee93193c1d12ac31db2b02d59b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 10:41:19 +0300 Subject: [PATCH 052/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=BF=D0=BB=D0=B0=D0=BD=20P?= =?UTF-8?q?lan=20B=20=E2=80=94=20=D0=B4=D0=BE=D0=B1=D1=8B=D1=87=D0=B0=20+?= =?UTF-8?q?=20RealCompetitorAgent=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...026-06-30-real-engine-step2-fetch-agent.md | 957 ++++++++++++++++++ 1 file changed, 957 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-real-engine-step2-fetch-agent.md diff --git a/docs/superpowers/plans/2026-06-30-real-engine-step2-fetch-agent.md b/docs/superpowers/plans/2026-06-30-real-engine-step2-fetch-agent.md new file mode 100644 index 00000000..b34764f8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-real-engine-step2-fetch-agent.md @@ -0,0 +1,957 @@ +# Живой движок шага 2 — добыча и агент (Plan B) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Собрать `RealCompetitorAgent::studyCompetitor` — он загружает страницы конкурента и его карточки в справочниках, превращает их в размеченные кандидаты, прогоняет через ядро `SourceAggregator` (Plan A) и отдаёт результат в существующем контракте `StudyCompetitorResult`, чтобы джоба/дедуп/БД работали без изменений. + +**Architecture:** Сеть отделена от логики. Импурный `Fetcher` (curl + рендер) прячется за интерфейсом; вся разметка — в чистом `CandidateBuilder` (использует `HtmlPhoneScanner` + `CalltrackingDetector` из Plan A), который полностью покрыт юнит-тестами на синтетике. `RealCompetitorAgent` оркестрирует: `Fetcher` → `CandidateBuilder` → `SourceAggregator::aggregate()` → маппинг `CollectedSource[]` в плоские строки `StudyCompetitorResult`. Тесты гоняются через `FakeFetcher` (без интернета). Живой `CurlPlaywrightFetcher` и подмена биндинга в провайдере — последние задачи, под ручную проверку и «эскейп». + +**Tech Stack:** PHP 8.3, Pest 4. Зависимости: Plan A (`PhoneType`, `HtmlPhoneScanner`, `CalltrackingDetector`, `CollectedSource`, `SourceAggregator`), `AutopodborNormalizer` (домен/телефон). Живой слой — `curl`/Guzzle + Playwright через `Symfony\Process` (node-скрипт); в этом плане живой слой добавляется одной изолированной задачей. + +**Граница плана (scope):** добыча + разметка + агент + маппинг в текущий контракт + (опционально, под «эскейп») подмена заглушки. НЕ входит: расширение БД/контракта под богатый список «где нашли» (Plan C), Vue-экраны (Plan D/E). Богатый провенанс (несколько источников на номер) в Plan B **схлопывается** в один `provenance_url`/`provenance_label` существующего контракта; полный список поедет в БД в Plan C. + +**Эталоны:** Plan A — `docs/superpowers/plans/2026-06-30-real-engine-step2-core.md`. R&D и механизм — `docs/superpowers/findings/2026-06-30-real-engine-step2-research.md`. Рабочий прототип логики — `scratchpad/engine_spike.php`, `engine_spike2.php`. + +--- + +## Контракты, на которые опираемся (уже существуют) + +- `interface CompetitorAgent { findCompetitors(...); studyCompetitor(StudyCompetitorRequest): StudyCompetitorResult; resolveByName(...); }` +- `StudyCompetitorRequest { array $competitor /* {name, site_url?, directory_urls?} */, int $regionCode }` +- `StudyCompetitorResult { array $sources /* каждая: {signal_type, identifier, phone_kind?, phone_type?, provenance_url?, provenance_label?} */ }` +- Из Plan A: `SourceAggregator::aggregate(array $candidates): list`, где кандидат — `{number, kind, label, url, office, tracker}`, а `CollectedSource{signalType, identifier, phoneKind, phoneType, office, sources[], confirmations()}`. +- `AutopodborNormalizer`: `phone()→7XXXXXXXXXX`, `domainHead()`, `sourceKey()`. + +--- + +## Структура файлов + +| Файл | Ответственность | +|---|---| +| `app/app/Services/Autopodbor/Agent/Fetch/FetchedSite.php` | DTO загруженного сайта: url, сырой HTML, видимые (отрендеренные) телефоны, номера со страницы «Контакты» с офисами | +| `app/app/Services/Autopodbor/Agent/Fetch/DirectoryCard.php` | DTO карточки справочника: номер, офис, url карточки, источник (2ГИС/Яндекс), сайт из карточки | +| `app/app/Services/Autopodbor/Agent/Fetch/Fetcher.php` | Интерфейс добычи: `site(url): FetchedSite`, `directory(url): list` | +| `app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php` | Чистая разметка: страницы+карточки → список кандидатов `{number,kind,label,url,office,tracker}` | +| `app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php` | Оркестратор: Fetcher → Builder → Aggregator → маппинг в `StudyCompetitorResult`; делегирует find/resolve фолбэку | +| `app/app/Services/Autopodbor/Agent/Fetch/CurlPlaywrightFetcher.php` | Живой `Fetcher`: curl сырого кода + рендер Playwright + парс карточек | +| `app/tests/Unit/Autopodbor/Agent/FakeFetcher.php` | Тест-дубль `Fetcher` (фиксированные страницы/карточки) | +| `app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php` | Юниты разметки | +| `app/tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php` | Юниты агента на `FakeFetcher` | + +--- + +## Task 1: DTO загруженного сайта и карточки справочника + +**Files:** +- Create: `app/app/Services/Autopodbor/Agent/Fetch/FetchedSite.php` +- Create: `app/app/Services/Autopodbor/Agent/Fetch/DirectoryCard.php` +- Test: `app/tests/Unit/Autopodbor/Agent/FetchDtoTest.php` + +- [ ] **Step 1: Падающий тест** + +```php +x', + visiblePhones: ['8 (391) 292-00-00'], + contactsNumbers: [['number' => '83912817070', 'office' => 'Кр. рабочий, 61']], + ); + expect($site->url)->toBe('https://k.ru/') + ->and($site->visiblePhones)->toContain('8 (391) 292-00-00') + ->and($site->contactsNumbers[0]['office'])->toBe('Кр. рабочий, 61'); + + $card = new DirectoryCard(number: '83912500000', office: 'Калинина, 185', url: 'https://2gis.ru/firm/1', source: '2ГИС', siteUrl: 'k.ru'); + expect($card->number)->toBe('83912500000')->and($card->source)->toBe('2ГИС')->and($card->siteUrl)->toBe('k.ru'); +}); +``` + +- [ ] **Step 2: Запустить — FAIL** (классы не найдены). +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Agent/FetchDtoTest.php` + +- [ ] **Step 3: Реализация `FetchedSite`** + +```php + $visiblePhones видимые посетителю (отрендеренные) номера, сырые строки + * @param list $contactsNumbers номера со страницы «Контакты» с привязкой к офису + */ + public function __construct( + public readonly string $url, + public readonly string $rawHtml, + public readonly array $visiblePhones = [], + public readonly array $contactsNumbers = [], + ) {} +} +``` + +- [ ] **Step 4: Реализация `DirectoryCard`** + +```php + new FetchedSite(url: 'https://k.ru/', rawHtml: 'x')], + cards: ['https://2gis.ru/firm/1' => []], + ); + expect($fetcher->site('https://k.ru/')->rawHtml)->toContain('73912920000') + ->and($fetcher->directory('https://2gis.ru/firm/1'))->toBe([]); +}); +``` + +- [ ] **Step 2: Запустить — FAIL.** +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Agent/FakeFetcherTest.php` + +- [ ] **Step 3: Реализация интерфейса** + +```php + */ + public function directory(string $url): array; +} +``` + +- [ ] **Step 4: Реализация тест-дубля** + +```php + $sites + * @param array> $cards + */ + public function __construct( + private array $sites = [], + private array $cards = [], + ) {} + + public function site(string $url): FetchedSite + { + return $this->sites[$url] ?? new FetchedSite(url: $url, rawHtml: ''); + } + + public function directory(string $url): array + { + return $this->cards[$url] ?? []; + } +} +``` + +- [ ] **Step 5: Запустить — PASS.** +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Agent/FakeFetcherTest.php` + +- [ ] **Step 6: Коммит** (после «эскейп») +```bash +git add app/app/Services/Autopodbor/Agent/Fetch/Fetcher.php app/tests/Unit/Autopodbor/Agent/FakeFetcher.php app/tests/Unit/Autopodbor/Agent/FakeFetcherTest.php +git commit -m "feat(автоподбор): интерфейс Fetcher + тест-дубль FakeFetcher" +``` + +--- + +## Task 3: CandidateBuilder — номера из кода сайта (code + tracker) + +**Files:** +- Create: `app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php` +- Test: `app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php` + +`CandidateBuilder::build(array $sites, array $cards): array` — на вход список `FetchedSite` и `DirectoryCard`, на выход список кандидатов `{number,kind,label,url,office,tracker}` (в форме Plan A). В этой задаче — только номера из структурного кода сайта (kind=`code`) и проброс флага трекера. + +- [ ] **Step 1: Падающий тест** + +```php +x', + ); + $cands = (new CandidateBuilder())->build([$site], []); + + $byNum = collect($cands)->keyBy('number'); + expect($byNum)->toHaveKey('73912920000'); + expect($byNum['73912920000']['kind'])->toBe('code'); + expect($byNum['73912920000']['url'])->toBe('https://k.ru/'); + expect($byNum['73912920000']['tracker'])->toBeTrue(); // roistat найден + expect($byNum['73912920000']['label'])->toBe('в коде сайта'); +}); +``` + +- [ ] **Step 2: Запустить — FAIL.** +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php` + +- [ ] **Step 3: Реализация (только code)** + +```php + $sites + * @param list $cards + * @return list + */ + public function build(array $sites, array $cards): array + { + $out = []; + foreach ($sites as $site) { + $scan = $this->scanner->scan($site->rawHtml); + $hasTracker = $this->detector->detect($site->rawHtml) !== []; + + foreach (array_keys($scan['code']) as $number) { + $out[] = [ + 'number' => (string) $number, + 'kind' => 'code', + 'label' => 'в коде сайта', + 'url' => $site->url, + 'office' => null, + 'tracker' => $hasTracker, + ]; + } + } + + return $out; + } +} +``` + +- [ ] **Step 4: Запустить — PASS.** +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php` + +- [ ] **Step 5: Коммит** (после «эскейп») +```bash +git add app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php +git commit -m "feat(автоподбор): CandidateBuilder — номера из кода сайта" +``` + +--- + +## Task 4: CandidateBuilder — подменный (displayed) и пул (pool) + +**Files:** +- Modify: `app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php` +- Modify: `app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php` + +Правило (из спайка): при активном трекере **видимый** отрендеренный номер, которого НЕТ в структурном коде → `displayed` (подменный 🎭). Номер, встречающийся в теле ≥2 раз, которого нет ни в коде, ни среди видимых → `pool` (свалка ротации; ядро её скроет). + +- [ ] **Step 1: Падающий тест (RED)** + +```php +it('размечает видимый подменный и скрытый пул при активном трекере', function () { + $site = new FetchedSite( + url: 'https://k.ru/', + rawHtml: '' + .'8 (391) 111-22-33' // видимый подменный (нет в tel:/schema) + .'8(391)700-00-01 8(391)700-00-01', // пул: 2 вхождения, не код, не видимый + visiblePhones: ['8 (391) 111-22-33'], + ); + $cands = (new CandidateBuilder())->build([$site], []); + $byNum = collect($cands)->keyBy('number'); + + expect($byNum['73911112233']['kind'])->toBe('displayed'); + expect($byNum['73917000001']['kind'])->toBe('pool'); +}); +``` + +- [ ] **Step 2: Запустить — FAIL.** +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php` + +- [ ] **Step 3: Дополнить `build()` — внутри цикла по сайтам, после блока code** + +```php + $codeNumbers = array_map('strval', array_keys($scan['code'])); + + // видимые отрендеренные номера → displayed (подменный), если их нет в коде + $visible = []; + foreach ($site->visiblePhones as $raw) { + $n = $this->normalize($raw); + if ($n === null) { + continue; + } + $visible[] = $n; + if (! in_array($n, $codeNumbers, true)) { + $out[] = [ + 'number' => $n, 'kind' => 'displayed', 'label' => 'показан на сайте (коллтрекинг)', + 'url' => $site->url, 'office' => null, 'tracker' => $hasTracker, + ]; + } + } + + // пул ротации: номер в теле ≥2 раз, не в коде и не видимый, при активном трекере + if ($hasTracker) { + foreach ($scan['body'] as $number => $count) { + $number = (string) $number; + if ($count >= 2 && ! in_array($number, $codeNumbers, true) && ! in_array($number, $visible, true)) { + $out[] = [ + 'number' => $number, 'kind' => 'pool', 'label' => 'пул подмены', + 'url' => $site->url, 'office' => null, 'tracker' => true, + ]; + } + } + } +``` + +И добавить приватный нормализатор (используется и дальше): + +```php + private function normalize(string $raw): ?string + { + $n = $this->norm->phone($raw); + + return (strlen($n) === 11 && $n[0] === '7') ? $n : null; + } +``` + +- [ ] **Step 4: Запустить — PASS.** +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php` + +- [ ] **Step 5: Коммит** (после «эскейп») +```bash +git add app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php +git commit -m "feat(автоподбор): CandidateBuilder — подменный и скрытый пул" +``` + +--- + +## Task 5: CandidateBuilder — страница «Контакты» (kind=contacts, офис) + +**Files:** +- Modify: `app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php` +- Modify: `app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php` + +Отрендеренные номера филиалов со страницы «Контакты» (которых нет в сыром коде) → kind=`contacts`, с подписью офиса. + +- [ ] **Step 1: Падающий тест (RED)** + +```php +it('из страницы контактов делает кандидатов с офисом', function () { + $site = new FetchedSite( + url: 'https://k.ru/', + rawHtml: 'пусто', + contactsNumbers: [ + ['number' => '8(391)281-70-70', 'office' => 'Кр. рабочий, 61'], + ['number' => '8(391)250-00-00', 'office' => 'Калинина, 185'], + ], + ); + $cands = (new CandidateBuilder())->build([$site], []); + $byNum = collect($cands)->keyBy('number'); + + expect($byNum['73912817070']['kind'])->toBe('contacts'); + expect($byNum['73912817070']['office'])->toBe('Кр. рабочий, 61'); + expect($byNum['73912817070']['label'])->toBe('страница «Контакты»'); +}); +``` + +- [ ] **Step 2: Запустить — FAIL.** + +- [ ] **Step 3: Дополнить `build()` — внутри цикла по сайтам, после блока pool** + +```php + foreach ($site->contactsNumbers as $row) { + $n = $this->normalize($row['number']); + if ($n === null) { + continue; + } + $out[] = [ + 'number' => $n, 'kind' => 'contacts', 'label' => 'страница «Контакты»', + 'url' => $site->url, 'office' => $row['office'] ?? null, 'tracker' => $hasTracker, + ]; + } +``` + +- [ ] **Step 4: Запустить — PASS.** + +- [ ] **Step 5: Коммит** (после «эскейп») +```bash +git add app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php +git commit -m "feat(автоподбор): CandidateBuilder — номера филиалов со страницы контактов" +``` + +--- + +## Task 6: CandidateBuilder — карточки справочников (kind=directory) + +**Files:** +- Modify: `app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php` +- Modify: `app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php` + +Каждая карточка 2ГИС/Яндекс → kind=`directory`, label = источник карточки, url = ссылка на карточку, office из карточки. + +- [ ] **Step 1: Падающий тест (RED)** + +```php +it('из карточек справочника делает кандидатов directory', function () { + $cards = [ + new \App\Services\Autopodbor\Agent\Fetch\DirectoryCard(number: '8(391)292-00-00', office: 'Единая справочная', url: 'https://2gis.ru/firm/1', source: '2ГИС'), + new \App\Services\Autopodbor\Agent\Fetch\DirectoryCard(number: '8(391)250-00-00', office: 'Калинина, 185', url: 'https://yandex.ru/maps/9', source: 'Яндекс.Карты'), + ]; + $cands = (new CandidateBuilder())->build([], $cards); + $byNum = collect($cands)->keyBy('number'); + + expect($byNum['73912920000']['kind'])->toBe('directory'); + expect($byNum['73912920000']['label'])->toBe('2ГИС'); + expect($byNum['73912920000']['url'])->toBe('https://2gis.ru/firm/1'); + expect($byNum['73912500000']['office'])->toBe('Калинина, 185'); +}); +``` + +- [ ] **Step 2: Запустить — FAIL.** + +- [ ] **Step 3: Дополнить `build()` — после цикла по сайтам, перед `return`** + +```php + foreach ($cards as $card) { + $n = $this->normalize($card->number); + if ($n === null) { + continue; + } + $out[] = [ + 'number' => $n, 'kind' => 'directory', 'label' => $card->source, + 'url' => $card->url, 'office' => $card->office, 'tracker' => false, + ]; + } +``` + +- [ ] **Step 4: Запустить — PASS, и весь файл теста зелёный.** +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php` + +- [ ] **Step 5: Коммит** (после «эскейп») +```bash +git add app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php +git commit -m "feat(автоподбор): CandidateBuilder — карточки справочников 2ГИС/Яндекс" +``` + +--- + +## Task 7: RealCompetitorAgent — оркестрация и маппинг в StudyCompetitorResult + +**Files:** +- Create: `app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php` +- Test: `app/tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php` + +`RealCompetitorAgent` реализует весь интерфейс `CompetitorAgent`. `studyCompetitor` делает настоящую работу; `findCompetitors`/`resolveByName` делегируются переданному фолбэк-агенту (пока — `FakeCompetitorAgent`), чтобы шаг 1 не сломался при подмене биндинга. + +Маппинг: телефонные `CollectedSource` → строки `{signal_type:'call', identifier, phone_kind, phone_type, provenance_url: первый source.url, provenance_label: первый source.label}`. Сайты (site_url конкурента + siteUrl из карточек) → строки `{signal_type:'site', identifier: domainHead, phone_kind:null, ...}`. + +- [ ] **Step 1: Падающий тест (на FakeFetcher)** + +```php + new FetchedSite( + url: 'https://k.ru/', + rawHtml: 'x8(391)111-22-33', + visiblePhones: ['8(391)111-22-33'], + ), + ], + cards: [ + 'https://2gis.ru/firm/1' => [ + new DirectoryCard(number: '8(391)292-00-00', office: 'Единая справочная', url: 'https://2gis.ru/firm/1', source: '2ГИС'), + ], + ], + ); + $agent = new RealCompetitorAgent($fetcher, new FakeCompetitorAgent()); + + $res = $agent->studyCompetitor(new StudyCompetitorRequest( + competitor: ['name' => 'КрасЛомбард', 'site_url' => 'k.ru', 'directory_urls' => ['https://2gis.ru/firm/1']], + regionCode: 391, + )); + + $calls = array_values(array_filter($res->sources, fn ($s) => $s['signal_type'] === 'call')); + $sites = array_values(array_filter($res->sources, fn ($s) => $s['signal_type'] === 'site')); + + // настоящий 292-00-00 (код+2ГИС = 2 подтверждения) выше подменного 111-22-33 + expect($calls[0]['identifier'])->toBe('73912920000'); + expect($calls[0]['phone_kind'])->toBe('real'); + expect(end($calls)['phone_kind'])->toBe('substitute'); + // сайт конкурента — отдельной строкой + expect(collect($sites)->pluck('identifier'))->toContain('k.ru'); +}); +``` + +- [ ] **Step 2: Запустить — FAIL** (класс не найден). +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php` + +- [ ] **Step 3: Реализация агента** + +```php +fallback->findCompetitors($r); + } + + public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult + { + return $this->fallback->resolveByName($r); + } + + public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult + { + $competitor = $r->competitor; + $siteUrl = $competitor['site_url'] ?? null; + $directoryUrls = $competitor['directory_urls'] ?? []; + + // 1. Грузим сайт(ы) и карточки справочников + $sites = []; + if (is_string($siteUrl) && $siteUrl !== '') { + $sites[] = $this->fetcher->site($this->ensureScheme($siteUrl)); + } + $cards = []; + foreach ($directoryUrls as $du) { + foreach ($this->fetcher->directory($du) as $card) { + $cards[] = $card; + } + } + + // 2. Размечаем кандидатов и сводим ядром + $candidates = $this->builder->build($sites, $cards); + $collected = $this->aggregator->aggregate($candidates); + + // 3. Маппинг в существующий контракт + $rows = []; + + // 3a. Сайты: сайт конкурента + сайты из карточек + $siteIds = []; + if (is_string($siteUrl) && $siteUrl !== '') { + $siteIds[$this->norm->domainHead($siteUrl)] = ['url' => $this->ensureScheme($siteUrl), 'label' => 'сайт конкурента']; + } + foreach ($cards as $card) { + if ($card->siteUrl !== null && $card->siteUrl !== '') { + $siteIds[$this->norm->domainHead($card->siteUrl)] ??= ['url' => $card->url, 'label' => $card->source.' — сайт в карточке']; + } + } + foreach ($siteIds as $domain => $meta) { + $rows[] = ['signal_type' => 'site', 'identifier' => $domain, 'phone_kind' => null, 'phone_type' => null, 'provenance_url' => $meta['url'], 'provenance_label' => $meta['label']]; + } + + // 3b. Телефоны + foreach ($collected as $c) { + $rows[] = $this->callRow($c); + } + + return new StudyCompetitorResult($rows); + } + + /** @return array{signal_type:string,identifier:string,phone_kind:?string,phone_type:?string,provenance_url:?string,provenance_label:?string} */ + private function callRow(CollectedSource $c): array + { + $top = $c->sources[0] ?? ['label' => null, 'url' => null]; + + return [ + 'signal_type' => 'call', + 'identifier' => $c->identifier, + 'phone_kind' => $c->phoneKind, + 'phone_type' => $c->phoneType, + 'provenance_url' => $top['url'] ?? null, + 'provenance_label' => $top['label'] ?? null, + ]; + } + + private function ensureScheme(string $url): string + { + return preg_match('#^[a-z]+://#i', $url) ? $url : 'https://'.$url; + } +} +``` + +- [ ] **Step 4: Запустить — PASS.** +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php` + +- [ ] **Step 5: Тест глубины сети (RED→PASS): несколько филиалов из разных справочников сводятся** + +```php +it('сводит филиалы из разных справочников: больше подтверждений — выше', function () { + $fetcher = new FakeFetcher( + sites: ['https://k.ru/' => new FetchedSite(url: 'https://k.ru/', rawHtml: 'x')], + cards: [ + 'https://2gis.ru/firm/1' => [new DirectoryCard(number: '8(391)292-00-00', office: 'Справочная', url: 'https://2gis.ru/firm/1', source: '2ГИС')], + 'https://yandex.ru/maps/2' => [new DirectoryCard(number: '8(391)281-70-70', office: 'Кр. рабочий, 61', url: 'https://yandex.ru/maps/2', source: 'Яндекс.Карты')], + ], + ); + $agent = new RealCompetitorAgent($fetcher, new FakeCompetitorAgent()); + $res = $agent->studyCompetitor(new StudyCompetitorRequest( + competitor: ['name' => 'КрасЛомбард', 'site_url' => 'k.ru', 'directory_urls' => ['https://2gis.ru/firm/1', 'https://yandex.ru/maps/2']], + regionCode: 391, + )); + $calls = array_values(array_filter($res->sources, fn ($s) => $s['signal_type'] === 'call')); + // 292-00-00: код + 2ГИС = 2 подтверждения → выше, чем 281-70-70 (только Яндекс = 1) + expect($calls[0]['identifier'])->toBe('73912920000'); + expect(collect($calls)->pluck('identifier'))->toContain('73912817070'); +}); +``` + +- [ ] **Step 6: Запустить — PASS** (логика готова; тест фиксирует глубину сети). +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php` + +- [ ] **Step 7: Прогнать весь блок автоподбора** +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/` +Expected: всё зелёное. + +- [ ] **Step 8: Коммит** (после «эскейп») +```bash +git add app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php app/tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php +git commit -m "feat(автоподбор): RealCompetitorAgent — оркестрация добычи и маппинг в контракт" +``` + +--- + +## Task 8: Живой Fetcher (curl + Playwright) — изолированно, без юнит-тестов сети + +**Files:** +- Create: `app/app/Services/Autopodbor/Agent/Fetch/CurlPlaywrightFetcher.php` +- Create: `app/scripts/render-page.cjs` (node-скрипт рендера через Playwright) + +Живой слой намеренно НЕ покрывается юнит-тестами (сеть/браузер недетерминированы). Он реализует `Fetcher`: `site()` берёт сырой код через curl и отрендеренные/видимые номера через Playwright; `directory()` парсит карточки. Проверяется вручную на живом конкуренте (Шаг 4 ниже), НЕ в CI. + +- [ ] **Step 1: node-скрипт рендера** — `app/scripts/render-page.cjs` + +```js +// Usage: node render-page.cjs +// Печатает JSON: { html, visiblePhones: [...] } — отрендеренный DOM и видимые tel:-номера. +const { chromium } = require('playwright'); + +(async () => { + const url = process.argv[2]; + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); + const html = await page.content(); + const visiblePhones = await page.$$eval('a[href^="tel:"]', els => + els.map(e => (e.textContent || '').trim()).filter(Boolean) + ); + process.stdout.write(JSON.stringify({ html, visiblePhones })); + } finally { + await browser.close(); + } +})().catch(e => { console.error(String(e)); process.exit(1); }); +``` + +- [ ] **Step 2: Реализация `CurlPlaywrightFetcher`** + +```php +renderScript === '') { + $this->renderScript = base_path('scripts/render-page.cjs'); + } + } + + public function site(string $url): FetchedSite + { + $raw = $this->curl($url); + + $visible = []; + $contacts = []; + $rendered = $this->render($url); + if ($rendered !== null) { + $visible = $rendered['visiblePhones'] ?? []; + // номера со страницы /contacts добираем отдельным рендером, если есть такая ссылка + $contactsUrl = $this->guessContactsUrl($url, $raw); + if ($contactsUrl !== null) { + $rc = $this->render($contactsUrl); + if ($rc !== null) { + foreach (($rc['visiblePhones'] ?? []) as $p) { + $contacts[] = ['number' => $p, 'office' => null]; + } + } + } + } + + return new FetchedSite(url: $url, rawHtml: $raw, visiblePhones: $visible, contactsNumbers: $contacts); + } + + public function directory(string $url): array + { + // Карточки 2ГИС/Яндекс — рендерим и вытаскиваем филиалы. Парсинг конкретной разметки + // выносится в Plan B-доработку; здесь — безопасный дефолт: пустой список, чтобы не падать. + return []; + } + + private function curl(string $url): string + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; LiderraBot/1.0)', + CURLOPT_SSL_VERIFYPEER => false, + ]); + $body = curl_exec($ch); + curl_close($ch); + + return is_string($body) ? $body : ''; + } + + /** @return array{html:string,visiblePhones:list}|null */ + private function render(string $url): ?array + { + $p = new Process([$this->nodeBin, $this->renderScript, $url]); + $p->setTimeout($this->timeout); + $p->run(); + if (! $p->isSuccessful()) { + return null; + } + $json = json_decode($p->getOutput(), true); + + return is_array($json) ? $json : null; + } + + private function guessContactsUrl(string $base, string $raw): ?string + { + if (preg_match('#href=["\']([^"\']*(?:contacts?|kontakty)[^"\']*)["\']#i', $raw, $m)) { + $href = $m[1]; + if (preg_match('#^https?://#i', $href)) { + return $href; + } + + return rtrim($base, '/').'/'.ltrim($href, '/'); + } + + return null; + } +} +``` + +- [ ] **Step 3: Проверить, что класс грузится (без сети)** +Run: `cd app && php -r "require 'vendor/autoload.php'; new App\Services\Autopodbor\Agent\Fetch\CurlPlaywrightFetcher('node','/tmp/x.cjs'); echo 'ok';"` +Expected: печатает `ok` (конструктор не лезет в сеть). + +- [ ] **Step 4: Коммит** (после «эскейп») +```bash +git add app/app/Services/Autopodbor/Agent/Fetch/CurlPlaywrightFetcher.php app/scripts/render-page.cjs +git commit -m "feat(автоподбор): живой CurlPlaywrightFetcher (curl + Playwright-рендер)" +``` + +> **Примечание о справочниках:** разбор конкретной HTML-разметки карточек 2ГИС/Яндекс (`directory()`) — отдельная доработка после ручной разведки реальной структуры карточек; в этой задаче `directory()` возвращает пустой список (безопасный дефолт), а глубина сети из справочников доказывается на `FakeFetcher` (Task 7). Это **намеренный известный разрыв** — фиксируется здесь, не «молча». + +--- + +## Task 9: Подмена биндинга — RealCompetitorAgent в проде воркстри (под «эскейп», после ручной проверки) + +**Files:** +- Modify: `app/app/Providers/AutopodborServiceProvider.php` +- Test: `app/tests/Unit/Autopodbor/Agent/ProviderBindingTest.php` + +Подменяем заглушку на реальный агент **с фолбэком на `FakeCompetitorAgent`** для find/resolve. Делать ТОЛЬКО после ручной проверки живого `CurlPlaywrightFetcher` на реальном конкуренте (Task 8 в бою воркстри). До проверки — оставить заглушку. + +- [ ] **Step 1: Тест биндинга (RED)** + +```php +toBeInstanceOf(RealCompetitorAgent::class); +}); +``` + +- [ ] **Step 2: Запустить — FAIL** (пока биндится Fake). +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Agent/ProviderBindingTest.php` + +- [ ] **Step 3: Заменить биндинг в провайдере** + +```php + public function register(): void + { + $this->app->bind(\App\Services\Autopodbor\Agent\Fetch\Fetcher::class, \App\Services\Autopodbor\Agent\Fetch\CurlPlaywrightFetcher::class); + + $this->app->bind(CompetitorAgent::class, function ($app) { + return new \App\Services\Autopodbor\Agent\RealCompetitorAgent( + fetcher: $app->make(\App\Services\Autopodbor\Agent\Fetch\Fetcher::class), + fallback: new \App\Services\Autopodbor\Agent\FakeCompetitorAgent(), + ); + }); + } +``` + +- [ ] **Step 4: Запустить — PASS.** +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Agent/ProviderBindingTest.php` + +- [ ] **Step 5: Прогнать весь блок автоподбора — ничего не сломалось** +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/` +Expected: всё зелёное. + +- [ ] **Step 6: Коммит** (после «эскейп») +```bash +git add app/app/Providers/AutopodborServiceProvider.php app/tests/Unit/Autopodbor/Agent/ProviderBindingTest.php +git commit -m "feat(автоподбор): подмена заглушки на RealCompetitorAgent с фолбэком find/resolve" +``` + +--- + +## Self-Review (выполнено) + +**Покрытие требований:** +- Сырой код сайта → kind=code — Task 3 ✓ +- Подменный (видимый) и скрытый пул — Task 4 ✓ +- Филиалы со страницы «Контакты» с офисом — Task 5 ✓ +- Карточки справочников 2ГИС/Яндекс — Task 6 ✓ +- Свод ядром + сортировка по подтверждениям — Task 7 (через `SourceAggregator` Plan A) ✓ +- Маппинг в существующий контракт `StudyCompetitorResult` (джоба/дедуп/БД не трогаем) — Task 7 ✓ +- Сайты отдельными строками (site_url + сайт из карточек) — Task 7 ✓ +- Глубина сети (филиалы из разных справочников сводятся) — Task 7 Step 5 ✓ +- Живой curl+Playwright fetcher — Task 8 ✓ +- Подмена заглушки одной точкой, с фолбэком find/resolve — Task 9 ✓ + +**Плейсхолдеры:** нет — каждый шаг с реальным кодом и командой. Единственный намеренный разрыв (`directory()`-парсинг живой разметки) явно помечен в Task 8, а его поведение доказано на `FakeFetcher`. + +**Согласованность типов:** кандидат `{number,kind,label,url,office,tracker}` — совпадает с входом `SourceAggregator::aggregate` (Plan A). `FetchedSite{url,rawHtml,visiblePhones,contactsNumbers}` и `DirectoryCard{number,office,url,source,siteUrl}` — едины в Tasks 1/3-7. `CollectedSource{identifier,phoneKind,phoneType,sources[]}` читается в `callRow()` ровно теми именами, что заданы в Plan A. Контракт `StudyCompetitorResult.sources[*]` = `{signal_type,identifier,phone_kind,phone_type,provenance_url,provenance_label}` — совпал с DTO. + +**Известные разрывы (намеренно):** +- `directory()` живого fetcher'а возвращает пустой список до ручной разведки разметки карточек — глубина сети доказана на FakeFetcher. +- Богатый список «где нашли» (несколько источников на номер) схлопывается в один provenance — полный список уедет в БД в Plan C. + +--- + +## Дальнейшие планы + +- **Plan C — Контракт/БД провенанса.** Расширить `StudyCompetitorResult`/`autopodbor_sources` под список «где нашли» + число подтверждений + офис (миграция + CHANGELOG + rls-reviewer). Связать с `RunAutopodborStudyJob`. Доработать `directory()`-парсинг живых карточек 2ГИС/Яндекс. +- **Plan D — Экран «Первый сбор»** (Vue) 1:1 с `…-sbor1.html`. +- **Plan E — Экран «Повторный сбор»** (Vue) 1:1 с `…-sbor2-povtor.html` + backend-диф. From c95bd3ea6066d695b55ef310dbc280c44d11c9f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 10:41:21 +0300 Subject: [PATCH 053/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20DTO=20FetchedSite=20=D0=B8?= =?UTF-8?q?=20DirectoryCard=20=D0=B4=D0=BB=D1=8F=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=87=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../Autopodbor/Agent/Fetch/DirectoryCard.php | 16 ++++++++++++++++ .../Autopodbor/Agent/Fetch/FetchedSite.php | 19 +++++++++++++++++++ .../Unit/Autopodbor/Agent/FetchDtoTest.php | 19 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Fetch/DirectoryCard.php create mode 100644 app/app/Services/Autopodbor/Agent/Fetch/FetchedSite.php create mode 100644 app/tests/Unit/Autopodbor/Agent/FetchDtoTest.php diff --git a/app/app/Services/Autopodbor/Agent/Fetch/DirectoryCard.php b/app/app/Services/Autopodbor/Agent/Fetch/DirectoryCard.php new file mode 100644 index 00000000..4797a7a0 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Fetch/DirectoryCard.php @@ -0,0 +1,16 @@ + $visiblePhones видимые посетителю (отрендеренные) номера, сырые строки + * @param list $contactsNumbers номера со страницы «Контакты» с привязкой к офису + */ + public function __construct( + public readonly string $url, + public readonly string $rawHtml, + public readonly array $visiblePhones = [], + public readonly array $contactsNumbers = [], + ) {} +} diff --git a/app/tests/Unit/Autopodbor/Agent/FetchDtoTest.php b/app/tests/Unit/Autopodbor/Agent/FetchDtoTest.php new file mode 100644 index 00000000..83eab6e9 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Agent/FetchDtoTest.php @@ -0,0 +1,19 @@ +x', + visiblePhones: ['8 (391) 292-00-00'], + contactsNumbers: [['number' => '83912817070', 'office' => 'Кр. рабочий, 61']], + ); + expect($site->url)->toBe('https://k.ru/') + ->and($site->visiblePhones)->toContain('8 (391) 292-00-00') + ->and($site->contactsNumbers[0]['office'])->toBe('Кр. рабочий, 61'); + + $card = new DirectoryCard(number: '83912500000', office: 'Калинина, 185', url: 'https://2gis.ru/firm/1', source: '2ГИС', siteUrl: 'k.ru'); + expect($card->number)->toBe('83912500000')->and($card->source)->toBe('2ГИС')->and($card->siteUrl)->toBe('k.ru'); +}); From bedcff5e76474597c9ca184231c5727f3aa79f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 10:42:04 +0300 Subject: [PATCH 054/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D1=84=D0=B5=D0=B9=D1=81=20Fetcher=20+=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82-=D0=B4=D1=83=D0=B1=D0=BB=D1=8C=20FakeFetcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../Autopodbor/Agent/Fetch/Fetcher.php | 13 ++++++++ .../Unit/Autopodbor/Agent/FakeFetcher.php | 31 +++++++++++++++++++ .../Unit/Autopodbor/Agent/FakeFetcherTest.php | 13 ++++++++ 3 files changed, 57 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Fetch/Fetcher.php create mode 100644 app/tests/Unit/Autopodbor/Agent/FakeFetcher.php create mode 100644 app/tests/Unit/Autopodbor/Agent/FakeFetcherTest.php diff --git a/app/app/Services/Autopodbor/Agent/Fetch/Fetcher.php b/app/app/Services/Autopodbor/Agent/Fetch/Fetcher.php new file mode 100644 index 00000000..b6bc5365 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Fetch/Fetcher.php @@ -0,0 +1,13 @@ + */ + public function directory(string $url): array; +} diff --git a/app/tests/Unit/Autopodbor/Agent/FakeFetcher.php b/app/tests/Unit/Autopodbor/Agent/FakeFetcher.php new file mode 100644 index 00000000..0bf26284 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Agent/FakeFetcher.php @@ -0,0 +1,31 @@ + $sites + * @param array> $cards + */ + public function __construct( + private array $sites = [], + private array $cards = [], + ) {} + + public function site(string $url): FetchedSite + { + return $this->sites[$url] ?? new FetchedSite(url: $url, rawHtml: ''); + } + + public function directory(string $url): array + { + return $this->cards[$url] ?? []; + } +} diff --git a/app/tests/Unit/Autopodbor/Agent/FakeFetcherTest.php b/app/tests/Unit/Autopodbor/Agent/FakeFetcherTest.php new file mode 100644 index 00000000..b1934c19 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Agent/FakeFetcherTest.php @@ -0,0 +1,13 @@ + new FetchedSite(url: 'https://k.ru/', rawHtml: 'x')], + cards: ['https://2gis.ru/firm/1' => []], + ); + expect($fetcher->site('https://k.ru/')->rawHtml)->toContain('73912920000') + ->and($fetcher->directory('https://2gis.ru/firm/1'))->toBe([]); +}); From 8445d5513bcbe49363216a6d61c549b9ca0fa50b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 10:42:05 +0300 Subject: [PATCH 055/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20CandidateBuilder=20=E2=80=94?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=B4/=D0=BF=D0=BE=D0=B4=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9/=D0=BF=D1=83=D0=BB/=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D1=8B/=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../Agent/Extract/CandidateBuilder.php | 106 ++++++++++++++++++ .../Extract/CandidateBuilderTest.php | 66 +++++++++++ 2 files changed, 172 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php create mode 100644 app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php diff --git a/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php b/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php new file mode 100644 index 00000000..4b254221 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php @@ -0,0 +1,106 @@ + $sites + * @param list $cards + * @return list + */ + public function build(array $sites, array $cards): array + { + $out = []; + foreach ($sites as $site) { + $scan = $this->scanner->scan($site->rawHtml); + $hasTracker = $this->detector->detect($site->rawHtml) !== []; + + foreach (array_keys($scan['code']) as $number) { + $out[] = [ + 'number' => (string) $number, + 'kind' => 'code', + 'label' => 'в коде сайта', + 'url' => $site->url, + 'office' => null, + 'tracker' => $hasTracker, + ]; + } + + $codeNumbers = array_map('strval', array_keys($scan['code'])); + + // видимые отрендеренные номера → displayed (подменный), если их нет в коде + $visible = []; + foreach ($site->visiblePhones as $raw) { + $n = $this->normalize($raw); + if ($n === null) { + continue; + } + $visible[] = $n; + if (! in_array($n, $codeNumbers, true)) { + $out[] = [ + 'number' => $n, 'kind' => 'displayed', 'label' => 'показан на сайте (коллтрекинг)', + 'url' => $site->url, 'office' => null, 'tracker' => $hasTracker, + ]; + } + } + + // пул ротации: номер в теле ≥2 раз, не в коде и не видимый, при активном трекере + if ($hasTracker) { + foreach ($scan['body'] as $number => $count) { + $number = (string) $number; + if ($count >= 2 && ! in_array($number, $codeNumbers, true) && ! in_array($number, $visible, true)) { + $out[] = [ + 'number' => $number, 'kind' => 'pool', 'label' => 'пул подмены', + 'url' => $site->url, 'office' => null, 'tracker' => true, + ]; + } + } + } + + // номера филиалов со страницы «Контакты» (рендер) + foreach ($site->contactsNumbers as $row) { + $n = $this->normalize($row['number']); + if ($n === null) { + continue; + } + $out[] = [ + 'number' => $n, 'kind' => 'contacts', 'label' => 'страница «Контакты»', + 'url' => $site->url, 'office' => $row['office'] ?? null, 'tracker' => $hasTracker, + ]; + } + } + + foreach ($cards as $card) { + $n = $this->normalize($card->number); + if ($n === null) { + continue; + } + $out[] = [ + 'number' => $n, 'kind' => 'directory', 'label' => $card->source, + 'url' => $card->url, 'office' => $card->office, 'tracker' => false, + ]; + } + + return $out; + } + + private function normalize(string $raw): ?string + { + $n = $this->norm->phone($raw); + + return (strlen($n) === 11 && $n[0] === '7') ? $n : null; + } +} diff --git a/app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php b/app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php new file mode 100644 index 00000000..3b631159 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php @@ -0,0 +1,66 @@ +x', + ); + $cands = (new CandidateBuilder)->build([$site], []); + + $byNum = collect($cands)->keyBy('number'); + expect($byNum)->toHaveKey('73912920000'); + expect($byNum['73912920000']['kind'])->toBe('code'); + expect($byNum['73912920000']['url'])->toBe('https://k.ru/'); + expect($byNum['73912920000']['tracker'])->toBeTrue(); // roistat найден + expect($byNum['73912920000']['label'])->toBe('в коде сайта'); +}); + +it('размечает видимый подменный и скрытый пул при активном трекере', function () { + $site = new FetchedSite( + url: 'https://k.ru/', + rawHtml: '' + .'8 (391) 111-22-33' // видимый подменный (нет в tel:/schema) + .'8(391)700-00-01 8(391)700-00-01', // пул: 2 вхождения, не код, не видимый + visiblePhones: ['8 (391) 111-22-33'], + ); + $cands = (new CandidateBuilder)->build([$site], []); + $byNum = collect($cands)->keyBy('number'); + + expect($byNum['73911112233']['kind'])->toBe('displayed'); + expect($byNum['73917000001']['kind'])->toBe('pool'); +}); + +it('из страницы контактов делает кандидатов с офисом', function () { + $site = new FetchedSite( + url: 'https://k.ru/', + rawHtml: 'пусто', + contactsNumbers: [ + ['number' => '8(391)281-70-70', 'office' => 'Кр. рабочий, 61'], + ['number' => '8(391)250-00-00', 'office' => 'Калинина, 185'], + ], + ); + $cands = (new CandidateBuilder)->build([$site], []); + $byNum = collect($cands)->keyBy('number'); + + expect($byNum['73912817070']['kind'])->toBe('contacts'); + expect($byNum['73912817070']['office'])->toBe('Кр. рабочий, 61'); + expect($byNum['73912817070']['label'])->toBe('страница «Контакты»'); +}); + +it('из карточек справочника делает кандидатов directory', function () { + $cards = [ + new DirectoryCard(number: '8(391)292-00-00', office: 'Единая справочная', url: 'https://2gis.ru/firm/1', source: '2ГИС'), + new DirectoryCard(number: '8(391)250-00-00', office: 'Калинина, 185', url: 'https://yandex.ru/maps/9', source: 'Яндекс.Карты'), + ]; + $cands = (new CandidateBuilder)->build([], $cards); + $byNum = collect($cands)->keyBy('number'); + + expect($byNum['73912920000']['kind'])->toBe('directory'); + expect($byNum['73912920000']['label'])->toBe('2ГИС'); + expect($byNum['73912920000']['url'])->toBe('https://2gis.ru/firm/1'); + expect($byNum['73912500000']['office'])->toBe('Калинина, 185'); +}); From 751741440488c5c97473ee806f0b15fed15a87ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 10:42:06 +0300 Subject: [PATCH 056/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20RealCompetitorAgent=20?= =?UTF-8?q?=E2=80=94=20=D0=BE=D1=80=D0=BA=D0=B5=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=B4=D0=BE=D0=B1=D1=8B=D1=87=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../Autopodbor/Agent/RealCompetitorAgent.php | 105 ++++++++++++++++++ .../Agent/RealCompetitorAgentTest.php | 60 ++++++++++ 2 files changed, 165 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php create mode 100644 app/tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php diff --git a/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php b/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php new file mode 100644 index 00000000..adb686c7 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php @@ -0,0 +1,105 @@ +fallback->findCompetitors($r); + } + + public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult + { + return $this->fallback->resolveByName($r); + } + + public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult + { + $competitor = $r->competitor; + $siteUrl = $competitor['site_url'] ?? null; + $directoryUrls = $competitor['directory_urls'] ?? []; + + // 1. Грузим сайт(ы) и карточки справочников + $sites = []; + if (is_string($siteUrl) && $siteUrl !== '') { + $sites[] = $this->fetcher->site($this->ensureScheme($siteUrl)); + } + $cards = []; + foreach ($directoryUrls as $du) { + foreach ($this->fetcher->directory($du) as $card) { + $cards[] = $card; + } + } + + // 2. Размечаем кандидатов и сводим ядром + $candidates = $this->builder->build($sites, $cards); + $collected = $this->aggregator->aggregate($candidates); + + // 3. Маппинг в существующий контракт + $rows = []; + + // 3a. Сайты: сайт конкурента + сайты из карточек + $siteIds = []; + if (is_string($siteUrl) && $siteUrl !== '') { + $siteIds[$this->norm->domainHead($siteUrl)] = ['url' => $this->ensureScheme($siteUrl), 'label' => 'сайт конкурента']; + } + foreach ($cards as $card) { + if ($card->siteUrl !== null && $card->siteUrl !== '') { + $siteIds[$this->norm->domainHead($card->siteUrl)] ??= ['url' => $card->url, 'label' => $card->source.' — сайт в карточке']; + } + } + foreach ($siteIds as $domain => $meta) { + $rows[] = ['signal_type' => 'site', 'identifier' => $domain, 'phone_kind' => null, 'phone_type' => null, 'provenance_url' => $meta['url'], 'provenance_label' => $meta['label']]; + } + + // 3b. Телефоны + foreach ($collected as $c) { + $rows[] = $this->callRow($c); + } + + return new StudyCompetitorResult($rows); + } + + /** @return array{signal_type:string,identifier:string,phone_kind:?string,phone_type:?string,provenance_url:?string,provenance_label:?string} */ + private function callRow(CollectedSource $c): array + { + $top = $c->sources[0] ?? ['label' => null, 'url' => null]; + + return [ + 'signal_type' => 'call', + 'identifier' => $c->identifier, + 'phone_kind' => $c->phoneKind, + 'phone_type' => $c->phoneType, + 'provenance_url' => $top['url'] ?? null, + 'provenance_label' => $top['label'] ?? null, + ]; + } + + private function ensureScheme(string $url): string + { + return preg_match('#^[a-z]+://#i', $url) ? $url : 'https://'.$url; + } +} diff --git a/app/tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php b/app/tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php new file mode 100644 index 00000000..113d5b36 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php @@ -0,0 +1,60 @@ + new FetchedSite( + url: 'https://k.ru', + rawHtml: 'x8(391)111-22-33', + visiblePhones: ['8(391)111-22-33'], + ), + ], + cards: [ + 'https://2gis.ru/firm/1' => [ + new DirectoryCard(number: '8(391)292-00-00', office: 'Единая справочная', url: 'https://2gis.ru/firm/1', source: '2ГИС'), + ], + ], + ); + $agent = new RealCompetitorAgent($fetcher, new FakeCompetitorAgent); + + $res = $agent->studyCompetitor(new StudyCompetitorRequest( + competitor: ['name' => 'КрасЛомбард', 'site_url' => 'k.ru', 'directory_urls' => ['https://2gis.ru/firm/1']], + regionCode: 391, + )); + + $calls = array_values(array_filter($res->sources, fn ($s) => $s['signal_type'] === 'call')); + $sites = array_values(array_filter($res->sources, fn ($s) => $s['signal_type'] === 'site')); + + // настоящий 292-00-00 (код+2ГИС = 2 подтверждения) выше подменного 111-22-33 + expect($calls[0]['identifier'])->toBe('73912920000'); + expect($calls[0]['phone_kind'])->toBe('real'); + expect(end($calls)['phone_kind'])->toBe('substitute'); + // сайт конкурента — отдельной строкой + expect(collect($sites)->pluck('identifier'))->toContain('k.ru'); +}); + +it('сводит филиалы из разных справочников: больше подтверждений — выше', function () { + $fetcher = new FakeFetcher( + sites: ['https://k.ru' => new FetchedSite(url: 'https://k.ru', rawHtml: 'x')], + cards: [ + 'https://2gis.ru/firm/1' => [new DirectoryCard(number: '8(391)292-00-00', office: 'Справочная', url: 'https://2gis.ru/firm/1', source: '2ГИС')], + 'https://yandex.ru/maps/2' => [new DirectoryCard(number: '8(391)281-70-70', office: 'Кр. рабочий, 61', url: 'https://yandex.ru/maps/2', source: 'Яндекс.Карты')], + ], + ); + $agent = new RealCompetitorAgent($fetcher, new FakeCompetitorAgent); + $res = $agent->studyCompetitor(new StudyCompetitorRequest( + competitor: ['name' => 'КрасЛомбард', 'site_url' => 'k.ru', 'directory_urls' => ['https://2gis.ru/firm/1', 'https://yandex.ru/maps/2']], + regionCode: 391, + )); + $calls = array_values(array_filter($res->sources, fn ($s) => $s['signal_type'] === 'call')); + // 292-00-00: код + 2ГИС = 2 подтверждения → выше, чем 281-70-70 (только Яндекс = 1) + expect($calls[0]['identifier'])->toBe('73912920000'); + expect(collect($calls)->pluck('identifier'))->toContain('73912817070'); +}); From b0e9573c812329dd2f57d8e2174f69dc72139882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 10:42:08 +0300 Subject: [PATCH 057/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B6=D0=B8=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=20CurlPlaywrightFetcher=20(curl=20+=20Playwright-=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B5=D1=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../Agent/Fetch/CurlPlaywrightFetcher.php | 95 +++++++++++++++++++ app/scripts/render-page.cjs | 19 ++++ 2 files changed, 114 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Fetch/CurlPlaywrightFetcher.php create mode 100644 app/scripts/render-page.cjs diff --git a/app/app/Services/Autopodbor/Agent/Fetch/CurlPlaywrightFetcher.php b/app/app/Services/Autopodbor/Agent/Fetch/CurlPlaywrightFetcher.php new file mode 100644 index 00000000..f4d3ce92 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Fetch/CurlPlaywrightFetcher.php @@ -0,0 +1,95 @@ +renderScript === '') { + $this->renderScript = base_path('scripts/render-page.cjs'); + } + } + + public function site(string $url): FetchedSite + { + $raw = $this->curl($url); + + $visible = []; + $contacts = []; + $rendered = $this->render($url); + if ($rendered !== null) { + $visible = $rendered['visiblePhones'] ?? []; + // номера со страницы /contacts добираем отдельным рендером, если есть такая ссылка + $contactsUrl = $this->guessContactsUrl($url, $raw); + if ($contactsUrl !== null) { + $rc = $this->render($contactsUrl); + if ($rc !== null) { + foreach (($rc['visiblePhones'] ?? []) as $p) { + $contacts[] = ['number' => $p, 'office' => null]; + } + } + } + } + + return new FetchedSite(url: $url, rawHtml: $raw, visiblePhones: $visible, contactsNumbers: $contacts); + } + + public function directory(string $url): array + { + // Карточки 2ГИС/Яндекс — рендерим и вытаскиваем филиалы. Парсинг конкретной разметки + // выносится в доработку Plan C; здесь — безопасный дефолт: пустой список, чтобы не падать. + return []; + } + + private function curl(string $url): string + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; LiderraBot/1.0)', + CURLOPT_SSL_VERIFYPEER => false, + ]); + $body = curl_exec($ch); + curl_close($ch); + + return is_string($body) ? $body : ''; + } + + /** @return array{html:string,visiblePhones:list}|null */ + private function render(string $url): ?array + { + $p = new Process([$this->nodeBin, $this->renderScript, $url]); + $p->setTimeout($this->timeout); + $p->run(); + if (! $p->isSuccessful()) { + return null; + } + $json = json_decode($p->getOutput(), true); + + return is_array($json) ? $json : null; + } + + private function guessContactsUrl(string $base, string $raw): ?string + { + if (preg_match('#href=["\']([^"\']*(?:contacts?|kontakty)[^"\']*)["\']#i', $raw, $m)) { + $href = $m[1]; + if (preg_match('#^https?://#i', $href)) { + return $href; + } + + return rtrim($base, '/').'/'.ltrim($href, '/'); + } + + return null; + } +} diff --git a/app/scripts/render-page.cjs b/app/scripts/render-page.cjs new file mode 100644 index 00000000..81bc9f8f --- /dev/null +++ b/app/scripts/render-page.cjs @@ -0,0 +1,19 @@ +// Usage: node render-page.cjs +// Печатает JSON: { html, visiblePhones: [...] } — отрендеренный DOM и видимые tel:-номера. +const { chromium } = require('playwright'); + +(async () => { + const url = process.argv[2]; + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); + const html = await page.content(); + const visiblePhones = await page.$$eval('a[href^="tel:"]', els => + els.map(e => (e.textContent || '').trim()).filter(Boolean) + ); + process.stdout.write(JSON.stringify({ html, visiblePhones })); + } finally { + await browser.close(); + } +})().catch(e => { console.error(String(e)); process.exit(1); }); From 692a889fc61a08099cdca480c483de4f556acf2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 10:42:28 +0300 Subject: [PATCH 058/417] =?UTF-8?q?style(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20pint-=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=D0=BC=D0=B5=D0=B6=D0=BD=D1=8B=D1=85=20DTO/?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- app/app/Services/Autopodbor/Agent/CompetitorAgent.php | 9 ++++++++- .../Autopodbor/Agent/Dto/FindCompetitorsResult.php | 2 +- .../Autopodbor/Agent/Dto/ResolveByNameResult.php | 2 +- .../Autopodbor/Agent/Dto/StudyCompetitorRequest.php | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/app/Services/Autopodbor/Agent/CompetitorAgent.php b/app/app/Services/Autopodbor/Agent/CompetitorAgent.php index 79abf78b..827e616e 100644 --- a/app/app/Services/Autopodbor/Agent/CompetitorAgent.php +++ b/app/app/Services/Autopodbor/Agent/CompetitorAgent.php @@ -2,11 +2,18 @@ namespace App\Services\Autopodbor\Agent; -use App\Services\Autopodbor\Agent\Dto\{FindCompetitorsRequest, FindCompetitorsResult, StudyCompetitorRequest, StudyCompetitorResult, ResolveByNameRequest, ResolveByNameResult}; +use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest; +use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult; +use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest; +use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult; +use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest; +use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult; interface CompetitorAgent { public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult; + public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult; + public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult; } diff --git a/app/app/Services/Autopodbor/Agent/Dto/FindCompetitorsResult.php b/app/app/Services/Autopodbor/Agent/Dto/FindCompetitorsResult.php index 3eabab09..bd31f5c6 100644 --- a/app/app/Services/Autopodbor/Agent/Dto/FindCompetitorsResult.php +++ b/app/app/Services/Autopodbor/Agent/Dto/FindCompetitorsResult.php @@ -5,7 +5,7 @@ namespace App\Services\Autopodbor\Agent\Dto; final class FindCompetitorsResult { /** - * @param array $competitors + * @param array $competitors */ public function __construct(public readonly array $competitors) {} } diff --git a/app/app/Services/Autopodbor/Agent/Dto/ResolveByNameResult.php b/app/app/Services/Autopodbor/Agent/Dto/ResolveByNameResult.php index 2057c1b8..d67e109c 100644 --- a/app/app/Services/Autopodbor/Agent/Dto/ResolveByNameResult.php +++ b/app/app/Services/Autopodbor/Agent/Dto/ResolveByNameResult.php @@ -5,7 +5,7 @@ namespace App\Services\Autopodbor\Agent\Dto; final class ResolveByNameResult { /** - * @param array $candidates + * @param array $candidates */ public function __construct(public readonly array $candidates) {} } diff --git a/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorRequest.php b/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorRequest.php index 41335797..1de7a967 100644 --- a/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorRequest.php +++ b/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorRequest.php @@ -5,7 +5,7 @@ namespace App\Services\Autopodbor\Agent\Dto; final class StudyCompetitorRequest { /** - * @param array{name:string,site_url?:?string,directory_urls?:array} $competitor + * @param array{name:string,site_url?:?string,directory_urls?:array} $competitor */ public function __construct( public readonly array $competitor, From 5459b4edd33f4b5c5e03bc705e4971125908fe3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 10:42:47 +0300 Subject: [PATCH 059/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=BF=D0=BB=D0=B0=D0=BD=20P?= =?UTF-8?q?lan=20A=20(=D1=8F=D0=B4=D1=80=D0=BE)=20+=20resume-=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=BC=D0=BF=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...6-06-30-real-engine-NEXT-SESSION-PROMPT.md | 35 ++ .../2026-06-30-real-engine-step2-core.md | 593 ++++++++++++++++++ 2 files changed, 628 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-real-engine-NEXT-SESSION-PROMPT.md create mode 100644 docs/superpowers/plans/2026-06-30-real-engine-step2-core.md diff --git a/docs/superpowers/plans/2026-06-30-real-engine-NEXT-SESSION-PROMPT.md b/docs/superpowers/plans/2026-06-30-real-engine-NEXT-SESSION-PROMPT.md new file mode 100644 index 00000000..e4b0b85c --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-real-engine-NEXT-SESSION-PROMPT.md @@ -0,0 +1,35 @@ +# Промпт-продолжение: сборка живого движка шага 2 (после компакта) + +Скопируй этот блок в новый промпт, чтобы продолжить. + +--- + +Продолжаем сборку **живого движка шага 2** фичи «Конкурентное поле» (воркстри `worktree-avtopodbor`, +код под `app/`, НЕ прод). Контекст полностью зафиксирован — прочитай в начале: + +1. **План слоя A:** `docs/superpowers/plans/2026-06-30-real-engine-step2-core.md` (7 задач по TDD). +2. **R&D и механизм:** `docs/superpowers/findings/2026-06-30-real-engine-step2-research.md`. +3. **Память:** `project-real-engine-step2-research-2026-06-30.md` (раздел «СОСТОЯНИЕ на 30.06»). +4. **Эталоны экранов (1:1):** `docs/superpowers/prototypes/2026-06-30-konkurentnoe-pole-istochniki-sbor1.html` + (первый сбор) и `…-sbor2-povtor.html` (повтор). Рабочий прототип логики: `scratchpad/engine_spike*.php`. + +**Решение владельца:** собираем **A→B→C→D→E по TDD**, автономно; коммит — сначала спросить «эскейп». +Деньги/дедуп/RLS/идемпотентность УЖЕ готовы — движок их не трогает (чистая функция «вход → источники»). +Замена заглушки `FakeCompetitorAgent` → `RealCompetitorAgent` = одна строка в `AutopodborServiceProvider`. + +**Задача сейчас:** начать **Plan A, Task 1** (класс `Extract/PhoneType`) строго по TDD +(красный тест → запуск-падение → минимальная реализация → запуск-зелёный → стоп перед коммитом, спросить «эскейп»), +затем Task 2…7 из плана. Тест-раннер: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/`. + +**Ключевые правила механизма (не потерять):** +- Настоящий номер — из структурного кода (`tel:`/schema/microdata) + страница контактов (рендер) + 2ГИС/Яндекс + e-mail. +- Подменный 🎭 — видимый номер коллтрекинга (Roistat/Callibri/…); внутренний ПУЛ ротации = свалка, СКРЫВАЕМ. + В UI подменный — «можно использовать, но эффект слабее» (НЕ «не для работы»). +- Глубокий сбор = вся СЕТЬ конкурента: все филиалы (у КрасЛомбарда 22, рендер дал 27 номеров vs 14 из кода) + все сайты. +- У каждого номера — список «где нашли» (кликабельные), сортировка: больше подтверждений выше, подменные вниз. +- Повторный сбор — отдельный экран-диф: новые / не найдены повторно (проверь сам) / без изменений. + +**Среда (квирки):** PHP CLI `c:/tools/php83/php`; Playwright `file://` заблокирован → показывать экраны через +`php -S 127.0.0.1:8100 -t `; рендер страниц — наш Playwright (xfetch/Firecrawl не используем). + +Стартуй с Plan A Task 1. Перед каждым коммитом — попроси «эскейп». diff --git a/docs/superpowers/plans/2026-06-30-real-engine-step2-core.md b/docs/superpowers/plans/2026-06-30-real-engine-step2-core.md new file mode 100644 index 00000000..4c927224 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-real-engine-step2-core.md @@ -0,0 +1,593 @@ +# Живой движок шага 2 — ядро извлечения (Plan A) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Продакшен-ядро движка шага 2 — чистые PHP-классы, которые из исходного кода страниц + отрендеренных номеров + данных справочников строят итоговый список источников конкурента (настоящий/подменный, тип, офис, «где нашли» список, число подтверждений), с дедупом, скрытием пула-свалки и сортировкой по подтверждениям. + +**Architecture:** 4 чистых модуля без инфраструктуры (всё тестируется юнит-тестами на синтетических данных): `PhoneType` (тип по коду), `HtmlPhoneScanner` (номера из кода: tel/schema/microdata + тело/e-mail), `CalltrackingDetector` (детект трекера), `SourceAggregator` (свод кандидатов → классификация/дедуп/сортировка). Реальные fetch/render/2ГИС и сборка `RealCompetitorAgent` — отдельный Plan B; экраны — Plan D/E. + +**Tech Stack:** PHP 8.3, Pest 4. Переиспользуем `App\Services\Autopodbor\AutopodborNormalizer` (телефон→`7XXXXXXXXXX`, голова домена). + +**Граница плана (scope):** только ЧИСТОЕ ядро. НЕ входит: HTTP-загрузка (curl/Guzzle), рендер (Playwright), обход 2ГИС/Яндекс, правка БД/контракта DTO `StudyCompetitorResult`, фронт. Это Plan B–E (перечислены в конце). Ядро самодостаточно и полностью покрыто тестами. + +**Эталоны:** макеты `docs/superpowers/prototypes/2026-06-30-konkurentnoe-pole-istochniki-sbor1.html` (первый сбор) и `…-sbor2-povtor.html` (повтор). R&D: `docs/superpowers/findings/2026-06-30-real-engine-step2-research.md`. Рабочий прототип логики: `scratchpad/engine_spike.php`, `engine_spike2.php`. + +--- + +## Структура файлов + +| Файл | Ответственность | +|---|---| +| `app/app/Services/Autopodbor/Agent/Extract/PhoneType.php` | Тип номера (city/mobile/tollfree) по коду | +| `app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php` | Из сырого HTML → номера в коде (tel/schema/microdata), счётчик тела, e-mail-цифры | +| `app/app/Services/Autopodbor/Agent/Extract/CalltrackingDetector.php` | Из HTML → список систем коллтрекинга | +| `app/app/Services/Autopodbor/Agent/Dto/CollectedSource.php` | Итоговый источник: id, тип, kind, office, sources[] «где нашли» | +| `app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php` | Свод кандидатов → классификация/дедуп/скрытие пула/сортировка | +| `app/tests/Unit/Autopodbor/Extract/*Test.php` | Pest-юниты на каждый модуль | + +--- + +## Task 1: Тип телефона (PhoneType) + +**Files:** +- Create: `app/app/Services/Autopodbor/Agent/Extract/PhoneType.php` +- Test: `app/tests/Unit/Autopodbor/Extract/PhoneTypeTest.php` + +- [ ] **Step 1: Написать падающий тест** + +```php +toBe('city'); // 391 — городской + expect(PhoneType::of('79135396546'))->toBe('mobile'); // 9xx — мобильный + expect(PhoneType::of('78002001122'))->toBe('tollfree'); // 800 — бесплатный +}); +``` + +- [ ] **Step 2: Запустить — убедиться, что падает** + +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/PhoneTypeTest.php` +Expected: FAIL — `Class "App\Services\Autopodbor\Agent\Extract\PhoneType" not found`. + +- [ ] **Step 3: Минимальная реализация** + +```php + array>, 'body' => array, 'emails' => list]` +где `code[number]` — список слотов кода (`'tel'|'schema'|'microdata'`), `body[number]` — число вхождений номера в тело, `emails` — цифровые локал-части e-mail. + +- [ ] **Step 1: Написать падающий тест (tel:)** + +```php +звонок2'; + $r = (new HtmlPhoneScanner())->scan($html); + expect($r['code'])->toHaveKey('78432032533') + ->and($r['code']['78432032533'])->toContain('tel') + ->and($r['code'])->toHaveKey('78432452533'); +}); +``` + +- [ ] **Step 2: Запустить — FAIL** (`Class … HtmlPhoneScanner not found`). +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php` + +- [ ] **Step 3: Реализация (только tel:)** + +```php +>, body: array, emails: list} */ + public function scan(string $html): array + { + $code = []; + $add = function (string $raw, string $slot) use (&$code): void { + $n = $this->normalizeMaybe($raw); + if ($n === null) return; + $code[$n] = $code[$n] ?? []; + if (! in_array($slot, $code[$n], true)) $code[$n][] = $slot; + }; + + if (preg_match_all('/tel:([+0-9()\s-]{7,})/i', $html, $m)) { + foreach ($m[1] as $x) $add($x, 'tel'); + } + + return ['code' => $code, 'body' => [], 'emails' => []]; + } + + private function normalizeMaybe(string $raw): ?string + { + $digits = preg_replace('/\D+/', '', $raw) ?? ''; + if (strlen($digits) === 11 && ($digits[0] === '8' || $digits[0] === '7')) return '7'.substr($digits, 1); + if (strlen($digits) === 10) return '7'.$digits; + return null; + } +} +``` + +- [ ] **Step 4: Запустить — PASS.** + +- [ ] **Step 5: Добавить тест schema/microdata (RED)** + +```php +it('берёт номера из schema.org и microdata', function () { + $html = '' + . 'x'; + $r = (new HtmlPhoneScanner())->scan($html); + expect($r['code']['78432032533'])->toContain('schema') + ->and($r['code']['78432452533'])->toContain('microdata'); +}); +``` + +- [ ] **Step 6: Запустить — FAIL** (schema/microdata ещё не парсятся). + +- [ ] **Step 7: Дополнить scan() (перед return)** + +```php + if (preg_match_all('/"telephone"\s*:\s*"([^"]+)"/i', $html, $m)) { + foreach ($m[1] as $x) $add($x, 'schema'); + } + if (preg_match_all('/itemprop=["\']telephone["\'][^>]*content=["\']([^"\']+)/i', $html, $m)) { + foreach ($m[1] as $x) $add($x, 'microdata'); + } +``` + +- [ ] **Step 8: Запустить — PASS.** + +- [ ] **Step 9: Тест тела и e-mail (RED)** + +```php +it('считает вхождения в тело и берёт e-mail-цифры', function () { + $html = 'тел 8(843)203-25-33, ещё 8(843)203-25-33. почта 2032533@mail.ru'; + $r = (new HtmlPhoneScanner())->scan($html); + expect($r['body']['78432032533'])->toBe(2) + ->and($r['emails'])->toContain('2032533'); +}); +``` + +- [ ] **Step 10: Запустить — FAIL.** + +- [ ] **Step 11: Дополнить scan() (перед return) телом и e-mail** + +```php + $body = []; + if (preg_match_all('/(?:\+7|8)[\s(\-]*\d{3}[\s)\-]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}/', $html, $m)) { + foreach ($m[0] as $x) { + $n = $this->normalizeMaybe($x); + if ($n !== null) $body[$n] = ($body[$n] ?? 0) + 1; + } + } + $emails = []; + if (preg_match_all('/([a-z0-9._%+-]+)@[a-z0-9.-]+\.[a-z]{2,}/i', $html, $m)) { + foreach ($m[1] as $local) { + $d = preg_replace('/\D+/', '', $local) ?? ''; + if (strlen($d) >= 7) $emails[] = $d; + } + } +``` +И заменить `return ['code' => $code, 'body' => [], 'emails' => []];` на +`return ['code' => $code, 'body' => $body, 'emails' => $emails];` + +- [ ] **Step 12: Запустить — PASS, и весь файл теста зелёный.** +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php` + +- [ ] **Step 13: Коммит** (после «эскейп») +```bash +git add app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php app/tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php +git commit -m "feat(автоподбор): HtmlPhoneScanner — номера из кода (tel/schema/microdata/тело/email)" +``` + +--- + +## Task 3: Детектор коллтрекинга + +**Files:** +- Create: `app/app/Services/Autopodbor/Agent/Extract/CalltrackingDetector.php` +- Test: `app/tests/Unit/Autopodbor/Extract/CalltrackingDetectorTest.php` + +- [ ] **Step 1: Падающий тест** + +```php +detect(''))->toBe(['roistat']); + expect((new CalltrackingDetector())->detect('
'))->toBe(['callibri']); + expect((new CalltrackingDetector())->detect('чисто'))->toBe([]); +}); +``` + +- [ ] **Step 2: Запустить — FAIL.** + +- [ ] **Step 3: Реализация** + +```php + */ + public function detect(string $html): array + { + $found = []; + foreach (self::PROVIDERS as $p) { + if (stripos($html, $p) !== false) $found[] = $p; + } + return array_values(array_unique($found)); + } +} +``` + +- [ ] **Step 4: Запустить — PASS.** + +- [ ] **Step 5: Коммит** (после «эскейп») +```bash +git add app/app/Services/Autopodbor/Agent/Extract/CalltrackingDetector.php app/tests/Unit/Autopodbor/Extract/CalltrackingDetectorTest.php +git commit -m "feat(автоподбор): CalltrackingDetector — детект систем подмены номера" +``` + +--- + +## Task 4: DTO итогового источника (CollectedSource) + +**Files:** +- Create: `app/app/Services/Autopodbor/Agent/Dto/CollectedSource.php` +- Test: `app/tests/Unit/Autopodbor/Extract/CollectedSourceTest.php` + +- [ ] **Step 1: Падающий тест** + +```php + 'в коде сайта', 'url' => 'https://x.ru/'], ['label' => '2ГИС', 'url' => 'https://2gis.ru/1']], + ); + expect($s->confirmations())->toBe(2)->and($s->phoneKind)->toBe('real'); +}); +``` + +- [ ] **Step 2: Запустить — FAIL.** + +- [ ] **Step 3: Реализация** + +```php + $sources */ + public function __construct( + public readonly string $signalType, // call | site + public readonly string $identifier, // 7XXXXXXXXXX | домен + public readonly ?string $phoneKind, // real | substitute | null + public readonly ?string $phoneType, // city | mobile | tollfree | null + public readonly ?string $office, // подпись филиала | null + public readonly array $sources, // «где нашли» + ) {} + + public function confirmations(): int + { + return count($this->sources); + } +} +``` + +- [ ] **Step 4: Запустить — PASS.** + +- [ ] **Step 5: Коммит** (после «эскейп») +```bash +git add app/app/Services/Autopodbor/Agent/Dto/CollectedSource.php app/tests/Unit/Autopodbor/Extract/CollectedSourceTest.php +git commit -m "feat(автоподбор): DTO CollectedSource — источник с провенансом и подтверждениями" +``` + +--- + +## Task 5: Агрегатор — дедуп + объединение провенанса + +**Files:** +- Create: `app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php` +- Test: `app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php` + +`SourceAggregator::aggregate(array $candidates): list` +Вход — список кандидатов (их готовит Plan B из сканера/рендера/справочников): +`['number' => '7XXXXXXXXXX', 'kind' => 'code'|'contacts'|'directory'|'email'|'pool'|'displayed', 'label' => string, 'url' => ?string, 'office' => ?string, 'tracker' => bool]` +Несколько кандидатов на один номер сливаются в один `CollectedSource` с объединённым `sources[]`. + +**Правила классификации** (как в утверждённом спайке): +- `pool` — внутренняя ротация трекера: **скрываем** (в выход не идёт). +- `displayed` при активном трекере и без «настоящих» источников → `phoneKind='substitute'` (🎭). +- иначе (есть хоть один источник kind ∈ {code, contacts, directory, email}) → `phoneKind='real'`. +- `office` берём из первого кандидата, где он задан. + +- [ ] **Step 1: Падающий тест (дедуп + объединение)** + +```php +aggregate([ + ['number' => '73912920000', 'kind' => 'code', 'label' => 'в коде сайта', 'url' => 'https://k.ru/', 'office' => null, 'tracker' => true], + ['number' => '73912920000', 'kind' => 'contacts', 'label' => 'страница «Контакты»','url' => 'https://k.ru/c', 'office' => 'Единая справочная', 'tracker' => true], + ]); + expect($out)->toHaveCount(1); + expect($out[0]->identifier)->toBe('73912920000'); + expect($out[0]->confirmations())->toBe(2); + expect($out[0]->office)->toBe('Единая справочная'); + expect($out[0]->phoneKind)->toBe('real'); + expect($out[0]->phoneType)->toBe('city'); +}); +``` + +- [ ] **Step 2: Запустить — FAIL.** + +- [ ] **Step 3: Реализация (дедуп + классификация real)** + +```php + $candidates + * @return list + */ + public function aggregate(array $candidates): array + { + /** @var array,kinds:list,office:?string,tracker:bool}> $by */ + $by = []; + foreach ($candidates as $c) { + $n = $c['number']; + $by[$n] ??= ['sources' => [], 'kinds' => [], 'office' => null, 'tracker' => false]; + $by[$n]['kinds'][] = $c['kind']; + $by[$n]['tracker'] = $by[$n]['tracker'] || $c['tracker']; + $by[$n]['office'] ??= $c['office']; + if ($c['kind'] !== 'pool') { + $key = $c['label']; + if (! isset($by[$n]['sources'][$key])) { + $by[$n]['sources'][$key] = ['label' => $c['label'], 'url' => $c['url']]; + } + } + } + + $out = []; + foreach ($by as $n => $info) { + $kinds = $info['kinds']; + $hasTrusted = (bool) array_intersect(self::TRUSTED, $kinds); + $onlyHidden = ! $hasTrusted && ! in_array('displayed', $kinds, true); // только pool + if ($onlyHidden) { + continue; // пул-свалка — не выводим + } + $kind = $hasTrusted ? 'real' : 'substitute'; + $out[] = new CollectedSource( + signalType: 'call', + identifier: $n, + phoneKind: $kind, + phoneType: PhoneType::of($n), + office: $info['office'], + sources: array_values($info['sources']), + ); + } + + return $out; + } +} +``` + +- [ ] **Step 4: Запустить — PASS.** + +- [ ] **Step 5: Тест скрытия пула + пометки подменного (RED)** + +```php +it('скрывает пул ротации и помечает видимый подменный', function () { + $out = (new SourceAggregator())->aggregate([ + ['number' => '79012414545', 'kind' => 'displayed', 'label' => 'Callibri (на сайте)', 'url' => 'https://k.ru/', 'office' => null, 'tracker' => true], + ['number' => '70489624563', 'kind' => 'pool', 'label' => 'пул', 'url' => null, 'office' => null, 'tracker' => true], + ]); + expect($out)->toHaveCount(1); // пул скрыт + expect($out[0]->identifier)->toBe('79012414545'); + expect($out[0]->phoneKind)->toBe('substitute'); +}); +``` + +- [ ] **Step 6: Запустить — PASS** (логика уже реализована в Step 3; тест подтверждает). + +- [ ] **Step 7: Коммит** (после «эскейп») +```bash +git add app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php +git commit -m "feat(автоподбор): SourceAggregator — дедуп, классификация, скрытие пула" +``` + +--- + +## Task 6: Сортировка по числу подтверждений + +**Files:** +- Modify: `app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php` +- Modify: `app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php` + +- [ ] **Step 1: Падающий тест (RED)** + +```php +it('сортирует: больше подтверждений — выше, подменные — в самый низ', function () { + $out = (new SourceAggregator())->aggregate([ + // 1 подтверждение + ['number' => '73912500000', 'kind' => 'contacts', 'label' => 'контакты', 'url' => null, 'office' => null, 'tracker' => true], + // 2 подтверждения + ['number' => '73912920000', 'kind' => 'code', 'label' => 'код', 'url' => null, 'office' => null, 'tracker' => true], + ['number' => '73912920000', 'kind' => 'contacts', 'label' => 'контакты', 'url' => null, 'office' => null, 'tracker' => true], + // подменный + ['number' => '79012414545', 'kind' => 'displayed','label' => 'Callibri', 'url' => null, 'office' => null, 'tracker' => true], + ]); + expect(array_map(fn ($s) => $s->identifier, $out)) + ->toBe(['73912920000', '73912500000', '79012414545']); +}); +``` + +- [ ] **Step 2: Запустить — FAIL** (порядок не гарантирован). +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php` + +- [ ] **Step 3: Добавить сортировку перед `return $out;`** + +```php + usort($out, function (CollectedSource $a, CollectedSource $b): int { + $sa = $a->phoneKind === 'substitute' ? 1 : 0; + $sb = $b->phoneKind === 'substitute' ? 1 : 0; + if ($sa !== $sb) return $sa <=> $sb; // подменные — вниз + if ($a->confirmations() !== $b->confirmations()) { + return $b->confirmations() <=> $a->confirmations(); // больше подтверждений — выше + } + return strcmp($a->identifier, $b->identifier); + }); +``` + +- [ ] **Step 4: Запустить — PASS, и весь файл зелёный.** + +- [ ] **Step 5: Коммит** (после «эскейп») +```bash +git add app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php +git commit -m "feat(автоподбор): сортировка источников по числу подтверждений" +``` + +--- + +## Task 7: Прогон ядра на живых данных (regression-фикстуры) + +**Files:** +- Test: `app/tests/Unit/Autopodbor/Extract/SourceAggregatorLiveTest.php` + +Зафиксировать поведение на реальных данных КрасЛомбарда из R&D (чтобы ядро не регрессировало): из реальных кандидатов получаем 27 настоящих + 1 подменный, `292-00-00` — сверху (5 источников). + +- [ ] **Step 1: Тест на упрощённой выборке реальных данных (RED)** + +```php +'73912920000','kind'=>'code','label'=>'код','url'=>null,'office'=>'Единая справочная','tracker'=>true], + ['number'=>'73912920000','kind'=>'contacts','label'=>'контакты','url'=>null,'office'=>null,'tracker'=>true], + ['number'=>'73912920000','kind'=>'directory','label'=>'2ГИС','url'=>'https://2gis.ru/1','office'=>null,'tracker'=>true], + ['number'=>'73912817070','kind'=>'code','label'=>'код','url'=>null,'office'=>'Кр. рабочий, 61','tracker'=>true], + ['number'=>'73912500000','kind'=>'contacts','label'=>'контакты','url'=>null,'office'=>'Калинина, 185','tracker'=>true], + ['number'=>'79012414545','kind'=>'displayed','label'=>'Callibri','url'=>null,'office'=>null,'tracker'=>true], + ['number'=>'70489624563','kind'=>'pool','label'=>'пул','url'=>null,'office'=>null,'tracker'=>true], + ]; + $out = (new SourceAggregator())->aggregate($cand); + $ids = array_map(fn ($s) => $s->identifier, $out); + + expect($ids)->not->toContain('70489624563'); // пул скрыт + expect($ids[0])->toBe('73912920000'); // 3 подтверждения — сверху + expect(end($out)->phoneKind)->toBe('substitute'); // подменный — внизу +}); +``` + +- [ ] **Step 2: Запустить — PASS** (логика готова; тест фиксирует поведение на живых данных). +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/SourceAggregatorLiveTest.php` + +- [ ] **Step 3: Прогнать весь блок Extract** +Run: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor/Extract/` +Expected: все зелёные. + +- [ ] **Step 4: Коммит** (после «эскейп») +```bash +git add app/tests/Unit/Autopodbor/Extract/SourceAggregatorLiveTest.php +git commit -m "test(автоподбор): regression-фикстура ядра на данных КрасЛомбарда" +``` + +--- + +## Self-Review (выполнено) + +**Покрытие требований ядра:** +- Номера из кода (tel/schema/microdata) — Task 2 ✓ +- E-mail-подтверждение — Task 2 (emails) + Task 5 (kind=email как trusted) ✓ +- Детект коллтрекинга — Task 3 ✓ +- Тип номера — Task 1 ✓ +- «Где нашли» список + дедуп провенанса — Task 5 ✓ +- Скрытие пула-свалки + пометка подменного — Task 5 ✓ +- Сортировка по подтверждениям, подменный вниз — Task 6 ✓ +- Живой regression — Task 7 ✓ + +**Плейсхолдеры:** нет — каждый шаг с реальным кодом и командой. +**Согласованность типов:** `scan()` → `{code,body,emails}`; кандидат-форма `{number,kind,label,url,office,tracker}`; `CollectedSource(signalType,identifier,phoneKind,phoneType,office,sources)` — едина во всех тасках. `PhoneType::of` и `confirmations()` — имена совпадают везде. + +**Известный разрыв (намеренно вне Plan A):** ядро классифицирует уже размеченных кандидатов. Превращение страниц/рендера/2ГИС в размеченных кандидатов (kind=code/contacts/directory/pool/displayed) — Plan B. + +--- + +## Дальнейшие планы (порядок сборки) + +- **Plan B — Fetch/Render/Directory → RealCompetitorAgent.** curl сырого кода + рендер страниц (Playwright из PHP через Process/node) + обход филиалов 2ГИС/Яндекс → формирование размеченных кандидатов → `aggregate()`. Биндинг в `AutopodborServiceProvider` (замена `FakeCompetitorAgent` одной строкой). Тесты с фейковым fetcher'ом. +- **Plan C — Контракт/БД провенанса.** Расширить `StudyCompetitorResult`/`autopodbor_sources` под список «где нашли» + число подтверждений + офис (миграция + CHANGELOG + rls-reviewer). Связать с `RunAutopodborStudyJob`. +- **Plan D — Экран «Первый сбор»** (Vue) один-в-один с `…-sbor1.html`: сортировка по подтверждениям, «где нашли»-плашки кликабельные, подписи офисов, многолинейные офисы отдельными строками, подменный «можно, но эффект слабее». +- **Plan E — Экран «Повторный сбор»** (Vue) один-в-один с `…-sbor2-povtor.html`: диф новые/не найдены/без изменений; backend-логика дифа повторного сбора. + +После «ок» по Plan A — собираем его по TDD, затем переходим к Plan B. From 7cb1bd3fbe9d5aea70ca84d04b18cf439a1a5c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 10:45:14 +0300 Subject: [PATCH 060/417] =?UTF-8?q?fix(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=83=D0=BA=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20fetcher=20=E2=80=94=20TL?= =?UTF-8?q?S-=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0,=20SSRF-?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=B6,=20=D0=B1=D0=B5=D0=B7=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CURLOPT_SSL_VERIFYPEER/VERIFYHOST включены - isSafeUrl: только http/https, блок loopback/приватных/служебных IP - FOLLOWLOCATION выключен, протоколы ограничены HTTP/HTTPS - render-page.cjs валидирует схему URL перед навигацией Co-Authored-By: Claude Opus 4.8 --- .../Agent/Fetch/CurlPlaywrightFetcher.php | 40 +++++++++++++++++-- app/scripts/render-page.cjs | 12 ++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/app/app/Services/Autopodbor/Agent/Fetch/CurlPlaywrightFetcher.php b/app/app/Services/Autopodbor/Agent/Fetch/CurlPlaywrightFetcher.php index f4d3ce92..725e31f4 100644 --- a/app/app/Services/Autopodbor/Agent/Fetch/CurlPlaywrightFetcher.php +++ b/app/app/Services/Autopodbor/Agent/Fetch/CurlPlaywrightFetcher.php @@ -20,6 +20,10 @@ final class CurlPlaywrightFetcher implements Fetcher public function site(string $url): FetchedSite { + if (! $this->isSafeUrl($url)) { + return new FetchedSite(url: $url, rawHtml: ''); + } + $raw = $this->curl($url); $visible = []; @@ -29,7 +33,7 @@ final class CurlPlaywrightFetcher implements Fetcher $visible = $rendered['visiblePhones'] ?? []; // номера со страницы /contacts добираем отдельным рендером, если есть такая ссылка $contactsUrl = $this->guessContactsUrl($url, $raw); - if ($contactsUrl !== null) { + if ($contactsUrl !== null && $this->isSafeUrl($contactsUrl)) { $rc = $this->render($contactsUrl); if ($rc !== null) { foreach (($rc['visiblePhones'] ?? []) as $p) { @@ -54,10 +58,12 @@ final class CurlPlaywrightFetcher implements Fetcher $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, + CURLOPT_FOLLOWLOCATION => false, // защита от SSRF через редирект на внутренний адрес + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; LiderraBot/1.0)', - CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, ]); $body = curl_exec($ch); curl_close($ch); @@ -65,6 +71,34 @@ final class CurlPlaywrightFetcher implements Fetcher return is_string($body) ? $body : ''; } + /** + * Пускаем только публичные http/https адреса; блокируем loopback, приватные + * и служебные диапазоны (защита от SSRF — заход на внутренние сервисы). + */ + private function isSafeUrl(string $url): bool + { + $parts = parse_url($url); + if ($parts === false || ! isset($parts['scheme'], $parts['host'])) { + return false; + } + if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) { + return false; + } + + $host = $parts['host']; + $ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : (gethostbynamel($host) ?: []); + if ($ips === []) { + return false; + } + foreach ($ips as $ip) { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + return false; // приватный/служебный/loopback — не ходим + } + } + + return true; + } + /** @return array{html:string,visiblePhones:list}|null */ private function render(string $url): ?array { diff --git a/app/scripts/render-page.cjs b/app/scripts/render-page.cjs index 81bc9f8f..d4c3252a 100644 --- a/app/scripts/render-page.cjs +++ b/app/scripts/render-page.cjs @@ -4,6 +4,18 @@ const { chromium } = require('playwright'); (async () => { const url = process.argv[2]; + // Защита от SSRF/локального доступа: только http/https. + let parsed; + try { + parsed = new URL(url); + } catch (e) { + console.error('bad url'); + process.exit(2); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + console.error('bad scheme'); + process.exit(2); + } const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage(); From 56a2b53645d12645a453250d93260497cfc03303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 12:11:27 +0300 Subject: [PATCH 061/417] =?UTF-8?q?fix(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20=D1=88=D0=B0=D0=B32):=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=82=D0=B5=D1=80=D1=8F=D1=82=D1=8C=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=D0=B5=D1=80=D0=B0=20=D0=BE=D1=84=D0=B8=D1=81=D0=BE=D0=B2?= =?UTF-8?q?,=20=D0=BD=D0=B0=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B1=D0=B5=D0=B7=20=D0=BA=D0=BE=D0=B4=D0=B0=20=D0=B3?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сканер кода сайта выкидывал короткие локальные номера (271-33-33, 2-828-828 и т.п.) как мусор — терялись реальные телефоны филиалов. Теперь короткий номер из tel:/schema/microdata достраивается кодом города: сперва по преобладающему коду полных номеров той же страницы, иначе по коду региона запроса; если код не определить — номер не теряется, а помечается «требует проверки» (phoneKind=uncertain). Из тела текста короткие формы не достраиваются (защита от ложных). TDD: 6 новых тестов, весь Autopodbor 40/40. Co-Authored-By: Claude Opus 4.8 --- .../Agent/Extract/CandidateBuilder.php | 13 ++ .../Agent/Extract/HtmlPhoneScanner.php | 108 +++++++++++--- .../Agent/Extract/SourceAggregator.php | 17 ++- .../Extract/CandidateBuilderTest.php | 29 ++++ .../Extract/HtmlPhoneScannerTest.php | 25 ++++ .../Extract/SourceAggregatorTest.php | 10 ++ .../findings/2026-06-30-STEP2-SVODKA.md | 133 ++++++++++++++++++ 7 files changed, 313 insertions(+), 22 deletions(-) create mode 100644 docs/superpowers/findings/2026-06-30-STEP2-SVODKA.md diff --git a/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php b/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php index 4b254221..ff76b998 100644 --- a/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php +++ b/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php @@ -39,6 +39,19 @@ final class CandidateBuilder ]; } + // короткие локальные номера, код города которых не удалось определить — + // НЕ теряем, отдаём клиенту с пометкой «требует проверки» + foreach ($scan['uncertain'] ?? [] as $short) { + $out[] = [ + 'number' => (string) $short, + 'kind' => 'uncertain', + 'label' => 'локальный номер на сайте — код города не определён, требует проверки', + 'url' => $site->url, + 'office' => null, + 'tracker' => $hasTracker, + ]; + } + $codeNumbers = array_map('strval', array_keys($scan['code'])); // видимые отрендеренные номера → displayed (подменный), если их нет в коде diff --git a/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php b/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php index 1d9e7dc0..2ab2095b 100644 --- a/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php +++ b/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php @@ -6,34 +6,66 @@ namespace App\Services\Autopodbor\Agent\Extract; final class HtmlPhoneScanner { - /** @return array{code: array>, body: array, emails: list} */ - public function scan(string $html): array + /** + * @param ?string $defaultAreaCode код города из запроса (напр. «391») — запасной для + * достройки коротких локальных номеров, если на странице нет полных + * @return array{code: array>, body: array, emails: list, uncertain: list} + */ + public function scan(string $html, ?string $defaultAreaCode = null): array { $code = []; - $add = function (string $raw, string $slot) use (&$code): void { - $n = $this->normalizeMaybe($raw); - if ($n === null) { - return; + $uncertain = []; + + // 1. Собираем сырые значения из ЯВНЫХ телефонных контекстов (tel/schema/microdata). + // Короткие локальные формы (без кода города) здесь безопасны — это точно телефоны. + $rawCandidates = []; + $patterns = [ + ['/tel:([+0-9()\s-]{6,})/i', 'tel'], + ['/"telephone"\s*:\s*"([^"]+)"/i', 'schema'], + ['/itemprop=["\']telephone["\'][^>]*content=["\']([^"\']+)/i', 'microdata'], + ]; + foreach ($patterns as [$re, $slot]) { + if (preg_match_all($re, $html, $m)) { + foreach ($m[1] as $x) { + $rawCandidates[] = ['raw' => $x, 'slot' => $slot]; + } } + } + + // 2. Из полных номеров берём национальные части (10 цифр) — по ним определяем код города. + $national = []; + foreach ($rawCandidates as $c) { + $n = $this->normalizeMaybe($c['raw']); + if ($n !== null) { + $national[] = substr($n, 1); + } + } + + $add = function (string $n, string $slot) use (&$code): void { $code[$n] ??= []; if (! in_array($slot, $code[$n], true)) { $code[$n][] = $slot; } }; - if (preg_match_all('/tel:([+0-9()\s-]{7,})/i', $html, $m)) { - foreach ($m[1] as $x) { - $add($x, 'tel'); + // 3. Разносим: полные — как есть; короткие локальные — достраиваем кодом города. + foreach ($rawCandidates as $c) { + $n = $this->normalizeMaybe($c['raw']); + if ($n !== null) { + $add($n, $c['slot']); + + continue; } - } - if (preg_match_all('/"telephone"\s*:\s*"([^"]+)"/i', $html, $m)) { - foreach ($m[1] as $x) { - $add($x, 'schema'); + $built = $this->buildLocal($c['raw'], $national, $defaultAreaCode); + if ($built !== null) { + $add($built, $c['slot']); + + continue; } - } - if (preg_match_all('/itemprop=["\']telephone["\'][^>]*content=["\']([^"\']+)/i', $html, $m)) { - foreach ($m[1] as $x) { - $add($x, 'microdata'); + // короткий локальный номер, код города не определить — НЕ теряем, помечаем к проверке + $short = preg_replace('/\D+/', '', $c['raw']) ?? ''; + if (strlen($short) >= 6 && strlen($short) <= 7 && ! in_array($short, $uncertain, true)) { + $uncertain[] = $short; } } @@ -57,7 +89,7 @@ final class HtmlPhoneScanner } } - return ['code' => $code, 'body' => $body, 'emails' => $emails]; + return ['code' => $code, 'body' => $body, 'emails' => $emails, 'uncertain' => $uncertain]; } private function normalizeMaybe(string $raw): ?string @@ -72,4 +104,44 @@ final class HtmlPhoneScanner return null; } + + /** + * Короткий локальный номер (6–7 цифр, написан без кода города) достраиваем + * преобладающим кодом города из полных номеров той же страницы, а не выкидываем. + * Пример: «271-33-33» при наличии «+7 (391) …» → 73912713333. + * + * @param list $national 10-значные национальные части полных номеров страницы + * @param ?string $defaultAreaCode запасной код города из запроса (если полных нет) + */ + private function buildLocal(string $raw, array $national, ?string $defaultAreaCode = null): ?string + { + $digits = preg_replace('/\D+/', '', $raw) ?? ''; + $len = strlen($digits); + if ($len < 6 || $len > 7) { + return null; + } + + $prefixLen = 10 - $len; + + // 1) самый частый префикс нужной длины среди полных номеров страницы = код города + if ($national !== []) { + $counts = []; + foreach ($national as $nat) { + $p = substr($nat, 0, $prefixLen); + $counts[$p] = ($counts[$p] ?? 0) + 1; + } + arsort($counts); + $prefix = (string) array_key_first($counts); + if (strlen($prefix) === $prefixLen) { + return '7'.$prefix.$digits; + } + } + + // 2) запасной: код города из запроса (если длина совпадает с недостающей) + if ($defaultAreaCode !== null && strlen($defaultAreaCode) === $prefixLen) { + return '7'.$defaultAreaCode.$digits; + } + + return null; + } } diff --git a/app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php b/app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php index a0cc7331..083b89a6 100644 --- a/app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php +++ b/app/app/Services/Autopodbor/Agent/Extract/SourceAggregator.php @@ -37,16 +37,25 @@ final class SourceAggregator $n = (string) $n; // PHP приводит числовые ключи массива к int — возвращаем строку $kinds = $info['kinds']; $hasTrusted = (bool) array_intersect(self::TRUSTED, $kinds); - $onlyHidden = ! $hasTrusted && ! in_array('displayed', $kinds, true); // только pool + $hasDisplayed = in_array('displayed', $kinds, true); + // короткий локальный номер без определимого кода города — «требует проверки» + $isUncertain = ! $hasTrusted && ! $hasDisplayed && in_array('uncertain', $kinds, true); + $onlyHidden = ! $hasTrusted && ! $hasDisplayed && ! $isUncertain; // только pool-свалка if ($onlyHidden) { continue; // пул-свалка — не выводим } - $kind = $hasTrusted ? 'real' : 'substitute'; + if ($isUncertain) { + $phoneKind = 'uncertain'; + $phoneType = null; // тип не выдумываем — номер ещё не достроен + } else { + $phoneKind = $hasTrusted ? 'real' : 'substitute'; + $phoneType = PhoneType::of($n); + } $out[] = new CollectedSource( signalType: 'call', identifier: $n, - phoneKind: $kind, - phoneType: PhoneType::of($n), + phoneKind: $phoneKind, + phoneType: $phoneType, office: $info['office'], sources: array_values($info['sources']), ); diff --git a/app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php b/app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php index 3b631159..750b4c57 100644 --- a/app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php +++ b/app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php @@ -19,6 +19,35 @@ it('из кода сайта делает кандидатов kind=code с фл expect($byNum['73912920000']['label'])->toBe('в коде сайта'); }); +it('достроенный короткий локальный номер становится кандидатом code', function () { + // КрасЛомбард-кейс: на странице полный 391-номер + короткие локальные без кода города. + // Короткие НЕ теряем — достраиваем кодом 391 со страницы. + $site = new FetchedSite( + url: 'https://k.ru/', + rawHtml: 'главный' + .'филиал-1' + .'филиал-2', + ); + $cands = (new CandidateBuilder)->build([$site], []); + $byNum = collect($cands)->keyBy('number'); + + expect($byNum)->toHaveKey('73912713333'); // 271-33-33 достроен + expect($byNum)->toHaveKey('73912828828'); // 2-828-828 достроен + expect($byNum['73912713333']['kind'])->toBe('code'); +}); + +it('короткий номер без определимого кода города попадает в кандидаты «требует проверки»', function () { + $site = new FetchedSite( + url: 'https://k.ru/', + rawHtml: 'единственный, без полного номера', + ); + $cands = (new CandidateBuilder)->build([$site], []); + $byNum = collect($cands)->keyBy('number'); + + expect($byNum)->toHaveKey('2713333'); // не потеряли + expect($byNum['2713333']['kind'])->toBe('uncertain'); +}); + it('размечает видимый подменный и скрытый пул при активном трекере', function () { $site = new FetchedSite( url: 'https://k.ru/', diff --git a/app/tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php b/app/tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php index a1b68890..19dedc35 100644 --- a/app/tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php +++ b/app/tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php @@ -24,3 +24,28 @@ it('считает вхождения в тело и берёт e-mail-цифр expect($r['body']['78432032533'])->toBe(2) ->and($r['emails'])->toContain('2032533'); }); + +it('достраивает короткий локальный номер кодом города со страницы', function () { + // на странице есть полный номер с кодом 391 (Красноярск) → код города = 391; + // короткий локальный 271-33-33 (7 цифр, без кода) НЕ выкидываем, а достраиваем до 73912713333 + $html = 'главный' + .'филиал'; + $r = (new HtmlPhoneScanner)->scan($html); + expect($r['code'])->toHaveKey('73912713333') + ->and($r['code'])->toHaveKey('73912000000'); +}); + +it('достраивает короткий номер кодом региона, когда на странице нет полного', function () { + // на странице ТОЛЬКО короткий 281-00-00, полных номеров нет → берём код города из запроса (391) + $html = 'единственный'; + $r = (new HtmlPhoneScanner)->scan($html, '391'); + expect($r['code'])->toHaveKey('73912810000'); +}); + +it('короткий номер без кода города НЕ теряет, а помечает «требует проверки»', function () { + // ни полных номеров на странице, ни кода региона → нельзя достроить, но и терять нельзя + $html = 'филиал'; + $r = (new HtmlPhoneScanner)->scan($html); + expect($r['code'])->toBeEmpty() + ->and($r['uncertain'])->toContain('2713333'); +}); diff --git a/app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php b/app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php index 9ae98e8b..de4efaca 100644 --- a/app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php +++ b/app/tests/Unit/Autopodbor/Extract/SourceAggregatorTest.php @@ -25,6 +25,16 @@ it('скрывает пул ротации и помечает видимый п expect($out[0]->phoneKind)->toBe('substitute'); }); +it('номер «требует проверки» не отбрасывает, помечает phoneKind=uncertain и не угадывает тип', function () { + $out = (new SourceAggregator)->aggregate([ + ['number' => '2713333', 'kind' => 'uncertain', 'label' => 'локальный номер — код города не определён', 'url' => 'https://k.ru/', 'office' => null, 'tracker' => false], + ]); + expect($out)->toHaveCount(1); // НЕ потерян + expect($out[0]->identifier)->toBe('2713333'); + expect($out[0]->phoneKind)->toBe('uncertain'); + expect($out[0]->phoneType)->toBeNull(); // тип не выдумываем +}); + it('сортирует: больше подтверждений — выше, подменные — в самый низ', function () { $out = (new SourceAggregator)->aggregate([ // 1 подтверждение diff --git a/docs/superpowers/findings/2026-06-30-STEP2-SVODKA.md b/docs/superpowers/findings/2026-06-30-STEP2-SVODKA.md new file mode 100644 index 00000000..3740aa20 --- /dev/null +++ b/docs/superpowers/findings/2026-06-30-STEP2-SVODKA.md @@ -0,0 +1,133 @@ +# ШАГ 2 (studyCompetitor) — СВОДКА ВСЕГО за 30.06.2026 (durable, чтобы не терять) + +> **Зачем файл:** код движка шага 1 потерялся (жил только в scratchpad, тот самоочистился — §13.0 +> заметок брейншторма). Чтобы шаг 2 НЕ повторил эту судьбу — здесь сведено всё: механизм, +> собранный код (закоммичен), живой прогон, и **открытые дефекты/хвосты** (включая пойманный +> владельцем баг с отсевом номеров без кода города). +> +> **Воркстри:** `worktree-avtopodbor` (НЕ прод). **Контракт-розетка:** §7 дизайн-документа +> `2026-06-28-autopodbor-konkurentov-design.md`. Движок меняет заглушку `FakeCompetitorAgent` +> одной строкой в `AutopodborServiceProvider`. + +--- + +## 1. Что такое шаг 2 +По **одному** конкуренту собрать его **источники** — сайты и телефоны — по всей сети офисов. +Каждый источник: пометка «где нашли» (провенанс), настоящий/подменный, тип номера, привязка к офису. +Платно («Изучить подробнее»), деньги только за успех. Из выбранных клиент заводит проекты. + +## 2. Доказанный механизм (R&D + спайк, всё проверено на живых) +- **Настоящий номер прячется в коде сайта** — `tel:`-ссылка, schema.org `telephone`, microdata, + иногда зашит в e-mail. Коллтрекинг (Roistat/Callibri/Calltouch/Comagic/…) подменяет ПОКАЗАННЫЙ + номер под рекламу, но настоящий остаётся в структуре кода. +- **Пул ротации трекера = свалка, СКРЫВАЕМ** (у МИРОКОН было 69 номеров). Показываем только + реально видимый посетителю подменный (1–2) — пометка 🎭. +- **Конкурент = сеть филиалов.** Глубокий сбор обходит ВСЕ офисы и сайты; рендер добирает номера + офисов, которых нет в сыром коде (КрасЛомбард: **27** номеров рендером vs 14 из `tel:`). +- **У каждого номера — список «где нашли»**, сортировка: больше подтверждений выше, подменные вниз. +- **Тип** — городской / мобильный / 8-800 по коду номера. + +## 3. Собранный код — ЗАКОММИЧЕНО (worktree-avtopodbor, 13 коммитов 30.06) +**Plan A — ядро (чистое, юнит-тесты на синтетике):** +- `Agent/Extract/PhoneType` — тип по коду. +- `Agent/Extract/HtmlPhoneScanner` — номера из кода: `tel`/schema/microdata + счёт по телу + e-mail. +- `Agent/Extract/CalltrackingDetector` — детект систем подмены. +- `Agent/Dto/CollectedSource` — источник с провенансом + `confirmations()`. +- `Agent/Extract/SourceAggregator` — дедуп, классификация real/substitute, скрытие пула, сортировка. + +**Plan B — добыча + агент:** +- `Agent/Fetch/FetchedSite`, `Agent/Fetch/DirectoryCard` — DTO. +- `Agent/Fetch/Fetcher` (интерфейс) + тест-дубль `FakeFetcher`. +- `Agent/Extract/CandidateBuilder` — страницы+карточки → размеченные кандидаты {number,kind,label,url,office,tracker}. +- `Agent/RealCompetitorAgent` — оркестрация Fetcher→Builder→Aggregator→маппинг в `StudyCompetitorResult` + (старый контракт; богатый список «где нашли» схлопывается в один provenance — расширение БД = Plan C). +- `Agent/Fetch/CurlPlaywrightFetcher` + `scripts/render-page.cjs` — живой загрузчик (curl + Playwright). + +**Тесты:** весь Autopodbor зелёный — 34 теста / 100 проверок. + +**НЕ закоммичено (ждёт «эскейп»):** `Agent/Extract/DirectoryParser` (читает карточку 2ГИС: tel из +кода + адрес из заголовка — проверен на настоящей карточке), `scripts/render-firm.cjs`, обновлённый +`CurlPlaywrightFetcher::directory()`. + +## 4. Живой прогон (30.06, эта сессия) +Настоящий **kraslombard24.ru** через закоммиченные классы движка → **27 настоящих номеров сети** +(11 подтверждены кодом+контактами, 16 — одним местом), коллтрекинг Callibri распознан, подменного- +пустышки нет. Страница глазами клиента (макет sbor1 с реальными данными): корень репо +`AUTOPODBOR-live-kraslombard-screen.png`. + +## 5. Документы и макеты (durable) +- R&D/механизм: `findings/2026-06-30-real-engine-step2-research.md` +- Plan A (ядро): `plans/2026-06-30-real-engine-step2-core.md` +- Plan B (добыча+агент): `plans/2026-06-30-real-engine-step2-fetch-agent.md` +- Макеты-эталоны: `prototypes/2026-06-30-konkurentnoe-pole-istochniki-sbor1.html` (первый сбор) + + `…-sbor2-povtor.html` (повтор/обновление источников: новые / не найдены повторно / без изменений) +- ⚠️ Спайк (логика, волатильно — scratchpad): `engine_spike.php`, `engine_spike2.php` + +--- + +## 6. 🔴 ОТКРЫТЫЕ ДЕФЕКТЫ И ХВОСТЫ + +### 6.1. ✅ ПОЧИНЕНО (30.06, по TDD): отсев номеров без кода города +**Был симптом:** на КрасЛомбарде движок **выкидывал нормальные номера, написанные без регионального +кода** — короткие локальные формы: `271-33-33`, `281-0000`, `255-00-00`, `2-828-828` и т.п. +`normalizeMaybe`/`normalize` принимали только 10–11 цифр, а 6–7-значные локальные молча +отбрасывались как шум → терялись реальные номера офисов. + +**Почему было неверно:** это не шум — тот же реальный номер без кода города. `271-33-33` (7 цифр) += `+7 (391) 271-33-33`. Красноярск = код **391**. + +**Как починено (TDD, 6 новых тестов, весь Autopodbor 40/40):** +- `HtmlPhoneScanner::scan($html, ?string $defaultAreaCode = null)` — короткие 6–7-значные номера из + **явных телефонных контекстов** (`tel:`/schema/microdata) больше НЕ теряются. Достройка кодом города: + 1. **преобладающий код среди полных номеров той же страницы** (если есть `+7 (391) …` → локальные тоже 391); + 2. иначе — **`defaultAreaCode` из запроса** (Красноярск → `391`), если длина подходит; + 3. если код не определить — номер кладётся в новый ключ `uncertain` (НЕ теряется молча). + Короткие из **тела текста** НЕ достраиваются (защита от ложных: цены/ID) — только явные tel/schema. +- `CandidateBuilder` — достроенные номера автоматически идут как `kind=code`; `uncertain` → кандидат + `kind=uncertain`, label «локальный номер на сайте — код города не определён, требует проверки». +- `SourceAggregator` — `uncertain` больше НЕ отбрасывается (раньше попадал под `onlyHidden`): + выводится с `phoneKind='uncertain'`, `phoneType=null` (тип не выдумываем). + +**Хвост (не блокирует):** проброс `defaultAreaCode` из города конкурента в `scan()` через +`CandidateBuilder`→`RealCompetitorAgent` — пока сделан на уровне сигнатуры сканера (запасной путь). +Основной путь (код со страницы) работает без проброса. Спайк `engine_spike.php` — волатильный, +не правился (канон — в закоммиченных классах). Файлы: `HtmlPhoneScanner.php`, `CandidateBuilder.php`, +`SourceAggregator.php` + тесты `HtmlPhoneScannerTest`/`CandidateBuilderTest`/`SourceAggregatorTest`. + +### 6.2. Справочники 2ГИС/Яндекс блокируют робота +Прямой headless-заход в 2ГИС → пустая 11КБ-заглушка; Яндекс → капча. **Решение уже записано** +(заметки движка шага 1 §12.2): **2ГИС → firecrawl** (антибот/прокси), **Яндекс → свой Playwright** +(рендерит отлично, бесплатно). `DirectoryParser` (чтение карточки) уже готов и проверен на +настоящем 2ГИС-HTML — нужен только правильный загрузчик. Прямой парс 2ГИС руками НЕ делать (стена). + +### 6.3. Нестыковка метки телефона (ось ещё не зафиксирована) +- §14.5 заметок (29.06): показывать **городской / мобильный / 8-800** (через DaData). +- Макеты 30.06 (приняты владельцем): **настоящий ✓ / подменный 🎭 + «где нашли»** (детект коллтрекинга). +Это РАЗНЫЕ оси. Собранный код даёт обе (`phoneType` И `phoneKind`). Нужно решение владельца: +показывать обе или одну. До решения — храним обе. + +### 6.4. SSRF redirect-bypass в живом загрузчике (ревью безопасности) +`render()`/`renderFirm()` запускают Playwright, который сам ходит по редиректам и резолвит DNS — +PHP-страж `isSafeUrl` обходится (публичный хост → редирект на внутренний IP / DNS-rebind). Чинить +при финализации слоя добычи: проверка хоста ВНУТРИ node-скрипта (`page.route` — рубить непубличные +адреса) или egress-прокси; резолв DNS один раз в PHP и передача IP+Host. Сейчас путь не боевой. + +### 6.5. ⚠️ Риск потери (как с движком шага 1) +Спайк и live-харнесс живут в scratchpad (волатильно). Код шага 2 — закоммичен (хорошо). Спайк при +необходимости перенести в durable. Движок шага 1 (`resolver.js` и пр.) уже **потерян** — при его +восстановлении класть сразу в репозиторий. + +### 6.6. Дальнейшие планы (из Plan A/B) +- **Plan C:** расширить контракт/БД `autopodbor_sources` под список «где нашли» + подтверждения + офис + (миграция + CHANGELOG + rls-reviewer); доработать `directory()` (firecrawl/Playwright). +- **Plan D/E:** Vue-экраны «Первый сбор» (sbor1) и «Повторный сбор» (sbor2-povtor) 1:1 с макетами. +- Замена `FakeCompetitorAgent` → `RealCompetitorAgent` (одна строка) — после закрытия 6.1–6.3. + +--- + +## 7. Что дальше (порядок — по решению владельца) +1. ✅ Починить отсев номеров без кода города (6.1) — TDD. **Сделано 30.06, 40/40.** +2. Справочники через firecrawl/Playwright (6.2). +3. Снять нестыковку метки телефона (6.3). +4. Закоммитить DirectoryParser + хардненинг SSRF (6.4). +5. Контракт/БД (Plan C) → экраны (Plan D/E). From 777c3d63cef39617a673efe377eb7a9dafe32257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 12:16:18 +0300 Subject: [PATCH 062/417] =?UTF-8?q?test(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20=D1=88=D0=B0=D0=B32):=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=D0=B5=D1=80=D0=BE=D0=B2=20=D0=BD=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=B2=D1=8F=D0=B7=D0=B0=D0=BD=D0=B0=20=D0=BA=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B3=D0=B8=D0=BE=D0=BD=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Москва 495, Питер 812, Екатеринбург 343 — код города берётся со страницы конкурента или из города запроса, никакой привязки к Красноярску. Защита от регрессии. Autopodbor 41/41. Co-Authored-By: Claude Opus 4.8 --- .../Extract/HtmlPhoneScannerTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php b/app/tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php index 19dedc35..a3cb1bd3 100644 --- a/app/tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php +++ b/app/tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php @@ -42,6 +42,24 @@ it('достраивает короткий номер кодом региона expect($r['code'])->toHaveKey('73912810000'); }); +it('достройка не привязана к региону: работает для любого кода города', function () { + // Москва (495): код города берётся со страницы, никакой привязки к Красноярску + $msk = (new HtmlPhoneScanner)->scan( + 'офисфилиал' + ); + expect($msk['code'])->toHaveKey('74957712233'); + + // Санкт-Петербург (812): тот же механизм, другой код + $spb = (new HtmlPhoneScanner)->scan( + 'офисфилиал' + ); + expect($spb['code'])->toHaveKey('78126405566'); + + // запасной код региона из запроса — тоже любой (Екатеринбург 343) + $ekb = (new HtmlPhoneScanner)->scan('единственный', '343'); + expect($ekb['code'])->toHaveKey('73433556677'); +}); + it('короткий номер без кода города НЕ теряет, а помечает «требует проверки»', function () { // ни полных номеров на странице, ни кода региона → нельзя достроить, но и терять нельзя $html = 'филиал'; From 5711cb4e354fcbfbd1d22750a984752ed82944df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 12:38:18 +0300 Subject: [PATCH 063/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20=D1=88=D0=B0=D0=B32):=20=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?2=D0=93=D0=98=D0=A1=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20xfetch.r?= =?UTF-8?q?u?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Антибот 2ГИС бьём сервисом xfetch.ru (render:true, timeout:20 — без timeout страница не дорисовывается). Доказано на живом КрасЛомбаре: поиск → 12 филиалов → телефон + адрес каждой карточки. - PageFetcher — граница «достать HTML» (тестируется без сети) - XfetchClient — POST к xfetch, декод base64; без ключа молча пусто - XfetchDirectoryFetcher — список→филиалы→карточки через DirectoryParser - DirectoryParser — чтение списка и карточки 2ГИС (был в хвостах) - config services.xfetch + .env.example; ключ только в .env (gitignored) Яндекс.Карты — отдельно (другой формат URL карточек). TDD: Autopodbor 46/46. Co-Authored-By: Claude Opus 4.8 --- app/.env.example | 3 + .../Agent/Extract/DirectoryParser.php | 84 +++++++++++++++++++ .../Autopodbor/Agent/Fetch/PageFetcher.php | 15 ++++ .../Autopodbor/Agent/Fetch/XfetchClient.php | 50 +++++++++++ .../Agent/Fetch/XfetchDirectoryFetcher.php | 53 ++++++++++++ app/config/services.php | 8 ++ .../Extract/DirectoryParserTest.php | 29 +++++++ .../Autopodbor/Fetch/XfetchClientTest.php | 31 +++++++ .../Fetch/XfetchDirectoryFetcherTest.php | 42 ++++++++++ .../findings/2026-06-30-STEP2-SVODKA.md | 26 ++++-- 10 files changed, 336 insertions(+), 5 deletions(-) create mode 100644 app/app/Services/Autopodbor/Agent/Extract/DirectoryParser.php create mode 100644 app/app/Services/Autopodbor/Agent/Fetch/PageFetcher.php create mode 100644 app/app/Services/Autopodbor/Agent/Fetch/XfetchClient.php create mode 100644 app/app/Services/Autopodbor/Agent/Fetch/XfetchDirectoryFetcher.php create mode 100644 app/tests/Unit/Autopodbor/Extract/DirectoryParserTest.php create mode 100644 app/tests/Unit/Autopodbor/Fetch/XfetchClientTest.php create mode 100644 app/tests/Unit/Autopodbor/Fetch/XfetchDirectoryFetcherTest.php diff --git a/app/.env.example b/app/.env.example index 1f3b5210..0011bb70 100644 --- a/app/.env.example +++ b/app/.env.example @@ -96,3 +96,6 @@ VITE_APP_NAME="${APP_NAME}" # Клиентский ключ Yandex SmartCaptcha (M-2). Пусто → fallback-чекбокс (dev). # На проде — клиентский ключ ysc1_… (для виджета на странице регистрации). VITE_YANDEX_SMARTCAPTCHA_SITEKEY= + +# Автоподбор шаг2: обход антибота справочников (2ГИС/Яндекс). Ключ — в .env, не в гите. +XFETCH_API_KEY= diff --git a/app/app/Services/Autopodbor/Agent/Extract/DirectoryParser.php b/app/app/Services/Autopodbor/Agent/Extract/DirectoryParser.php new file mode 100644 index 00000000..85ee8dec --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Extract/DirectoryParser.php @@ -0,0 +1,84 @@ +). + * + * @return list + */ + public function parseBranchList(string $html): array + { + $out = []; + if (preg_match_all('#href="(/[a-z0-9_-]+/firm/\d+)"#i', $html, $m)) { + foreach ($m[1] as $href) { + if (! in_array($href, $out, true)) { + $out[] = $href; + } + } + } + + return $out; + } + + /** + * Карточка филиала: телефоны из tel:-ссылок + адрес из заголовка страницы. + * + * @return list + */ + public function parseFirmCard(string $html, string $url, string $source): array + { + $office = $this->officeFromTitle($html); + + $out = []; + if (preg_match_all('/tel:([+0-9()\s-]{7,})/i', $html, $m)) { + $seen = []; + foreach ($m[1] as $raw) { + $num = trim($raw); + if (isset($seen[$num])) { + continue; + } + $seen[$num] = true; + $out[] = new DirectoryCard(number: $num, office: $office, url: $url, source: $source); + } + } + + return $out; + } + + /** + * Адрес офиса из вида «Имя, адрес…, Город — 2ГИС». + * Берём всё между первой и последней запятой (имя фирмы и город/суффикс отбрасываем). + */ + private function officeFromTitle(string $html): ?string + { + if (! preg_match('#<title>(.*?)#is', $html, $m)) { + return null; + } + $title = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + // нормализуем пробелы (в т.ч. неразрывные U+00A0) → одиночные + $title = preg_replace('/[\s\x{00A0}]+/u', ' ', $title) ?? $title; + $title = trim($title); + // отрезаем хвост « — 2ГИС» / « — Яндекс Карты» + $title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title; + + $parts = array_map('trim', explode(',', $title)); + if (count($parts) < 3) { + return null; // нет адреса между именем и городом + } + // имя фирмы — первый кусок, город — последний; адрес — середина + $address = array_slice($parts, 1, count($parts) - 2); + + return implode(', ', $address) ?: null; + } +} diff --git a/app/app/Services/Autopodbor/Agent/Fetch/PageFetcher.php b/app/app/Services/Autopodbor/Agent/Fetch/PageFetcher.php new file mode 100644 index 00000000..5dae9121 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Fetch/PageFetcher.php @@ -0,0 +1,15 @@ +apiKey === null || $this->apiKey === '') { + return ''; + } + + $resp = Http::timeout($this->httpTimeout)->asJson()->post($this->endpoint, [ + 'url' => $url, + 'api_key' => $this->apiKey, + 'render' => $this->render, + 'timeout' => $this->renderTimeout, + ]); + + if (! $resp->successful()) { + return ''; + } + + $b64 = $resp->json('response_body_base64'); + if (! is_string($b64) || $b64 === '') { + return ''; + } + + $html = base64_decode($b64, true); + + return is_string($html) ? $html : ''; + } +} diff --git a/app/app/Services/Autopodbor/Agent/Fetch/XfetchDirectoryFetcher.php b/app/app/Services/Autopodbor/Agent/Fetch/XfetchDirectoryFetcher.php new file mode 100644 index 00000000..46a567b4 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Fetch/XfetchDirectoryFetcher.php @@ -0,0 +1,53 @@ +pages->html($url)); + } + + public function directory(string $url): array + { + $listHtml = $this->pages->html($url); + $links = $this->parser->parseBranchList($listHtml); + if ($links === []) { + return []; + } + + $origin = parse_url($url); + $base = ($origin['scheme'] ?? 'https').'://'.($origin['host'] ?? ''); + $source = stripos((string) ($origin['host'] ?? ''), 'yandex') !== false ? 'Яндекс.Карты' : '2ГИС'; + + $cards = []; + foreach (array_slice($links, 0, $this->maxBranches) as $href) { + $firmUrl = str_starts_with($href, 'http') ? $href : $base.$href; + $firmHtml = $this->pages->html($firmUrl); + if ($firmHtml === '') { + continue; + } + foreach ($this->parser->parseFirmCard($firmHtml, $firmUrl, $source) as $card) { + $cards[] = $card; + } + } + + return $cards; + } +} diff --git a/app/config/services.php b/app/config/services.php index dd79f9cc..151529cf 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -114,4 +114,12 @@ return [ ))), ], + // Автоподбор шаг 2: сервис обхода антибота справочников (2ГИС/Яндекс) — xfetch.ru. + // Ключ — ТОЛЬКО в .env (gitignored), в код/гит не попадает. Без ключа загрузчик + // молча возвращает пусто (поток не падает). + 'xfetch' => [ + 'key' => env('XFETCH_API_KEY'), + 'endpoint' => env('XFETCH_ENDPOINT', 'https://xf4.ru/fetch'), + ], + ]; diff --git a/app/tests/Unit/Autopodbor/Extract/DirectoryParserTest.php b/app/tests/Unit/Autopodbor/Extract/DirectoryParserTest.php new file mode 100644 index 00000000..f68a839f --- /dev/null +++ b/app/tests/Unit/Autopodbor/Extract/DirectoryParserTest.php @@ -0,0 +1,29 @@ +Ф1Ф2' + .'дубльне филиал'; + $links = (new DirectoryParser)->parseBranchList($html); + expect($links)->toBe(['/krasnoyarsk/firm/111', '/krasnoyarsk/firm/222']); +}); + +it('из карточки 2ГИС берёт телефон из tel: и адрес из заголовка', function () { + $html = 'КрасЛомбард, улица Ладо Кецховели, 30, Красноярск — 2ГИС' + .'

КрасЛомбард

+7 (391) 2...'; + $cards = (new DirectoryParser)->parseFirmCard($html, 'https://2gis.ru/krasnoyarsk/firm/111', '2ГИС'); + + expect($cards)->toHaveCount(1); + expect($cards[0]->number)->toBe('+73912920000'); + expect($cards[0]->office)->toBe('улица Ладо Кецховели, 30'); + expect($cards[0]->url)->toBe('https://2gis.ru/krasnoyarsk/firm/111'); + expect($cards[0]->source)->toBe('2ГИС'); +}); + +it('схлопывает неразрывные/двойные пробелы в адресе из заголовка', function () { + $html = "КрасЛомбард,\u{00A0}улица Весны,\u{00A0}7а, Красноярск — 2ГИС" + .'x'; + $cards = (new DirectoryParser)->parseFirmCard($html, 'u', '2ГИС'); + expect($cards[0]->office)->toBe('улица Весны, 7а'); +}); diff --git a/app/tests/Unit/Autopodbor/Fetch/XfetchClientTest.php b/app/tests/Unit/Autopodbor/Fetch/XfetchClientTest.php new file mode 100644 index 00000000..37b37fee --- /dev/null +++ b/app/tests/Unit/Autopodbor/Fetch/XfetchClientTest.php @@ -0,0 +1,31 @@ + Http::response([ + 'status_code' => 200, + 'response_body_base64' => base64_encode('привет'), + ]), + ]); + $client = new XfetchClient('test-key'); + expect($client->html('https://2gis.ru/x'))->toBe('привет'); +}); + +it('без ключа не ходит в сеть и возвращает пусто', function () { + Http::fake(); + $client = new XfetchClient(null); + expect($client->html('https://2gis.ru/x'))->toBe(''); + Http::assertNothingSent(); +}); + +it('шлёт url, api_key и render:true на endpoint xfetch', function () { + Http::fake(['xf4.ru/*' => Http::response(['response_body_base64' => base64_encode('ok')])]); + (new XfetchClient('k123'))->html('https://2gis.ru/y'); + Http::assertSent(fn ($r) => $r->url() === 'https://xf4.ru/fetch' + && $r['url'] === 'https://2gis.ru/y' + && $r['api_key'] === 'k123' + && $r['render'] === true); +}); diff --git a/app/tests/Unit/Autopodbor/Fetch/XfetchDirectoryFetcherTest.php b/app/tests/Unit/Autopodbor/Fetch/XfetchDirectoryFetcherTest.php new file mode 100644 index 00000000..a755abeb --- /dev/null +++ b/app/tests/Unit/Autopodbor/Fetch/XfetchDirectoryFetcherTest.php @@ -0,0 +1,42 @@ + $pages */ +function fakePages(array $pages): PageFetcher +{ + return new class($pages) implements PageFetcher + { + /** @param array $pages */ + public function __construct(private array $pages) {} + + public function html(string $url): string + { + return $this->pages[$url] ?? ''; + } + }; +} + +it('обходит список 2ГИС и собирает карточки филиалов с адресом', function () { + $fake = fakePages([ + 'https://2gis.ru/krasnoyarsk/search/X' => 'ф1ф2', + 'https://2gis.ru/krasnoyarsk/firm/111' => 'КрасЛомбард, улица Весны, 7, Красноярск — 2ГИСx', + 'https://2gis.ru/krasnoyarsk/firm/222' => 'КрасЛомбард, проспект Мира, 10, Красноярск — 2ГИСx', + ]); + + $cards = (new XfetchDirectoryFetcher($fake))->directory('https://2gis.ru/krasnoyarsk/search/X'); + + expect($cards)->toHaveCount(2); + $byNum = collect($cards)->keyBy('number'); + expect($byNum['+73912550000']->office)->toBe('улица Весны, 7'); + expect($byNum['+73912550000']->source)->toBe('2ГИС'); + expect($byNum['+73912920000']->office)->toBe('проспект Мира, 10'); +}); + +it('site() тянет HTML конкурента через тот же загрузчик', function () { + $fake = fakePages(['https://k.ru' => 'x']); + $site = (new XfetchDirectoryFetcher($fake))->site('https://k.ru'); + expect($site->url)->toBe('https://k.ru') + ->and($site->rawHtml)->toContain('tel:+73912920000'); +}); diff --git a/docs/superpowers/findings/2026-06-30-STEP2-SVODKA.md b/docs/superpowers/findings/2026-06-30-STEP2-SVODKA.md index 3740aa20..ccabd492 100644 --- a/docs/superpowers/findings/2026-06-30-STEP2-SVODKA.md +++ b/docs/superpowers/findings/2026-06-30-STEP2-SVODKA.md @@ -94,11 +94,27 @@ не правился (канон — в закоммиченных классах). Файлы: `HtmlPhoneScanner.php`, `CandidateBuilder.php`, `SourceAggregator.php` + тесты `HtmlPhoneScannerTest`/`CandidateBuilderTest`/`SourceAggregatorTest`. -### 6.2. Справочники 2ГИС/Яндекс блокируют робота -Прямой headless-заход в 2ГИС → пустая 11КБ-заглушка; Яндекс → капча. **Решение уже записано** -(заметки движка шага 1 §12.2): **2ГИС → firecrawl** (антибот/прокси), **Яндекс → свой Playwright** -(рендерит отлично, бесплатно). `DirectoryParser` (чтение карточки) уже готов и проверен на -настоящем 2ГИС-HTML — нужен только правильный загрузчик. Прямой парс 2ГИС руками НЕ делать (стена). +### 6.2. ✅ 2ГИС через xfetch.ru РАБОТАЕТ (30.06); Яндекс — отдельно +**Решение по требованию владельца:** антибот справочников бьём сервисом **xfetch.ru** (НЕ firecrawl). +API: `POST https://xf4.ru/fetch` тело `{"url","api_key","render":true,"timeout":20}` → HTML в base64 +(`response_body_base64`). **Без `timeout` 2ГИС не успевает дорисоваться** и отдаёт ошибку браузера — +обязателен `render:true, timeout:20`. + +**Доказано на живом 2ГИС (КрасЛомбард, 30.06):** страница поиска → **12 карточек филиалов**; +карточка → `+73912920000`, адрес «улица 60 лет Октября, 54» (прогнал настоящим `DirectoryParser`). + +**Собрано и протестировано (46/46):** +- `Agent/Fetch/PageFetcher` — интерфейс «достать HTML» (граница антибота, тестируется без сети). +- `Agent/Fetch/XfetchClient implements PageFetcher` — POST к xfetch, декод base64; **без ключа молча + пусто** (не падает). Ключ — `config('services.xfetch.key')` из `XFETCH_API_KEY` в `app/.env` + (gitignored, в код/гит НЕ попадает; плейсхолдер — в `.env.example`). +- `Agent/Fetch/XfetchDirectoryFetcher implements Fetcher` — список→ссылки→карточки через `DirectoryParser`. +- `DirectoryParser` — закоммичен вместе с этим (был в хвостах §6.4). + +**Хвост — Яндекс.Карты:** у Яндекса другой формат URL карточек (`/maps/org/`, НЕ `/city/firm/`), +`DirectoryParser` под него ещё не написан — нужен отдельный парсер по РЕАЛЬНОМУ DOM Яндекса (не выдумывать). +**Провод в движок (замена FakeCompetitorAgent→Real + проброс региона) — Plan C, пока не сделан.** +Прямой парс 2ГИС/Яндекс руками НЕ делать (стена) — только через xfetch. ### 6.3. Нестыковка метки телефона (ось ещё не зафиксирована) - §14.5 заметок (29.06): показывать **городской / мобильный / 8-800** (через DaData). From 1b7939094cbc97b0b3cf71f4265f9c3a81ab58ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 12:41:18 +0300 Subject: [PATCH 064/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20=D1=88=D0=B0=D0=B32):=20Compos?= =?UTF-8?q?iteFetcher=20=E2=80=94=20=D1=81=D0=B0=D0=B9=D1=82=20=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B0=D0=B7=D0=BD=D1=8B=D0=BC=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=83=D1=82=D1=8F=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Один Fetcher для движка: site() → богатый загрузчик (curl+Playwright, видимые/пул/контакты), directory() → антибот-загрузчик (xfetch). Готовит провод реального агента (Plan C). TDD: Autopodbor 47/47. Co-Authored-By: Claude Opus 4.8 --- .../Agent/Fetch/CompositeFetcher.php | 28 +++++++++++ .../Autopodbor/Fetch/CompositeFetcherTest.php | 49 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Fetch/CompositeFetcher.php create mode 100644 app/tests/Unit/Autopodbor/Fetch/CompositeFetcherTest.php diff --git a/app/app/Services/Autopodbor/Agent/Fetch/CompositeFetcher.php b/app/app/Services/Autopodbor/Agent/Fetch/CompositeFetcher.php new file mode 100644 index 00000000..d540b834 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Fetch/CompositeFetcher.php @@ -0,0 +1,28 @@ +siteFetcher->site($url); + } + + public function directory(string $url): array + { + return $this->directoryFetcher->directory($url); + } +} diff --git a/app/tests/Unit/Autopodbor/Fetch/CompositeFetcherTest.php b/app/tests/Unit/Autopodbor/Fetch/CompositeFetcherTest.php new file mode 100644 index 00000000..5e5557dd --- /dev/null +++ b/app/tests/Unit/Autopodbor/Fetch/CompositeFetcherTest.php @@ -0,0 +1,49 @@ +site('https://k.ru')->rawHtml)->toBe('ИЗ-САЙТ-ЗАГРУЗЧИКА'); + + $cards = $c->directory('https://2gis.ru/x'); + expect($cards)->toHaveCount(1) + ->and($cards[0]->number)->toBe('+73912920000') + ->and($cards[0]->source)->toBe('2ГИС'); +}); From 951a9e6b78abf6ed40b0c92ddde9968bf5bccb2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 13:27:27 +0300 Subject: [PATCH 065/417] =?UTF-8?q?fix(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20=D1=88=D0=B0=D0=B32):=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BB=D0=BE=D0=B4=D0=B8=D1=82=D1=8C=20=D1=84?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D1=88=D0=B8=D0=B2=D0=BA=D0=B8=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=BE=D0=B1=D1=80=D0=B5=D0=B7=D0=BA=D0=BE=D0=B2=20+=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B4=D1=91=D0=B6=D0=BD=D0=B5=D0=B5=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D1=81=D0=B5=D1=80=202=D0=93=D0=98=D0=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два бага, вскрытых живым прогоном по КрасЛомбару (оба в обработке, не в xfetch): 1. Достройка коротких номеров лепила фальшивку из обрезка полного номера: сайт делит номер на tel:+7 (391) 271 и tel:271-33-33. Обрезок 7391271 (страна+код города) ошибочно достраивался в 73917391271. Теперь обрезок «7/8 + код города» распознаётся и выкидывается; настоящие локальные (включая московские 771-..) — целы. Логику вынес в classifyShort/areaCode. 2. parseBranchList брал ссылку филиала только из href — на части прорисовок 2ГИС ссылка лежит в JSON-данных, филиалы терялись. Берём путь /city/firm/ откуда угодно (с дедупом). TDD: Autopodbor 49/49. Co-Authored-By: Claude Opus 4.8 --- .../Agent/Extract/DirectoryParser.php | 7 ++- .../Agent/Extract/HtmlPhoneScanner.php | 60 ++++++++++++------- .../Extract/DirectoryParserTest.php | 8 +++ .../Extract/HtmlPhoneScannerTest.php | 14 +++++ 4 files changed, 67 insertions(+), 22 deletions(-) diff --git a/app/app/Services/Autopodbor/Agent/Extract/DirectoryParser.php b/app/app/Services/Autopodbor/Agent/Extract/DirectoryParser.php index 85ee8dec..00f59fa7 100644 --- a/app/app/Services/Autopodbor/Agent/Extract/DirectoryParser.php +++ b/app/app/Services/Autopodbor/Agent/Extract/DirectoryParser.php @@ -19,9 +19,12 @@ final class DirectoryParser */ public function parseBranchList(string $html): array { + // Путь карточки филиала /<город>/firm/ — 2ГИС отдаёт его то как href, + // то внутри JSON-состояния страницы. Берём отовсюду (с дедупом) — иначе на части + // прорисовок филиалы теряются (поймано на живом 2ГИС). $out = []; - if (preg_match_all('#href="(/[a-z0-9_-]+/firm/\d+)"#i', $html, $m)) { - foreach ($m[1] as $href) { + if (preg_match_all('#/[a-z0-9_-]+/firm/\d+#i', $html, $m)) { + foreach ($m[0] as $href) { if (! in_array($href, $out, true)) { $out[] = $href; } diff --git a/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php b/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php index 2ab2095b..dba78575 100644 --- a/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php +++ b/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php @@ -56,17 +56,13 @@ final class HtmlPhoneScanner continue; } - $built = $this->buildLocal($c['raw'], $national, $defaultAreaCode); - if ($built !== null) { - $add($built, $c['slot']); - - continue; - } - // короткий локальный номер, код города не определить — НЕ теряем, помечаем к проверке - $short = preg_replace('/\D+/', '', $c['raw']) ?? ''; - if (strlen($short) >= 6 && strlen($short) <= 7 && ! in_array($short, $uncertain, true)) { - $uncertain[] = $short; + [$status, $value] = $this->classifyShort($c['raw'], $national, $defaultAreaCode); + if ($status === 'built') { + $add((string) $value, $c['slot']); + } elseif ($status === 'uncertain' && ! in_array($value, $uncertain, true)) { + $uncertain[] = (string) $value; // код города не определить — не теряем, к проверке } + // 'fragment' (обрезок полного номера) и 'skip' (не телефон) — игнорируем } $body = []; @@ -106,24 +102,49 @@ final class HtmlPhoneScanner } /** - * Короткий локальный номер (6–7 цифр, написан без кода города) достраиваем - * преобладающим кодом города из полных номеров той же страницы, а не выкидываем. - * Пример: «271-33-33» при наличии «+7 (391) …» → 73912713333. + * Классифицирует короткую (6–7 цифр) форму номера из явного телефонного контекста. + * Возвращает [статус, значение]: + * - 'built' + 7XXXXXXXXXX — локальный номер достроен кодом города; + * - 'fragment' + null — обрезок полного номера (страна+код), выкидываем; + * - 'uncertain' + цифры — код города не определить, не теряем, помечаем к проверке; + * - 'skip' + null — не 6–7-значная форма, не наше. + * Пример: «271-33-33» при «+7 (391) …» → ['built','73912713333']; + * обрезок «+7 (391) 271» (7391271) → ['fragment', null]. * * @param list $national 10-значные национальные части полных номеров страницы * @param ?string $defaultAreaCode запасной код города из запроса (если полных нет) + * @return array{0:string,1:?string} */ - private function buildLocal(string $raw, array $national, ?string $defaultAreaCode = null): ?string + private function classifyShort(string $raw, array $national, ?string $defaultAreaCode): array { $digits = preg_replace('/\D+/', '', $raw) ?? ''; $len = strlen($digits); if ($len < 6 || $len > 7) { - return null; + return ['skip', null]; } - $prefixLen = 10 - $len; + $area = $this->areaCode($national, 10 - $len, $defaultAreaCode); + if ($area === null) { + return ['uncertain', $digits]; // код города не определить + } - // 1) самый частый префикс нужной длины среди полных номеров страницы = код города + // Обрезок полного номера: уже содержит код страны 7/8 + код города (напр. 7391271 = 7·391·271). + // Настоящий локальный (2713333, или московский 7712233 при коде 495) этим не задевается. + if (str_starts_with($digits, '7'.$area) || str_starts_with($digits, '8'.$area)) { + return ['fragment', null]; + } + + return ['built', '7'.$area.$digits]; + } + + /** + * Код города нужной длины: самый частый префикс среди полных номеров страницы, + * иначе запасной из запроса. null — если не определить. + * + * @param list $national + */ + private function areaCode(array $national, int $prefixLen, ?string $defaultAreaCode): ?string + { if ($national !== []) { $counts = []; foreach ($national as $nat) { @@ -133,13 +154,12 @@ final class HtmlPhoneScanner arsort($counts); $prefix = (string) array_key_first($counts); if (strlen($prefix) === $prefixLen) { - return '7'.$prefix.$digits; + return $prefix; } } - // 2) запасной: код города из запроса (если длина совпадает с недостающей) if ($defaultAreaCode !== null && strlen($defaultAreaCode) === $prefixLen) { - return '7'.$defaultAreaCode.$digits; + return $defaultAreaCode; } return null; diff --git a/app/tests/Unit/Autopodbor/Extract/DirectoryParserTest.php b/app/tests/Unit/Autopodbor/Extract/DirectoryParserTest.php index f68a839f..5fc6f053 100644 --- a/app/tests/Unit/Autopodbor/Extract/DirectoryParserTest.php +++ b/app/tests/Unit/Autopodbor/Extract/DirectoryParserTest.php @@ -9,6 +9,14 @@ it('берёт ссылки на карточки филиалов из спис expect($links)->toBe(['/krasnoyarsk/firm/111', '/krasnoyarsk/firm/222']); }); +it('берёт ссылки на филиалы, даже когда они в данных страницы, а не в href', function () { + // 2ГИС иногда рендерит карточки без чистого href — ссылка лежит в JSON-состоянии страницы + $html = '{"items":[{"link":"/krasnoyarsk/firm/111"},{"link":"/krasnoyarsk/firm/222"}]}' + .'дубль'; + $links = (new DirectoryParser)->parseBranchList($html); + expect($links)->toBe(['/krasnoyarsk/firm/111', '/krasnoyarsk/firm/222']); +}); + it('из карточки 2ГИС берёт телефон из tel: и адрес из заголовка', function () { $html = 'КрасЛомбард, улица Ладо Кецховели, 30, Красноярск — 2ГИС' .'

КрасЛомбард

+7 (391) 2...'; diff --git a/app/tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php b/app/tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php index a3cb1bd3..535e7789 100644 --- a/app/tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php +++ b/app/tests/Unit/Autopodbor/Extract/HtmlPhoneScannerTest.php @@ -60,6 +60,20 @@ it('достройка не привязана к региону: работае expect($ekb['code'])->toHaveKey('73433556677'); }); +it('НЕ слепляет фальшивку из обрезка полного номера (страна+код)', function () { + // Сайт делит номер на 2 tel:-ссылки: «+7 (391) 271» (обрезок) и «271-33-33» (локальная часть). + // Обрезок 7391271 начинается с 7+391 → это кусок полного номера, достраивать его НЕЛЬЗЯ. + // Локальную часть 271-33-33 достраиваем кодом 391 со страницы. + $html = 'главный' + .'обрезок' + .'локальная часть'; + $r = (new HtmlPhoneScanner)->scan($html); + expect($r['code'])->toHaveKey('73912713333') // локальная часть достроена верно + ->and($r['code'])->toHaveKey('73912000000') // полный + ->and($r['code'])->not->toHaveKey('73917391271') // фальшивка из обрезка НЕ появилась + ->and($r['uncertain'])->not->toContain('7391271'); // и в «требует проверки» обрезок не лезет +}); + it('короткий номер без кода города НЕ теряет, а помечает «требует проверки»', function () { // ни полных номеров на странице, ни кода региона → нельзя достроить, но и терять нельзя $html = 'филиал'; From 084b5f4cd7bf9dc87797a77f369b4b0c058a6c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 14:47:55 +0300 Subject: [PATCH 066/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20=D1=88=D0=B0=D0=B32):=20=D1=8D?= =?UTF-8?q?=D0=BA=D1=80=D0=B0=D0=BD=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=83=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=20=E2=80=94=20=C2=AB=D0=B3=D0=B4=D0=B5?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=88=D0=BB=D0=B8=C2=BB=20=D1=81=D1=81=D1=8B?= =?UTF-8?q?=D0=BB=D0=BA=D0=B0=D0=BC=D0=B8=20+=20=D1=81=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Экран 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 --- app/resources/js/api/autopodbor.ts | 11 ++++ .../screens/FieldCompetitorPreview.vue | 21 ++++++++ .../screens/FieldCompetitorScreen.story.vue | 17 +++++++ .../screens/FieldCompetitorScreen.vue | 51 +++++++++++++++++-- .../views/autopodbor/screens/field-shared.css | 38 ++++++++++++++ .../AutopodborFieldCompetitorScreen.spec.ts | 26 ++++++++++ 6 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 app/resources/js/views/autopodbor/screens/FieldCompetitorPreview.vue create mode 100644 app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.story.vue diff --git a/app/resources/js/api/autopodbor.ts b/app/resources/js/api/autopodbor.ts index 1d9936b5..2e98554c 100644 --- a/app/resources/js/api/autopodbor.ts +++ b/app/resources/js/api/autopodbor.ts @@ -64,6 +64,12 @@ export interface CompetitorDto { search_run_id: number | null; } +/** Одно место, где нашли источник (для кликабельного списка «где нашли»). */ +export interface WhereFound { + label: string; + url: string | null; +} + export interface SourceDto { id: number; competitor_id: number; @@ -76,6 +82,11 @@ export interface SourceDto { provenance_label: string | null; created_project_id: number | null; existing_project_id?: number | null; + /** Полный список «где нашли» (места + ссылки); подтверждения = его длина. */ + where_found?: WhereFound[]; + confirmations?: number; + /** Адрес офиса/филиала, если источник привязан к конкретному адресу. */ + office?: string | null; } /** Статус проекта, привязанного к источнику (для рабочего места «поле»). */ diff --git a/app/resources/js/views/autopodbor/screens/FieldCompetitorPreview.vue b/app/resources/js/views/autopodbor/screens/FieldCompetitorPreview.vue new file mode 100644 index 00000000..f3078cae --- /dev/null +++ b/app/resources/js/views/autopodbor/screens/FieldCompetitorPreview.vue @@ -0,0 +1,21 @@ + + + diff --git a/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.story.vue b/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.story.vue new file mode 100644 index 00000000..70ed6c88 --- /dev/null +++ b/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.story.vue @@ -0,0 +1,17 @@ + + + diff --git a/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue b/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue index 2fd8173c..b9ef0e44 100644 --- a/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue +++ b/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue @@ -1,7 +1,7 @@ + diff --git a/docs/superpowers/prototypes/2026-06-30-konkurentnoe-pole-istochniki-sbor2-povtor.html b/docs/superpowers/prototypes/2026-06-30-konkurentnoe-pole-istochniki-sbor2-povtor.html new file mode 100644 index 00000000..5c68b60d --- /dev/null +++ b/docs/superpowers/prototypes/2026-06-30-konkurentnoe-pole-istochniki-sbor2-povtor.html @@ -0,0 +1,105 @@ + +Лидерра — Повторный сбор источников (КрасЛомбард) + + +
+ +
+
КрасЛомбард — повторный сбор
+
🔍1 839 600 ₽🔔ЛК
+
+ ← Назад к источникам конкурента +
КрасЛомбард сеть · региональный
+ +
+

🔄 Повторный сбор выполнен

+

Прошли всю сеть конкурента заново и сравнили с вашими источниками. Ниже — только изменения. То, что у вас уже есть и осталось на месте, не трогаем и не дублируем.

+
+
🆕 2 новых
+
⚠️ 1 не найден повторно
+
26 без изменений
+
+
+ +
🆕 Новые источники 2
+

Появились у конкурента с прошлого раза. В ваших источниках их ещё нет — добавьте, если нужны.

+ +
📞
+
8(391)290-10-10 городской ✓ настоящий
+
🏢 новый офис: пр. Мира, 10
+
+
+ +
📞
+
8(923)444-80-80 мобильный ✓ настоящий
+
🏢 новый офис: Канск, ул. Московская, 70
+
📍 где нашли: 2ГИС
+
+ +
⚠️ Не найдены повторно 1
+

Эти источники у вас есть, но при новом сборе мы их у конкурента не нашли. Возможно, офис закрыли или сменили номер — проверьте сами и решите.

+ +
📞
+
8(391)284-33-33 городской не найден сейчас
+
🏢 был: ул. Тотмина, 1а · по нему у вас работает проект
+
Раньше стоял на сайте и в 2ГИС, сейчас не нашли ни там, ни там. Проверьте — возможно, офис закрылся.
+
+
+ +
✓ Без изменений 26
+
26 ваших источников на месте — ничего делать не нужно. Всё работает как прежде.Показать список →
+ +
+
+
+ diff --git a/docs/superpowers/prototypes/2026-06-30-live-kraslombard-collected.html b/docs/superpowers/prototypes/2026-06-30-live-kraslombard-collected.html new file mode 100644 index 00000000..2dae9608 --- /dev/null +++ b/docs/superpowers/prototypes/2026-06-30-live-kraslombard-collected.html @@ -0,0 +1,40 @@ +Лидерра — КрасЛомбард · живой сбор
КрасЛомбард
1 839 600 ₽ЛК
← Назад в поле
КрасЛомбард ● ЖИВОЙ СБОР

Ломбард · Красноярск · собрано движком с сайта конкурента и 2ГИС · номера реальные, проверьте по ссылкам

📍 «Где нашли» — места, где встретили номер. Больше мест — надёжнее, такие выше.
💻 «в коде сайта» — номер зашит в страницу как кнопка-звонок. На него идёт звонок с сайта.
📍 «2ГИС» — номер и адрес офиса из карточки справочника.
📞 Собрано живьём: 29 настоящих номеров · центральный подтверждён сайтом + 2ГИС
📞
8 (391) 292-00-00 городской ✓ настоящий
🏢 улица Ладо Кецховели, 30
✓2подтвержд.
📞
8 (391) 250-40-00 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 252-00-00 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 255-00-00 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 271-33-33 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 278-40-40 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 279-70-00 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 281-00-00 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 281-70-70 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 281-80-00 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 282-85-85 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 282-88-28 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 284-33-33 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 284-55-55 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 286-40-00 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 288-85-85 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 288-92-88 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 289-33-33 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 289-80-00 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (391) 292-55-55 городской ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (902) 962-32-32 мобильный ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (902) 982-85-85 мобильный ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (902) 982-88-28 мобильный ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (923) 300-22-22 мобильный ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (923) 309-00-90 мобильный ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (953) 586-40-00 мобильный ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (953) 588-85-85 мобильный ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (953) 841-70-70 мобильный ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📞
8 (953) 841-80-00 мобильный ✓ настоящий
📍 где нашли: 💻 в коде сайта
✓11 место
📌 Черновик. Отметьте нужные источники.
\ No newline at end of file diff --git a/docs/superpowers/prototypes/serve.js b/docs/superpowers/prototypes/serve.js new file mode 100644 index 00000000..d0865143 --- /dev/null +++ b/docs/superpowers/prototypes/serve.js @@ -0,0 +1,17 @@ +const http = require("http"), fs = require("fs"), path = require("path"); +const ROOT = path.join(__dirname, "proto"); +const PORT = 8123; +const MIME = { ".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "application/javascript", ".png": "image/png", ".svg": "image/svg+xml" }; +http.createServer((req, res) => { + let p = decodeURIComponent(req.url.split("?")[0]); + if (p === "/") p = "/index.html"; + const f = path.join(ROOT, p); + if (!f.startsWith(ROOT) || !fs.existsSync(f)) { res.writeHead(404); res.end("404"); return; } + res.writeHead(200, { + "Content-Type": MIME[path.extname(f)] || "application/octet-stream", + "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", + "Pragma": "no-cache", + "Expires": "0", + }); + fs.createReadStream(f).pipe(res); +}).listen(PORT, () => console.log("Прототип поднят: http://localhost:" + PORT + "/")); From 3ca447d8b91c919749434b499f1c805532c1a58c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 14:53:04 +0300 Subject: [PATCH 068/417] =?UTF-8?q?fix(=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF?= =?UTF-8?q?=D0=B0=D1=81=D0=BD=D0=BE=D1=81=D1=82=D1=8C):=20XSS=20=D0=B2=20?= =?UTF-8?q?=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=D1=85=20=C2=AB=D0=B3=D0=B4?= =?UTF-8?q?=D0=B5=20=D0=BD=D0=B0=D1=88=D0=BB=D0=B8=C2=BB=20+=20path=20trav?= =?UTF-8?q?ersal=20=D0=B2=20serve.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сек-ревью коммита нашло две дыры: 1. FieldCompetitorScreen: href из where_found рендерился без проверки схемы — javascript:-ссылка выполнила бы скрипт при клике. Добавлен safeUrl(): кликабельны только http/https, остальное — простым текстом. +TDD-тест. 2. serve.js (локальный dev-сервер прототипов): обход каталога. realpath-сверка с ROOT + require isFile + bind 127.0.0.1. Autopodbor FieldCompetitor 13/13. Co-Authored-By: Claude Opus 4.8 --- .../autopodbor/screens/FieldCompetitorScreen.vue | 14 ++++++++++++-- .../AutopodborFieldCompetitorScreen.spec.ts | 14 ++++++++++++++ docs/superpowers/prototypes/serve.js | 13 +++++++++---- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue b/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue index b9ef0e44..0dd99c98 100644 --- a/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue +++ b/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue @@ -33,6 +33,16 @@ function whereFound(s: FieldSourceDto): WhereFound[] { if (s.provenance_label) return [{ label: s.provenance_label, url: s.provenance_url }]; return []; } +// Кликабельной делаем только http/https — защита от javascript:-схемы (XSS). +function safeUrl(u: string | null): string | null { + if (!u) return null; + try { + const p = new URL(u, window.location.origin); + return p.protocol === 'http:' || p.protocol === 'https:' ? u : null; + } catch { + return null; + } +} // Сортировка: подменные — вниз; среди остальных больше подтверждений — выше. function sortSources(list: FieldSourceDto[]): FieldSourceDto[] { return [...list].sort((a, b) => { @@ -442,7 +452,7 @@ defineExpose({ ctab, selected, inWork, props: props, switchTab, toggle, openEdit
где нашли: @@ -518,7 +528,7 @@ defineExpose({ ctab, selected, inWork, props: props, switchTab, toggle, openEdit
где нашли: diff --git a/app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts b/app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts index 6b74df0d..f6c96084 100644 --- a/app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts +++ b/app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts @@ -212,6 +212,20 @@ describe('FieldCompetitorScreen', () => { expect(links[1].text()).toContain('Весны'); }); + it('не делает кликабельной ссылку с javascript:-схемой (XSS-защита)', async () => { + const store = useAutopodborStore(); + seed(store, [ + src({ id: 10, signal_type: 'call', identifier: '78432001122', phone_kind: 'real', phone_type: 'city', where_found: [{ label: 'плохая', url: 'javascript:alert(1)' }, { label: '2ГИС', url: 'https://2gis.ru/x' }] }), + ]); + const w = mountFc(makeNav(3)); + await new Promise((r) => setTimeout(r, 0)); + const links = w.findAll('a.ld-fchip'); + expect(links.length).toBe(1); // только https кликабельна + expect(links[0].attributes('href')).toBe('https://2gis.ru/x'); + expect(links.some((a) => (a.attributes('href') ?? '').toLowerCase().startsWith('javascript'))).toBe(false); + expect(w.text()).toContain('плохая'); // не потеряли — показана простым текстом + }); + it('окно «Изменить источник» открывается с залоченным типом', async () => { const store = useAutopodborStore(); seed(store, [src({ id: 10, signal_type: 'site', identifier: 'okna.ru' })]); diff --git a/docs/superpowers/prototypes/serve.js b/docs/superpowers/prototypes/serve.js index d0865143..72af4553 100644 --- a/docs/superpowers/prototypes/serve.js +++ b/docs/superpowers/prototypes/serve.js @@ -6,12 +6,17 @@ http.createServer((req, res) => { let p = decodeURIComponent(req.url.split("?")[0]); if (p === "/") p = "/index.html"; const f = path.join(ROOT, p); - if (!f.startsWith(ROOT) || !fs.existsSync(f)) { res.writeHead(404); res.end("404"); return; } + // Защита от выхода за ROOT (path traversal): сверяем РЕАЛЬНЫЙ путь и требуем файл. + let real; + try { real = fs.realpathSync(f); } catch { res.writeHead(404); res.end("404"); return; } + const rootReal = fs.realpathSync(ROOT) + path.sep; + if (!real.startsWith(rootReal) || !fs.statSync(real).isFile()) { res.writeHead(404); res.end("404"); return; } res.writeHead(200, { - "Content-Type": MIME[path.extname(f)] || "application/octet-stream", + "Content-Type": MIME[path.extname(real)] || "application/octet-stream", "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "Pragma": "no-cache", "Expires": "0", }); - fs.createReadStream(f).pipe(res); -}).listen(PORT, () => console.log("Прототип поднят: http://localhost:" + PORT + "/")); + fs.createReadStream(real).pipe(res); + // Локальный dev-сервер прототипов — только loopback, наружу не торчит. +}).listen(PORT, "127.0.0.1", () => console.log("Прототип поднят: http://127.0.0.1:" + PORT + "/")); From eb856353f8b8e31d580819724dc73bedb791889f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 15:08:53 +0300 Subject: [PATCH 069/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20+=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D1=82-=D0=BF=D1=80=D0=BE=D0=BC=D0=BF=D1=82=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GOTOVNOST-status: к внедрению НЕ готов; шаг 2 собран по частям, не встроен; шаг 1 — реальной логики никогда не писали (всегда заглушка), из репо ничего не терялось (проверено git); собрать шаг 1 = написать одну findCompetitors. - KONTEKST-prodolzhenie: промпт для корректного подхвата контекста + план. Co-Authored-By: Claude Opus 4.8 --- .../findings/2026-06-30-GOTOVNOST-status.md | 94 +++++++++++++++++++ .../plans/2026-06-30-KONTEKST-prodolzhenie.md | 66 +++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 docs/superpowers/findings/2026-06-30-GOTOVNOST-status.md create mode 100644 docs/superpowers/plans/2026-06-30-KONTEKST-prodolzhenie.md diff --git a/docs/superpowers/findings/2026-06-30-GOTOVNOST-status.md b/docs/superpowers/findings/2026-06-30-GOTOVNOST-status.md new file mode 100644 index 00000000..daf7a7cc --- /dev/null +++ b/docs/superpowers/findings/2026-06-30-GOTOVNOST-status.md @@ -0,0 +1,94 @@ +# Автоподбор конкурентов — статус готовности к внедрению (30.06.2026) + +> Честная сводка: что собрано, что нет, готов ли агент к боевому, собран ли шаг 1. +> Воркстри `worktree-avtopodbor`, **НЕ на проде**. Не считая правок самого последнего захода. + +--- + +## КОРОТКИЙ ВЕРДИКТ + +- **К внедрению на боевой — НЕ готов.** +- **Шаг 1 (поиск конкурента и его площадок) — реальная логика НИКОГДА не писалась (всегда заглушка); + обвязка готова. Из репозитория ничего не терялось** (проверено по git — см. раздел ниже). +- **Шаг 2 (сбор источников по конкуренту) — собран ПО ЧАСТЯМ и проверен в изоляции, но в портал НЕ встроен.** + +В приложении и для поиска (шаг 1), и для изучения (шаг 2) сейчас работает **заглушка `FakeCompetitorAgent`** +(`AutopodborServiceProvider` байндит её). Настоящий движок шага 2 (`RealCompetitorAgent`) написан и +протестирован, но **одной строкой провайдера ещё не подключён**. + +--- + +## ШАГ 1 — поиск конкурента + его площадок + +**Статус: реальная логика поиска НЕ написана (всегда была заглушка); обвязка — готова.** 🟡 + +Проверено по коду и git-истории (30.06): +- **Из репозитория ничего не терялось.** Реального движка шага 1 в коммитах **никогда не было**: + коммит `3b9c1b8b` (28.06) сразу завёл `CompetitorAgent` **интерфейс + заглушку + binding**, и с тех + пор `FakeCompetitorAgent::findCompetitors` отдаёт **4 захардкоженных демо-конкурента** («Окна + Комфорт» и т.п.) — не настоящий поиск. +- «Утерян» относится к **research-спайку** (JS-прототип `resolver.js`, исследовал *как* искать) — он + жил в scratchpad и самоочистился. Это **черновик, не рабочий код**, он не коммитился. На диске сейчас + только спайк шага 2 (`engine_spike.php`), спайка шага 1 нет. +- **Обвязка шага 1 — настоящая и рабочая:** `RunAutopodborSearchJob` реально зовёт + `$agent->findCompetitors(...)`, есть списание (`AutopodborChargeService`), дедуп (`AutopodborDedup`), + запись в БД (`AutopodborRun`), контроллер, фронт-экраны. Заглушка — только сам «мозг»-агент. + +**Вывод:** «собрать шаг 1» = **написать ОДНУ реальную реализацию `findCompetitors`** (как написан +настоящий `studyCompetitor` для шага 2) по дизайну брейншторма §12 и подменить заглушку в провайдере. +Это разработка с нуля по готовому контракту, **не** восстановление потерянной инфраструктуры. +- Итог сейчас: «дай конкурентов по моей нише» на портале возвращает демо-данные, не настоящий поиск. + +--- + +## ШАГ 2 — сбор источников по одному конкуренту + +**Статус: ядро собрано и проверено в изоляции; в портал НЕ встроено.** 🟡 + +### Что СОБРАНО и закоммичено (worktree) +- **Извлечение из кода сайта** — `HtmlPhoneScanner` (tel/schema/microdata/тело/e-mail), + `CalltrackingDetector`, `PhoneType`, `CandidateBuilder`, `SourceAggregator`, `DirectoryParser`. +- **Достройка номеров без кода города** (региононезависимо) + защита от фальшивок из обрезков. +- **Загрузка справочников 2ГИС через xfetch.ru** — `XfetchClient`, `XfetchDirectoryFetcher`, + `PageFetcher`, `CompositeFetcher` (сайт — обычным curl, справочники — xfetch). +- **Экран портала** `FieldCompetitorScreen`: «где нашли» кликабельными ссылками (только http/https), + сортировка по подтверждениям, адреса филиалов. DTO расширен (`where_found/confirmations/office`). +- **Тесты:** бэкенд Autopodbor 49/49; фронт FieldCompetitor 13/13. +- **Доказано на живом КрасЛомбаре** (через изолированные харнессы, НЕ через боевой API): + 29 настоящих номеров с сайта + 11 филиалов 2ГИС с адресами. + +### Что НЕ сделано — нужно для внедрения 🔴 +1. **Провод реального агента**: заменить `FakeCompetitorAgent` → `RealCompetitorAgent` в провайдере; + собрать `RealCompetitorAgent` с `CompositeFetcher(curl-сайт + xfetch-справочники)`. +2. **Ключ xfetch в боевом окружении** (`XFETCH_API_KEY` в `.env` прода) + ретрай флаки 2ГИС в + `XfetchClient`/`XfetchDirectoryFetcher` (сейчас ретрай был только в харнессе). +3. **Бэкенд-провод «где нашли»**: список мест + подтверждения + адрес довести через + `StudyCompetitorResult` → таблицу `autopodbor_sources` (миграция) → API `FieldSourceDto`. + Сейчас движок собирает богатый список, но контракт схлопывает его в один провенанс. +4. **Проброс кода города (region) из запроса** в `HtmlPhoneScanner` через `CandidateBuilder` + (сейчас работает только «код со страницы»; запасной по региону — не подключён к боевому потоку). +5. **Сквозной прогон через настоящий портал** (кнопка «Изучить» → API → БД → экран), а не харнесс; + проверка денег/идемпотентности/RLS на реальном пути. +6. **Яндекс.Карты** — отдельный парсер под реальный DOM (формат карточек ≠ 2ГИС). +7. **Поиск других площадок конкурента** на шаге 2 (поле «сайт» в карточках 2ГИС + общий веб-поиск + через firecrawl) — НЕ начато. +8. **SSRF-хвост** резервного Playwright-пути (`§6.4` сводки) — если этот путь оживёт. + +--- + +## ИНСТРУМЕНТЫ (по факту проверки 30.06) +- **2ГИС**: discovery списка филиалов — **только xfetch** (firecrawl на странице поиска отдаёт капчу/пусто). + Карточку филиала firecrawl берёт (с `rawHtml`), но соседние филиалы не перечисляет. +- **Сайт конкурента**: обычный curl (не антибот). **firecrawl** — для сайта/веб-поиска площадок. +- Ключи xfetch + firecrawl — в `app/.env` (gitignore), в гит не попадают. + +--- + +## ПОРЯДОК ВНЕДРЕНИЯ (рекомендация) +1. Бэкенд-провод «где нашли» + миграция `autopodbor_sources` (п.3) — экран заработает по-настоящему. +2. Провод `RealCompetitorAgent` + xfetch-ключ + ретрай (пп.1,2) + сквозной прогон (п.5). +3. Восстановить **шаг 1** (движок поиска) — без него фича начинается с ручного ввода конкурента. +4. Яндекс (п.6) и поиск площадок (п.7). +5. Приёмка/безопасность → выкат за тумблером. + +Источники: `2026-06-30-STEP2-SVODKA.md` (детали механизма и дефектов), брейншторм §8–§14 (решения владельца). diff --git a/docs/superpowers/plans/2026-06-30-KONTEKST-prodolzhenie.md b/docs/superpowers/plans/2026-06-30-KONTEKST-prodolzhenie.md new file mode 100644 index 00000000..c2985b5e --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-KONTEKST-prodolzhenie.md @@ -0,0 +1,66 @@ +# Контекст-промпт продолжения «Автоподбора конкурентов» (после компакта) + +> Скопируй блок ниже в новый промпт, чтобы правильно подхватить контекст. +> Воркстри `worktree-avtopodbor`, ветка `worktree-avtopodbor`. **НЕ прод. Коммит — спросив «эскейп». +> Пуш — только на gitea (GitHub suspended). Простым языком — владелец не программист.** + +--- + +Продолжаем фичу **«Автоподбор конкурентов» / «Конкурентное поле»** Лидерры. Воркстри +`worktree-avtopodbor` (код под `app/`). НЕ прод. Руками 2ГИС/Яндекс не парсить (антибот) — только +через сервисы. Коммит — спросив «эскейп». Пуш — gitea. + +ПРОЧИТАЙ В НАЧАЛЕ (durable, не по памяти): +1. **Статус готовности:** `docs/superpowers/findings/2026-06-30-GOTOVNOST-status.md` — что собрано/не + собрано, готов ли к внедрению, состояние шага 1 и шага 2. +2. **Сводка по шагу 2:** `docs/superpowers/findings/2026-06-30-STEP2-SVODKA.md` — механизм, код, + живые прогоны, открытые дефекты §6. +3. **Решения владельца:** `docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-brainstorm-notes.md` + §8–§14 (⚠️ в ОСНОВНОМ репо, не воркстри). §12 = движок (включая дизайн шага 1: 2ГИС/Яндекс + + похожесть), §14 = UX «два ящика». +4. Розетка-контракт: `…-autopodbor-konkurentov-design.md` §7. + +ГДЕ МЫ СЕЙЧАС (проверено по коду+git 30.06): +- **К внедрению НЕ готов.** В приложении и поиск (шаг 1), и изучение (шаг 2) идут через **заглушку** + `FakeCompetitorAgent` (захардкоженные демо-данные). `AutopodborServiceProvider` байндит её. +- **Шаг 2** — ядро собрано и проверено на живом КрасЛомбаре (через изолированные харнессы, НЕ через + боевой API): `HtmlPhoneScanner`/`CandidateBuilder`/`SourceAggregator`/`DirectoryParser` + + `XfetchClient`/`XfetchDirectoryFetcher`/`CompositeFetcher` + `RealCompetitorAgent`. Экран + `FieldCompetitorScreen`: «где нашли» ссылками, сортировка по подтверждениям. Тесты: бэк 49/49, + фронт FieldCompetitor 13/13. НО в портал НЕ встроен (см. ниже). +- **Шаг 1** — реальной логики поиска **никогда не писали** (с первого коммита `3b9c1b8b` — интерфейс + + заглушка). Из репо ничего не терялось; «утерян» = research-спайк `resolver.js` в scratchpad, он + самоочистился (черновик, не коммитился). **Обвязка шага 1 готова** (`RunAutopodborSearchJob` зовёт + агента, списание/дедуп/БД/контроллер/фронт). Собрать шаг 1 = написать ОДНУ реальную `findCompetitors` + по контракту (как сделан `studyCompetitor`). + +ПЛАН (порядок — решение владельца): +1. **Доделать шаг 2 до боевого** (см. GOTOVNOST §«Что НЕ сделано»): провод `RealCompetitorAgent` в + провайдер + `CompositeFetcher`; ключ xfetch в боевом `.env` + ретрай флаки 2ГИС в клиенте; бэкенд- + провод «где нашли» (список+подтверждения+адрес) через `StudyCompetitorResult`→БД `autopodbor_sources` + (миграция)→API `FieldSourceDto`; проброс region в `HtmlPhoneScanner` через `CandidateBuilder`; + сквозной прогон через настоящую кнопку «Изучить». +2. **Написать шаг 1** (реальный `findCompetitors`) по дизайну §12. +3. Яндекс.Карты (отдельный парсер) + поиск других площадок конкурента (поле «сайт» в карточках 2ГИС + + веб-поиск firecrawl). + +ИНСТРУМЕНТЫ (по факту проверки 30.06): +- **2ГИС список филиалов — только xfetch** (firecrawl на странице поиска отдаёт капчу/пусто; карточку + берёт с `rawHtml`, но соседние филиалы не перечисляет). Сайт конкурента — обычный curl. firecrawl — + для сайта/веб-поиска площадок. +- xfetch: `POST https://xf4.ru/fetch` тело `{url,api_key,render:true,timeout:20}` → HTML в base64. + firecrawl v1: `POST https://api.firecrawl.dev/v1/scrape` (Bearer), для 2ГИС-карточки `rawHtml`+ + `onlyMainContent:false`+`proxy:stealth`. +- Ключи `XFETCH_API_KEY` и `FIRECRAWL_API_KEY` — в `app/.env` (gitignore, в гит НЕ попадают). + +СРЕДА (квирки): тесты бэк `cd app && ./vendor/bin/pest tests/Unit/Autopodbor`; фронт +`npx vitest run tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts`; PHP `c:/tools/php83/php`; +Histoire для рендера экрана: `npx histoire dev --host 127.0.0.1` (слушает 6006; без `--host` биндится +на `::1` и браузер не пускает); Playwright MCP лочит профиль → снять `*mcp-chrome-*` chrome; экраны/ +скриншоты — viewport повыше, иначе дальние карточки не прорисовываются; `/tmp` между Bash не живёт; +lefthook не в PATH (коммиты проходят); 22 фронт-теста падают пред-существующе (не трогать). + +⚠️ 22 пред-существующих фронт-падения (SettingsView/ErrorView/DealsView/ProjectsView/LegalDocView/ +KanbanCard/AdminPricing) — НЕ от автоподбора (проверено git-stash'ем). Не чинить в рамках этой фичи. + +Стартуй с плана п.1. Перед коммитом — «эскейп». Пуш — gitea. From a4a523f73f8ef7b2342a6986a4a6ea343b116a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 15:40:30 +0300 Subject: [PATCH 070/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B1=D0=BE=D0=B3=D0=B0?= =?UTF-8?q?=D1=82=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D0=BD=D1=81=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=E2=80=94=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20?= =?UTF-8?q?=C2=AB=D0=B3=D0=B4=D0=B5=20=D0=BD=D0=B0=D1=88=D0=BB=D0=B8=C2=BB?= =?UTF-8?q?,=20=D0=BE=D1=84=D0=B8=D1=81,=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шаг 2 «Конкурентного поля»: один номер встречается в нескольких местах — код сайта плюс карточки 2ГИС/Яндекс с разными адресами. Раньше хранилось одно provenance_url/label — список терялся. Теперь сквозной провод движок→контракт→джоб→БД→API; фронт уже умел показывать кликабельным списком с подтверждениями. - autopodbor_sources +3 колонки where_found/office/confirmations миграция 2026_06_30_120000, идемпотентная, RLS-review APPROVE 7/7 - canon-sync schema.sql v8.59 плюс CHANGELOG, вкл. catch-up phone_type/box 29.06 - тесты бэкенда автоподбора 122/122 НЕ на проде, воркстри avtopodbor. Co-Authored-By: Claude Opus 4.8 --- .../Resources/Autopodbor/SourceResource.php | 3 + .../Jobs/Autopodbor/RunAutopodborStudyJob.php | 3 + app/app/Models/AutopodborSource.php | 5 ++ .../Agent/Dto/StudyCompetitorResult.php | 2 +- .../Autopodbor/Agent/RealCompetitorAgent.php | 17 ++++- ..._add_where_found_to_autopodbor_sources.php | 45 +++++++++++++ app/tests/Doubles/RichCompetitorAgent.php | 48 ++++++++++++++ .../AutopodborSourceWhereFoundTest.php | 63 +++++++++++++++++++ .../AutopodborSourcesSchemaTest.php | 2 + .../Agent/RealCompetitorAgentTest.php | 41 ++++++++++++ db/CHANGELOG_schema.md | 37 ++++++++++- db/schema.sql | 16 ++++- 12 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 app/database/migrations/2026_06_30_120000_add_where_found_to_autopodbor_sources.php create mode 100644 app/tests/Doubles/RichCompetitorAgent.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborSourceWhereFoundTest.php diff --git a/app/app/Http/Resources/Autopodbor/SourceResource.php b/app/app/Http/Resources/Autopodbor/SourceResource.php index 4288e45c..95968c0a 100644 --- a/app/app/Http/Resources/Autopodbor/SourceResource.php +++ b/app/app/Http/Resources/Autopodbor/SourceResource.php @@ -22,6 +22,9 @@ class SourceResource extends JsonResource 'provenance_url' => $this->provenance_url, 'provenance_label' => $this->provenance_label, 'created_project_id' => $this->created_project_id, + 'where_found' => $this->where_found ?? [], + 'office' => $this->office, + 'confirmations' => $this->confirmations, ]; } } diff --git a/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php b/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php index 465eab95..f2862453 100644 --- a/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php +++ b/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php @@ -87,6 +87,9 @@ class RunAutopodborStudyJob implements ShouldQueue 'phone_type' => $s['phone_type'] ?? null, 'provenance_url' => $s['provenance_url'] ?? null, 'provenance_label' => $s['provenance_label'] ?? null, + 'where_found' => $s['where_found'] ?? null, + 'office' => $s['office'] ?? null, + 'confirmations' => $s['confirmations'] ?? 1, ] ); } diff --git a/app/app/Models/AutopodborSource.php b/app/app/Models/AutopodborSource.php index d1cd235f..7aa4453c 100644 --- a/app/app/Models/AutopodborSource.php +++ b/app/app/Models/AutopodborSource.php @@ -24,10 +24,15 @@ class AutopodborSource extends Model 'dedup_key', 'created_project_id', 'box', + 'where_found', + 'office', + 'confirmations', ]; protected $casts = [ 'created_at' => 'datetime', + 'where_found' => 'array', + 'confirmations' => 'integer', ]; public function competitor(): BelongsTo diff --git a/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorResult.php b/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorResult.php index bdd54730..d65aa911 100644 --- a/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorResult.php +++ b/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorResult.php @@ -5,7 +5,7 @@ namespace App\Services\Autopodbor\Agent\Dto; final class StudyCompetitorResult { /** - * @param array $sources + * @param array,office?:?string,confirmations?:int}> $sources */ public function __construct(public readonly array $sources) {} } diff --git a/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php b/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php index adb686c7..776ff80c 100644 --- a/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php +++ b/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php @@ -72,7 +72,17 @@ final class RealCompetitorAgent implements CompetitorAgent } } foreach ($siteIds as $domain => $meta) { - $rows[] = ['signal_type' => 'site', 'identifier' => $domain, 'phone_kind' => null, 'phone_type' => null, 'provenance_url' => $meta['url'], 'provenance_label' => $meta['label']]; + $rows[] = [ + 'signal_type' => 'site', + 'identifier' => $domain, + 'phone_kind' => null, + 'phone_type' => null, + 'provenance_url' => $meta['url'], + 'provenance_label' => $meta['label'], + 'where_found' => [['label' => $meta['label'], 'url' => $meta['url']]], + 'office' => null, + 'confirmations' => 1, + ]; } // 3b. Телефоны @@ -83,7 +93,7 @@ final class RealCompetitorAgent implements CompetitorAgent return new StudyCompetitorResult($rows); } - /** @return array{signal_type:string,identifier:string,phone_kind:?string,phone_type:?string,provenance_url:?string,provenance_label:?string} */ + /** @return array{signal_type:string,identifier:string,phone_kind:?string,phone_type:?string,provenance_url:?string,provenance_label:?string,where_found:array,office:?string,confirmations:int} */ private function callRow(CollectedSource $c): array { $top = $c->sources[0] ?? ['label' => null, 'url' => null]; @@ -95,6 +105,9 @@ final class RealCompetitorAgent implements CompetitorAgent 'phone_type' => $c->phoneType, 'provenance_url' => $top['url'] ?? null, 'provenance_label' => $top['label'] ?? null, + 'where_found' => $c->sources, + 'office' => $c->office, + 'confirmations' => $c->confirmations(), ]; } diff --git a/app/database/migrations/2026_06_30_120000_add_where_found_to_autopodbor_sources.php b/app/database/migrations/2026_06_30_120000_add_where_found_to_autopodbor_sources.php new file mode 100644 index 00000000..dc2c9ce7 --- /dev/null +++ b/app/database/migrations/2026_06_30_120000_add_where_found_to_autopodbor_sources.php @@ -0,0 +1,45 @@ + DB::selectOne( + 'SELECT 1 AS r FROM information_schema.columns WHERE table_name = ? AND column_name = ?', + ['autopodbor_sources', $col], + ) !== null; + + if (! $has('where_found')) { + DB::statement('ALTER TABLE autopodbor_sources ADD COLUMN where_found JSONB'); + } + if (! $has('office')) { + DB::statement('ALTER TABLE autopodbor_sources ADD COLUMN office VARCHAR(255)'); + } + if (! $has('confirmations')) { + DB::statement('ALTER TABLE autopodbor_sources ADD COLUMN confirmations SMALLINT NOT NULL DEFAULT 1'); + } + } + + public function down(): void + { + DB::statement('ALTER TABLE autopodbor_sources DROP COLUMN IF EXISTS where_found'); + DB::statement('ALTER TABLE autopodbor_sources DROP COLUMN IF EXISTS office'); + DB::statement('ALTER TABLE autopodbor_sources DROP COLUMN IF EXISTS confirmations'); + } +}; diff --git a/app/tests/Doubles/RichCompetitorAgent.php b/app/tests/Doubles/RichCompetitorAgent.php new file mode 100644 index 00000000..59f41bb8 --- /dev/null +++ b/app/tests/Doubles/RichCompetitorAgent.php @@ -0,0 +1,48 @@ + 'call', + 'identifier' => '73912920000', + 'phone_kind' => 'real', + 'phone_type' => 'city', + 'provenance_url' => 'https://k.ru', + 'provenance_label' => 'в коде сайта', + 'where_found' => [ + ['label' => 'в коде сайта', 'url' => 'https://k.ru'], + ['label' => '2ГИС · ул. Весны 7а', 'url' => 'https://2gis.ru/firm/1'], + ], + 'office' => 'Единая справочная', + 'confirmations' => 2, + ], + ]); + } + + public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult + { + return new ResolveByNameResult([]); + } +} diff --git a/app/tests/Feature/Autopodbor/AutopodborSourceWhereFoundTest.php b/app/tests/Feature/Autopodbor/AutopodborSourceWhereFoundTest.php new file mode 100644 index 00000000..321b0248 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborSourceWhereFoundTest.php @@ -0,0 +1,63 @@ +bind(CompetitorAgent::class, RichCompetitorAgent::class); + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '900', 'type' => 'decimal']); + $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'КрасЛомбард', 'dedup_key' => 'site:k.ru', 'site_url' => 'k.ru']); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'queued', 'region_code' => 24, 'competitor_id' => $comp->id, 'params' => []]); + + app()->call([new RunAutopodborStudyJob($run->id), 'handle']); + + $src = AutopodborSource::where('competitor_id', $comp->id)->where('signal_type', 'call')->firstOrFail(); + expect($src->where_found)->toBeArray()->toHaveCount(2); + expect(collect($src->where_found)->pluck('url'))->toContain('https://2gis.ru/firm/1'); + expect($src->office)->toBe('Единая справочная'); + expect($src->confirmations)->toBe(2); +}); + +it('API-ресурс источника отдаёт where_found, office и confirmations', function () { + $tenant = Tenant::factory()->create(); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'К', 'dedup_key' => 'site:k.ru', 'site_url' => 'k.ru']); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 24, 'competitor_id' => $comp->id, 'params' => []]); + $src = AutopodborSource::create([ + 'tenant_id' => $tenant->id, + 'competitor_id' => $comp->id, + 'study_run_id' => $run->id, + 'signal_type' => 'call', + 'identifier' => '73912920000', + 'phone_kind' => 'real', + 'phone_type' => 'city', + 'provenance_url' => 'https://k.ru', + 'provenance_label' => 'в коде сайта', + 'dedup_key' => 'call:73912920000', + 'where_found' => [['label' => 'в коде сайта', 'url' => 'https://k.ru'], ['label' => '2ГИС', 'url' => 'https://2gis.ru/firm/1']], + 'office' => 'Единая справочная', + 'confirmations' => 2, + ]); + + $arr = (new SourceResource($src))->toArray(request()); + + expect($arr['where_found'])->toHaveCount(2); + expect($arr['office'])->toBe('Единая справочная'); + expect($arr['confirmations'])->toBe(2); +}); diff --git a/app/tests/Feature/Autopodbor/AutopodborSourcesSchemaTest.php b/app/tests/Feature/Autopodbor/AutopodborSourcesSchemaTest.php index b427bccf..2be90d80 100644 --- a/app/tests/Feature/Autopodbor/AutopodborSourcesSchemaTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborSourcesSchemaTest.php @@ -6,5 +6,7 @@ it('создаёт autopodbor_sources', function () { expect(DB::getSchemaBuilder()->hasColumns('autopodbor_sources', [ 'id','tenant_id','competitor_id','study_run_id','signal_type','identifier', 'phone_kind','provenance_url','provenance_label','dedup_key','created_project_id','created_at', + // §14: богатый провенанс — список «где нашли», офис, число подтверждений + 'where_found','office','confirmations', ]))->toBeTrue(); }); diff --git a/app/tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php b/app/tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php index 113d5b36..d75c5f97 100644 --- a/app/tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php +++ b/app/tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php @@ -40,6 +40,47 @@ it('studyCompetitor: настоящий номер из кода+2ГИС нав expect(collect($sites)->pluck('identifier'))->toContain('k.ru'); }); +it('телефонная строка несёт список «где нашли», офис и число подтверждений', function () { + $fetcher = new FakeFetcher( + sites: ['https://k.ru' => new FetchedSite(url: 'https://k.ru', rawHtml: 'x')], + cards: [ + 'https://2gis.ru/firm/1' => [new DirectoryCard(number: '8(391)292-00-00', office: 'Единая справочная', url: 'https://2gis.ru/firm/1', source: '2ГИС')], + ], + ); + $agent = new RealCompetitorAgent($fetcher, new FakeCompetitorAgent); + $res = $agent->studyCompetitor(new StudyCompetitorRequest( + competitor: ['name' => 'КрасЛомбард', 'site_url' => 'k.ru', 'directory_urls' => ['https://2gis.ru/firm/1']], + regionCode: 391, + )); + + $top = array_values(array_filter($res->sources, fn ($s) => $s['signal_type'] === 'call'))[0]; + // 292-00-00 найден в коде сайта и в 2ГИС → 2 подтверждения, 2 места «где нашли» + expect($top['confirmations'])->toBe(2); + expect($top['where_found'])->toHaveCount(2); + expect(collect($top['where_found'])->pluck('label'))->toContain('в коде сайта'); + expect(collect($top['where_found'])->pluck('label'))->toContain('2ГИС'); + expect($top['office'])->toBe('Единая справочная'); + // адрес/ссылка 2ГИС в списке кликабельны + expect(collect($top['where_found'])->pluck('url'))->toContain('https://2gis.ru/firm/1'); +}); + +it('строка-сайт тоже несёт where_found и одно подтверждение', function () { + $fetcher = new FakeFetcher( + sites: ['https://k.ru' => new FetchedSite(url: 'https://k.ru', rawHtml: 'x')], + cards: [], + ); + $agent = new RealCompetitorAgent($fetcher, new FakeCompetitorAgent); + $res = $agent->studyCompetitor(new StudyCompetitorRequest( + competitor: ['name' => 'КрасЛомбард', 'site_url' => 'k.ru', 'directory_urls' => []], + regionCode: 391, + )); + + $site = array_values(array_filter($res->sources, fn ($s) => $s['signal_type'] === 'site'))[0]; + expect($site['confirmations'])->toBe(1); + expect($site['where_found'])->toHaveCount(1); + expect($site['where_found'][0]['url'])->toBe('https://k.ru'); +}); + it('сводит филиалы из разных справочников: больше подтверждений — выше', function () { $fetcher = new FakeFetcher( sites: ['https://k.ru' => new FetchedSite(url: 'https://k.ru', rawHtml: 'x')], diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 9899a337..8067b040 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -2,7 +2,42 @@ **Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. -**Файл схемы:** `schema.sql` (текущая версия — v8.58, консолидированная — разворачивает БД с нуля). +**Файл схемы:** `schema.sql` (текущая версия — v8.59, консолидированная — разворачивает БД с нуля). + +## v8.59 (2026-06-30) — Автоподбор: богатый провенанс источника + canon-sync инкрементов 29.06 + +Шаг 2 «Конкурентного поля»: один номер часто встречается в нескольких местах — в коде +сайта и в карточках справочников (2ГИС/Яндекс) с РАЗНЫМИ адресами. Старый контракт хранил +лишь одно `provenance_url/label` — список «где нашли» терялся. Добавлены три колонки в +`autopodbor_sources`; фронт (`SourceDto.where_found/confirmations/office`) уже умеет их +показывать кликабельным списком с подтверждениями. + +Миграция: `app/database/migrations/2026_06_30_120000_add_where_found_to_autopodbor_sources.php` +(идемпотентная: `ADD COLUMN` под guard'ом `information_schema.columns`). + +**Добавлено (`autopodbor_sources`):** + +- **`where_found`** — `JSONB` (nullable): список мест `[{label,url},…]`; число подтверждений + = его длина. Кликабельные ссылки на сайт/карточки справочников. +- **`office`** — `VARCHAR(255)` (nullable): подпись филиала/адрес точки из карточки справочника. +- **`confirmations`** — `SMALLINT NOT NULL DEFAULT 1`: число подтверждений номера (сортировка + «больше подтверждений — выше»). + +Добавление безопасно: nullable / со значением по умолчанию — старые строки не переписываются. +RLS таблицы (`tenant_isolation`) — построчная, новые колонки её не меняют (rls-reviewer review). + +**Canon-sync (ранее не синканные инкременты 29.06, внесены в тот же DDL):** + +- **`phone_type`** `VARCHAR(12)` + `CHECK (… IN ('city','mobile','tollfree'))` — тип номера + (DaData). Миграция `2026_06_29_120100`. +- **`box`** `VARCHAR(16) NOT NULL DEFAULT 'proposal'` + `CHECK (… IN ('proposal','field'))` + + индекс `autopodbor_sources_competitor_box_idx (competitor_id, box)` — «два ящика» (§14.1). + Миграция `2026_06_29_120000`. + +**⏸ Известный хвост (отдельный canon-sync):** колонка `box` у `autopodbor_competitors` +(+ `CHECK` + индекс `autopodbor_competitors_tenant_box_idx`, та же миграция `2026_06_29_120000`) +в DDL `schema.sql` ещё не синкана. Точная пересверка сводных счётчиков (таблицы/индексы/RLS) +отложена — прецедент v8.54/v8.55. Воркстри-фича `avtopodbor`, на проде НЕ разворачивалась. ## v8.58 (2026-06-28) — Автоподбор конкурентов: 3 таблицы (autopodbor_runs/competitors/sources) diff --git a/db/schema.sql b/db/schema.sql index 55ba227b..67f2e2ae 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,6 +1,7 @@ -- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») --- Версия: v8.58 (28.06.2026 — Автоподбор конкурентов: +3 regular-таблицы autopodbor_runs/competitors/sources, RLS tenant_isolation в харднинг-форме (ENABLE+FORCE+NULLIF), +6 индексов (вкл. 2 UNIQUE dedup: autopodbor_competitor_dedup, autopodbor_source_dedup), +3 RLS-политики. Счётчики: таблиц 79→82 (regular 69→72) / индексов 124→130 / RLS 44→47. Миграции 2026_06_28_100000/100100/100200. Функций/триггеров/партиций без изменений. GRANTs через blanket ON ALL TABLES в db/02_grants.sql — отдельных строк не добавлено.) +-- Версия: v8.59 (30.06.2026 — Автоподбор, богатый провенанс источника + canon-sync инкрементов 29.06: autopodbor_sources +3 колонки where_found JSONB (список «где нашли» [{label,url}], подтверждения = длина) / office VARCHAR(255) (адрес филиала из карточки справочника) / confirmations SMALLINT DEFAULT 1 (сортировка «больше подтверждений — выше»), миграция 2026_06_30_120000 (идемпотентная ADD COLUMN, nullable/default — старые строки не переписываются, RLS построчная не меняется). В ТОТ ЖЕ DDL внесены ранее не синканные инкременты 29.06: phone_type VARCHAR(12)+chk (миграция 2026_06_29_120100), box VARCHAR(16) DEFAULT 'proposal'+chk+индекс autopodbor_sources_competitor_box_idx (миграция 2026_06_29_120000). Структурно для autopodbor_sources: +5 колонок (3 новых + 2 catch-up), +1 индекс, +2 CHECK. ⏸ ХВОСТ: box у autopodbor_competitors (+колонка+chk+индекс autopodbor_competitors_tenant_box_idx, та же миграция 2026_06_29_120000) в DDL ещё НЕ синкан — отдельный canon-sync; точная пересверка счётчиков таблиц/индексов/RLS отложена (прецедент v8.54/v8.55). Воркстри-фича avtopodbor, НЕ на проде. RLS/функций/триггеров/партиций без изменений.) +-- Базовая версия: v8.58 (28.06.2026 — Автоподбор конкурентов: +3 regular-таблицы autopodbor_runs/competitors/sources, RLS tenant_isolation в харднинг-форме (ENABLE+FORCE+NULLIF), +6 индексов (вкл. 2 UNIQUE dedup: autopodbor_competitor_dedup, autopodbor_source_dedup), +3 RLS-политики. Счётчики: таблиц 79→82 (regular 69→72) / индексов 124→130 / RLS 44→47. Миграции 2026_06_28_100000/100100/100200. Функций/триггеров/партиций без изменений. GRANTs через blanket ON ALL TABLES в db/02_grants.sql — отдельных строк не добавлено.) -- Базовая версия: v8.57 (26.06.2026 — RLS GUC hardening: ВСЕ 44 политики tenant_isolation приведены к NULLIF(current_setting('app.current_tenant_id', true), '')::bigint — устранён класс отказов на Managed PG/PgBouncer, когда GUC app.current_tenant_id пуст ('' → 22P02) либо не задан (→ 42704). Инцидент 26.06: вход в портал падал на резолве users (60 ошибок, все на users). 5 bootstrap-таблиц (users, auth_log, email_verifications, user_recovery_codes, user_sessions) дополнительно получили разрешающую ветку «NULLIF(...) IS NULL OR ...» — читаются/пишутся ДО tenant-контекста на auth-роутах без 'tenant' middleware; при ЗАДАННОМ tenant изоляция НЕ меняется (rls-reviewer APPROVE). Структурно БД НЕ меняется — переписаны только USING/WITH CHECK (счётчики таблиц/индексов/RLS=44/функций/триггеров без изменений). Миграция 2026_06_26_153000_rls_nullif_guc_hardening (идемпотентна). Применена на боевой кластер; lead_charges FORCE RLS сохранён.) -- Базовая версия: v8.56 (26.06.2026 — Путь А, шов C: тело функции audit_block_mutation() пропускает пересчёт hash-цепочки по метке GUC app.audit_rebuild='on' (+ superuser ИЛИ член crm_migrator) ВМЕСТО session_replication_role (superuser-only, недоступен в Yandex Managed PG). Append-only сохранён: без метки UPDATE/DELETE аудита запрещён. Структурно БД НЕ меняется — только тело функции (счётчики таблиц/индексов/RLS/функций/триггеров без изменений, функций по-прежнему 5). Миграция 2026_06_26_140000_audit_block_mutation_guc_rebuild_flag. AuditRebuildChain команда переведена на SET LOCAL app.audit_rebuild в транзакции (Odyssey-safe).) -- Базовая версия: v8.55 (25.06.2026 — Эпик 5 отчёт заливки: +1 таблица supplier_sync_runs (сводка по вечерней заливке проектов поставщику — групп/синк/ручная/отложено/упало + status; SaaS-level без RLS/tenant_id как supplier_csv_reconcile_log, пишет crm_supplier_worker BYPASSRLS, читает SaaS-admin) + 1 явный индекс idx_supplier_sync_runs_created. Миграция 2026_06_25_130000. Структурно +1 regular-таблица + 1 индекс. NB: сводные счётчики несут известный дрейф рантайм-счётчика (ср. сверка 23.06) — точная пересверка отдельным canon-sync. RLS/функций/триггеров без изменений.) @@ -1421,7 +1422,8 @@ CREATE POLICY tenant_isolation ON autopodbor_competitors -- (study_run_id CASCADE). created_project_id: если по источнику создан проект — -- ссылка на него (SET NULL при удалении проекта). -- Дедупликация по dedup_key внутри конкурента. --- Миграция 2026_06_28_100200. +-- Миграции: 2026_06_28_100200 (база), 2026_06_29_120100 (phone_type), +-- 2026_06_29_120000 (box), 2026_06_30_120000 (where_found/office/confirmations). -- ----------------------------------------------------------------------------- CREATE TABLE autopodbor_sources ( id BIGSERIAL PRIMARY KEY, @@ -1431,14 +1433,22 @@ CREATE TABLE autopodbor_sources ( signal_type VARCHAR(8) NOT NULL, -- site | call identifier VARCHAR(255) NOT NULL, -- голова домена / 7xxxxxxxxxx phone_kind VARCHAR(12), -- real | substitute | NULL (для site) + phone_type VARCHAR(12), -- city | mobile | tollfree | NULL (тип номера, DaData) provenance_url VARCHAR(500), provenance_label VARCHAR(255), + where_found JSONB, -- список мест «где нашли» [{label,url},...]; подтверждения = длина + office VARCHAR(255), -- подпись филиала/адрес точки (из карточки справочника) + confirmations SMALLINT NOT NULL DEFAULT 1, -- число подтверждений номера (сортировка «больше — выше») dedup_key VARCHAR(255) NOT NULL, created_project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + box VARCHAR(16) NOT NULL DEFAULT 'proposal', -- proposal | field («два ящика», §14.1) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT autopodbor_sources_phone_type_chk CHECK (phone_type IS NULL OR phone_type IN ('city', 'mobile', 'tollfree')), + CONSTRAINT autopodbor_sources_box_chk CHECK (box IN ('proposal', 'field')) ); CREATE UNIQUE INDEX autopodbor_source_dedup ON autopodbor_sources (competitor_id, dedup_key); CREATE INDEX ON autopodbor_sources (tenant_id, competitor_id); +CREATE INDEX autopodbor_sources_competitor_box_idx ON autopodbor_sources (competitor_id, box); ALTER TABLE autopodbor_sources ENABLE ROW LEVEL SECURITY; ALTER TABLE autopodbor_sources FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON autopodbor_sources From 2df6cd9a396bda9df184cfb122eaca55433b89b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 15:51:31 +0300 Subject: [PATCH 071/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B2=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D1=91=D0=BD=20=D0=BD=D0=B0=D1=81=D1=82=D0=BE=D1=8F=D1=89?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=B4=D0=B2=D0=B8=D0=B6=D0=BE=D0=BA=20=D1=88?= =?UTF-8?q?=D0=B0=D0=B3=D0=B0=202=20+=20=D1=80=D0=B5=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=B9=20=D1=84=D0=BB=D0=B0=D0=BA=D0=B8=202=D0=93=D0=98=D0=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Провайдер биндит RealCompetitorAgent вместо заглушки: сайт конкурента — обычный curl плюс локальный Playwright, справочники 2ГИС/Яндекс — через антибот xfetch. Поиск/резолв шага 1 ещё не реальны — делегируются заглушке через fallback, поведение поиска не изменилось. - AutopodborServiceProvider биндит RealCompetitorAgent с CompositeFetcher - XfetchClient повторяет запрос при пустом флаки-рендере 2ГИС, до 3 раз - тесты привязки контейнера плюс ретрая; study-тест пинит заглушку явно - тесты заглушки строят FakeCompetitorAgent напрямую - бэкенд автоподбора 126/126 НЕ на проде, воркстри avtopodbor. Co-Authored-By: Claude Opus 4.8 --- .../Providers/AutopodborServiceProvider.php | 25 +++++++++++++++++-- .../Autopodbor/Agent/Fetch/XfetchClient.php | 15 +++++++++++ .../Autopodbor/AutopodborAgentBindingTest.php | 25 +++++++++++++++++++ .../Autopodbor/RunAutopodborStudyJobTest.php | 4 +++ .../Autopodbor/FakeCompetitorAgentTest.php | 24 ++++++++++-------- .../Autopodbor/Fetch/XfetchClientTest.php | 16 ++++++++++++ 6 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 app/tests/Feature/Autopodbor/AutopodborAgentBindingTest.php diff --git a/app/app/Providers/AutopodborServiceProvider.php b/app/app/Providers/AutopodborServiceProvider.php index e8a347b1..6c3f89df 100644 --- a/app/app/Providers/AutopodborServiceProvider.php +++ b/app/app/Providers/AutopodborServiceProvider.php @@ -4,13 +4,34 @@ namespace App\Providers; use App\Services\Autopodbor\Agent\CompetitorAgent; use App\Services\Autopodbor\Agent\FakeCompetitorAgent; +use App\Services\Autopodbor\Agent\Fetch\CompositeFetcher; +use App\Services\Autopodbor\Agent\Fetch\CurlPlaywrightFetcher; +use App\Services\Autopodbor\Agent\Fetch\XfetchClient; +use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher; +use App\Services\Autopodbor\Agent\RealCompetitorAgent; use Illuminate\Support\ServiceProvider; class AutopodborServiceProvider extends ServiceProvider { public function register(): void { - // v1: заглушка. Реальный движок биндится здесь, когда будет готов. - $this->app->bind(CompetitorAgent::class, FakeCompetitorAgent::class); + // Шаг 2 (изучение конкурента) — настоящий движок: сайт конкурента берём обычным + // curl + локальный Playwright (бесплатно), справочники 2ГИС/Яндекс — через антибот + // xfetch.ru. Поиск/резолв (шаг 1) ещё не реальны — RealCompetitorAgent делегирует + // их заглушке FakeCompetitorAgent (fallback). Когда шаг 1 будет готов — заменить + // fallback на настоящую реализацию find/resolve. + $this->app->bind(CompetitorAgent::class, function ($app): CompetitorAgent { + $xfetch = new XfetchClient( + apiKey: config('services.xfetch.key'), + endpoint: config('services.xfetch.endpoint', 'https://xf4.ru/fetch'), + ); + + $fetcher = new CompositeFetcher( + siteFetcher: new CurlPlaywrightFetcher, + directoryFetcher: new XfetchDirectoryFetcher($xfetch), + ); + + return new RealCompetitorAgent($fetcher, new FakeCompetitorAgent); + }); } } diff --git a/app/app/Services/Autopodbor/Agent/Fetch/XfetchClient.php b/app/app/Services/Autopodbor/Agent/Fetch/XfetchClient.php index aa093c4f..e8730445 100644 --- a/app/app/Services/Autopodbor/Agent/Fetch/XfetchClient.php +++ b/app/app/Services/Autopodbor/Agent/Fetch/XfetchClient.php @@ -19,6 +19,7 @@ final class XfetchClient implements PageFetcher private bool $render = true, private int $renderTimeout = 20, private int $httpTimeout = 120, + private int $retries = 3, ) {} public function html(string $url): string @@ -27,6 +28,20 @@ final class XfetchClient implements PageFetcher return ''; } + // Рендер 2ГИС/Яндекс флакует: иногда возвращается пустой/ошибочный ответ. + // Повторяем до $retries раз — берём первый непустой результат. + for ($attempt = 1; $attempt <= max(1, $this->retries); $attempt++) { + $html = $this->fetchOnce($url); + if ($html !== '') { + return $html; + } + } + + return ''; + } + + private function fetchOnce(string $url): string + { $resp = Http::timeout($this->httpTimeout)->asJson()->post($this->endpoint, [ 'url' => $url, 'api_key' => $this->apiKey, diff --git a/app/tests/Feature/Autopodbor/AutopodborAgentBindingTest.php b/app/tests/Feature/Autopodbor/AutopodborAgentBindingTest.php new file mode 100644 index 00000000..01a3afc9 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborAgentBindingTest.php @@ -0,0 +1,25 @@ +toBeInstanceOf(RealCompetitorAgent::class); +}); + +it('поиск/резолв у настоящего агента пока делегируются заглушке', function () { + // find/resolve ещё не реальны → RealCompetitorAgent отдаёт их в fallback (Fake), + // поэтому демо-конкуренты по-прежнему приходят (поведение поиска не изменилось). + $agent = app(CompetitorAgent::class); + $res = $agent->findCompetitors(new FindCompetitorsRequest( + regionCode: 24, + examples: ['окна'], + aboutSelf: [], + includeFederal: false, + maxCompetitors: 5, + )); + expect($res->competitors)->not->toBeEmpty(); +}); diff --git a/app/tests/Feature/Autopodbor/RunAutopodborStudyJobTest.php b/app/tests/Feature/Autopodbor/RunAutopodborStudyJobTest.php index 786d2006..b4414f07 100644 --- a/app/tests/Feature/Autopodbor/RunAutopodborStudyJobTest.php +++ b/app/tests/Feature/Autopodbor/RunAutopodborStudyJobTest.php @@ -9,6 +9,7 @@ use App\Models\AutopodborSource; use App\Models\SystemSetting; use App\Models\Tenant; use App\Services\Autopodbor\Agent\CompetitorAgent; +use App\Services\Autopodbor\Agent\FakeCompetitorAgent; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; use Tests\Concerns\SharesSupplierPdo; @@ -17,6 +18,9 @@ use Tests\Doubles\EmptyCompetitorAgent; uses(DatabaseTransactions::class, SharesSupplierPdo::class); it('успешное изучение: источники + конкурент изучен + списание', function () { + // Дефолтный агент теперь настоящий (ходит в сеть) — для детерминированного + // изучения пиним заглушку с захардкоженными демо-источниками. + app()->bind(CompetitorAgent::class, FakeCompetitorAgent::class); $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); DB::statement('SET app.current_tenant_id = '.$tenant->id); SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '900', 'type' => 'decimal']); diff --git a/app/tests/Unit/Autopodbor/FakeCompetitorAgentTest.php b/app/tests/Unit/Autopodbor/FakeCompetitorAgentTest.php index 5b9b98b4..aa82ca1e 100644 --- a/app/tests/Unit/Autopodbor/FakeCompetitorAgentTest.php +++ b/app/tests/Unit/Autopodbor/FakeCompetitorAgentTest.php @@ -1,27 +1,31 @@ toBeInstanceOf(\App\Services\Autopodbor\Agent\FakeCompetitorAgent::class); +// Контейнер с этого этапа отдаёт настоящий движок (RealCompetitorAgent) — см. +// AutopodborAgentBindingTest. Здесь проверяем поведение САМОЙ заглушки: строим её напрямую. + +it('заглушка отдаёт демо-данные для поиска/изучения/резолва', function () { + $agent = new FakeCompetitorAgent; $found = $agent->findCompetitors(new FindCompetitorsRequest(16, ['okna.ru'], [], true, 15)); expect(count($found->competitors))->toBeGreaterThan(0) - ->and($found->competitors[0])->toHaveKeys(['name','relevance_pct','site_url']); + ->and($found->competitors[0])->toHaveKeys(['name', 'relevance_pct', 'site_url']); - $study = $agent->studyCompetitor(new StudyCompetitorRequest(['name'=>'Окна Комфорт','site_url'=>'okna-komfort-kzn.ru'], 16)); + $study = $agent->studyCompetitor(new StudyCompetitorRequest(['name' => 'Окна Комфорт', 'site_url' => 'okna-komfort-kzn.ru'], 16)); expect($study->sources)->not->toBeEmpty() - ->and($study->sources[0])->toHaveKeys(['signal_type','identifier','provenance_url']); + ->and($study->sources[0])->toHaveKeys(['signal_type', 'identifier', 'provenance_url']); $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)); + $agent = new FakeCompetitorAgent; + $study = $agent->studyCompetitor(new StudyCompetitorRequest(['name' => 'Окна Комфорт', 'site_url' => 'okna-komfort-kzn.ru'], 16)); $phoneTypes = collect($study->sources) ->where('signal_type', 'call') diff --git a/app/tests/Unit/Autopodbor/Fetch/XfetchClientTest.php b/app/tests/Unit/Autopodbor/Fetch/XfetchClientTest.php index 37b37fee..01d2d0c6 100644 --- a/app/tests/Unit/Autopodbor/Fetch/XfetchClientTest.php +++ b/app/tests/Unit/Autopodbor/Fetch/XfetchClientTest.php @@ -21,6 +21,22 @@ it('без ключа не ходит в сеть и возвращает пус Http::assertNothingSent(); }); +it('повторяет запрос при флаки-пустом рендере 2ГИС и берёт удачный', function () { + Http::fake(['xf4.ru/*' => Http::sequence() + ->push(['response_body_base64' => ''], 200) // 1-й рендер пустой (флак) + ->push(['response_body_base64' => base64_encode('x')], 200), // 2-й удачный + ]); + $client = new XfetchClient('k', retries: 3); + expect($client->html('https://2gis.ru/x'))->toContain('73912920000'); +}); + +it('после исчерпания ретраев возвращает пусто', function () { + Http::fake(['xf4.ru/*' => Http::response(['response_body_base64' => ''], 200)]); + $client = new XfetchClient('k', retries: 2); + expect($client->html('https://2gis.ru/x'))->toBe(''); + Http::assertSentCount(2); +}); + it('шлёт url, api_key и render:true на endpoint xfetch', function () { Http::fake(['xf4.ru/*' => Http::response(['response_body_base64' => base64_encode('ok')])]); (new XfetchClient('k123'))->html('https://2gis.ru/y'); From 7cbc712dc49e37b17ca09877d4ba11cbbb339e20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 16:10:00 +0300 Subject: [PATCH 072/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B7=D0=B0=D0=BF=D0=B0?= =?UTF-8?q?=D1=81=D0=BD=D0=B0=D1=8F=20=D0=B4=D0=BE=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B0=20=D0=BA=D0=BE=D1=80=D0=BE=D1=82=D0=BA=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BA=D0=BE=D0=B4=D1=83=20=D0=B3=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=20=D1=80=D0=B5=D0=B3=D0=B8=D0=BE=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Если на сайте конкурента короткий локальный номер без кода города И на странице нет ни одного полного номера, движок берёт код по региону конкурента. Основной путь прежний — код со страницы; региональный код лишь запасной. - RegionAreaCode: субъект РФ → телефонный код адм. центра, только уверенные 3-значные коды миллионников, ключ по имени из RussianRegions, неизвестный → null - RealCompetitorAgent резолвит regionCode и пробрасывает в CandidateBuilder→HtmlPhoneScanner - ограничение задокументировано: код центра, не выдумываем при отсутствии в карте - тесты карты и сквозной достройки; бэкенд автоподбора 130/130 НЕ на проде, воркстри avtopodbor. Co-Authored-By: Claude Opus 4.8 --- .../Agent/Extract/CandidateBuilder.php | 6 +- .../Autopodbor/Agent/RealCompetitorAgent.php | 7 ++- app/app/Support/RegionAreaCode.php | 59 +++++++++++++++++++ .../Agent/RealCompetitorAgentTest.php | 18 ++++++ .../Unit/Autopodbor/RegionAreaCodeTest.php | 31 ++++++++++ 5 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 app/app/Support/RegionAreaCode.php create mode 100644 app/tests/Unit/Autopodbor/RegionAreaCodeTest.php diff --git a/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php b/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php index ff76b998..7a22b02b 100644 --- a/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php +++ b/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php @@ -19,13 +19,15 @@ final class CandidateBuilder /** * @param list $sites * @param list $cards + * @param ?string $defaultAreaCode запасной код города (по региону конкурента) — + * для достройки коротких номеров, если на странице нет полных * @return list */ - public function build(array $sites, array $cards): array + public function build(array $sites, array $cards, ?string $defaultAreaCode = null): array { $out = []; foreach ($sites as $site) { - $scan = $this->scanner->scan($site->rawHtml); + $scan = $this->scanner->scan($site->rawHtml, $defaultAreaCode); $hasTracker = $this->detector->detect($site->rawHtml) !== []; foreach (array_keys($scan['code']) as $number) { diff --git a/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php b/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php index 776ff80c..fcfffd7d 100644 --- a/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php +++ b/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php @@ -15,6 +15,7 @@ use App\Services\Autopodbor\Agent\Extract\CandidateBuilder; use App\Services\Autopodbor\Agent\Extract\SourceAggregator; use App\Services\Autopodbor\Agent\Fetch\Fetcher; use App\Services\Autopodbor\AutopodborNormalizer; +use App\Support\RegionAreaCode; final class RealCompetitorAgent implements CompetitorAgent { @@ -54,8 +55,10 @@ final class RealCompetitorAgent implements CompetitorAgent } } - // 2. Размечаем кандидатов и сводим ядром - $candidates = $this->builder->build($sites, $cards); + // 2. Размечаем кандидатов и сводим ядром. Запасной код города — по региону + // конкурента (только если на странице нет полных номеров; см. RegionAreaCode). + $areaCode = RegionAreaCode::forSubject($r->regionCode); + $candidates = $this->builder->build($sites, $cards, $areaCode); $collected = $this->aggregator->aggregate($candidates); // 3. Маппинг в существующий контракт diff --git a/app/app/Support/RegionAreaCode.php b/app/app/Support/RegionAreaCode.php new file mode 100644 index 00000000..456a76b8 --- /dev/null +++ b/app/app/Support/RegionAreaCode.php @@ -0,0 +1,59 @@ + каноничное имя субъекта => 3-значный код адм. центра */ + private const AREA_BY_NAME = [ + 'Москва' => '495', + 'Санкт-Петербург' => '812', + 'Красноярский край' => '391', // Красноярск + 'Новосибирская область' => '383', // Новосибирск + 'Свердловская область' => '343', // Екатеринбург + 'Республика Татарстан' => '843', // Казань + 'Нижегородская область' => '831', // Нижний Новгород + 'Самарская область' => '846', // Самара + 'Ростовская область' => '863', // Ростов-на-Дону + 'Воронежская область' => '473', // Воронеж + 'Пермский край' => '342', // Пермь + 'Приморский край' => '423', // Владивосток + 'Республика Башкортостан' => '347', // Уфа + 'Челябинская область' => '351', // Челябинск + 'Краснодарский край' => '861', // Краснодар + ]; + + /** 3-значный телефонный код центра субъекта или null, если уверенного кода нет. */ + public static function forSubject(int $subjectCode): ?string + { + $name = RussianRegions::CODE_TO_NAME[$subjectCode] ?? null; + if ($name === null) { + return null; + } + + return self::AREA_BY_NAME[$name] ?? null; + } +} diff --git a/app/tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php b/app/tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php index d75c5f97..ef8ef6a8 100644 --- a/app/tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php +++ b/app/tests/Unit/Autopodbor/Agent/RealCompetitorAgentTest.php @@ -5,6 +5,7 @@ use App\Services\Autopodbor\Agent\FakeCompetitorAgent; use App\Services\Autopodbor\Agent\Fetch\DirectoryCard; use App\Services\Autopodbor\Agent\Fetch\FetchedSite; use App\Services\Autopodbor\Agent\RealCompetitorAgent; +use App\Support\RussianRegions; use Tests\Unit\Autopodbor\Agent\FakeFetcher; it('studyCompetitor: настоящий номер из кода+2ГИС наверху, подменный внизу, сайт отдельной строкой', function () { @@ -64,6 +65,23 @@ it('телефонная строка несёт список «где нашл expect(collect($top['where_found'])->pluck('url'))->toContain('https://2gis.ru/firm/1'); }); +it('по региону достраивает короткий номер, если на странице нет полных', function () { + // На странице только короткий локальный номер в tel:, полных номеров нет — + // код города берём по региону конкурента (Красноярский край → 391). + $fetcher = new FakeFetcher( + sites: ['https://k.ru' => new FetchedSite(url: 'https://k.ru', rawHtml: 'Позвонить')], + cards: [], + ); + $agent = new RealCompetitorAgent($fetcher, new FakeCompetitorAgent); + $res = $agent->studyCompetitor(new StudyCompetitorRequest( + competitor: ['name' => 'КрасЛомбард', 'site_url' => 'k.ru', 'directory_urls' => []], + regionCode: RussianRegions::nameToCode()['Красноярский край'], + )); + + $calls = array_values(array_filter($res->sources, fn ($s) => $s['signal_type'] === 'call')); + expect(collect($calls)->pluck('identifier'))->toContain('73912920000'); +}); + it('строка-сайт тоже несёт where_found и одно подтверждение', function () { $fetcher = new FakeFetcher( sites: ['https://k.ru' => new FetchedSite(url: 'https://k.ru', rawHtml: 'x')], diff --git a/app/tests/Unit/Autopodbor/RegionAreaCodeTest.php b/app/tests/Unit/Autopodbor/RegionAreaCodeTest.php new file mode 100644 index 00000000..abb184fc --- /dev/null +++ b/app/tests/Unit/Autopodbor/RegionAreaCodeTest.php @@ -0,0 +1,31 @@ +toBe('391'); + expect(RegionAreaCode::forSubject(subjectCode('Новосибирская область')))->toBe('383'); + expect(RegionAreaCode::forSubject(subjectCode('Свердловская область')))->toBe('343'); + expect(RegionAreaCode::forSubject(subjectCode('Республика Татарстан')))->toBe('843'); + expect(RegionAreaCode::forSubject(subjectCode('Москва')))->toBe('495'); + expect(RegionAreaCode::forSubject(subjectCode('Санкт-Петербург')))->toBe('812'); +}); + +it('возвращает null для региона без уверенного кода (не выдумываем)', function () { + // Еврейская АО в карту намеренно не внесена — код столицы длинный/неоднозначный. + expect(RegionAreaCode::forSubject(subjectCode('Еврейская автономная область')))->toBeNull(); +}); + +it('возвращает null для неизвестного/нулевого кода', function () { + expect(RegionAreaCode::forSubject(0))->toBeNull(); + expect(RegionAreaCode::forSubject(999))->toBeNull(); +}); From 7a5996a9f2bd48fd2a4631805d258de850d4124d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 16:27:43 +0300 Subject: [PATCH 073/417] =?UTF-8?q?fix(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=80=D0=B5=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=B9=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D0=B0=D0=BB=D0=BE=D0=B2=202=D0=93=D0=98=D0=A1=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D1=84=D0=BB=D0=B0=D0=BA=D0=B8-=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D0=B5=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Живой прогон вскрыл: рендер списка 2ГИС иногда отдаёт непустую «оболочку» без ссылок на филиалы — ретрай в XfetchClient ловит только пустой ответ, а тут карточки терялись. XfetchDirectoryFetcher теперь повторяет загрузку списка, пока не появятся ссылки на филиалы, до 3 раз. Проверено вживую на kraslombard24.ru: центральный 8(391)292-00-00 подтверждён И кодом сайта, И 2ГИС с адресом «улица Ладо Кецховели, 30»; where_found/office/confirmations доходят до БД и API. 0 фальшивок. - тест повтора списка при флаке; бэкенд автоподбора 131/131 НЕ на проде, воркстри avtopodbor. Co-Authored-By: Claude Opus 4.8 --- .../Agent/Fetch/XfetchDirectoryFetcher.php | 13 ++++++++-- .../Fetch/XfetchDirectoryFetcherTest.php | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/app/app/Services/Autopodbor/Agent/Fetch/XfetchDirectoryFetcher.php b/app/app/Services/Autopodbor/Agent/Fetch/XfetchDirectoryFetcher.php index 46a567b4..f5376388 100644 --- a/app/app/Services/Autopodbor/Agent/Fetch/XfetchDirectoryFetcher.php +++ b/app/app/Services/Autopodbor/Agent/Fetch/XfetchDirectoryFetcher.php @@ -17,6 +17,7 @@ final class XfetchDirectoryFetcher implements Fetcher private PageFetcher $pages, private DirectoryParser $parser = new DirectoryParser, private int $maxBranches = 25, + private int $listRetries = 3, ) {} public function site(string $url): FetchedSite @@ -26,8 +27,16 @@ final class XfetchDirectoryFetcher implements Fetcher public function directory(string $url): array { - $listHtml = $this->pages->html($url); - $links = $this->parser->parseBranchList($listHtml); + // Рендер списка филиалов 2ГИС флакует: иногда отдаётся «оболочка» БЕЗ ссылок на + // карточки. Повторяем загрузку списка, пока ссылки не появятся (до $listRetries раз). + $links = []; + for ($attempt = 1; $attempt <= max(1, $this->listRetries); $attempt++) { + $listHtml = $this->pages->html($url); + $links = $this->parser->parseBranchList($listHtml); + if ($links !== []) { + break; + } + } if ($links === []) { return []; } diff --git a/app/tests/Unit/Autopodbor/Fetch/XfetchDirectoryFetcherTest.php b/app/tests/Unit/Autopodbor/Fetch/XfetchDirectoryFetcherTest.php index a755abeb..608dd29e 100644 --- a/app/tests/Unit/Autopodbor/Fetch/XfetchDirectoryFetcherTest.php +++ b/app/tests/Unit/Autopodbor/Fetch/XfetchDirectoryFetcherTest.php @@ -34,6 +34,32 @@ it('обходит список 2ГИС и собирает карточки ф expect($byNum['+73912920000']->office)->toBe('проспект Мира, 10'); }); +it('повторяет загрузку списка 2ГИС при флаки-рендере без ссылок на филиалы', function () { + // 1-й рендер списка — «ошибочная оболочка» без ссылок (флак); 2-й — нормальный список. + $seq = new class implements PageFetcher + { + public int $listCalls = 0; + + public function html(string $url): string + { + if (str_contains($url, '/branches/') || str_contains($url, '/search/')) { + $this->listCalls++; + + return $this->listCalls === 1 + ? '
Подождите, идёт загрузка…
' // флак: ссылок на филиалы нет + : 'ф1'; // повтор удачен + } + + return 'КрасЛомбард, улица Весны, 7, Красноярск — 2ГИСx'; + } + }; + + $cards = (new XfetchDirectoryFetcher($seq))->directory('https://2gis.ru/krasnoyarsk/branches/985'); + + expect($cards)->toHaveCount(1); + expect($seq->listCalls)->toBe(2); // список перезагрузили после флака +}); + it('site() тянет HTML конкурента через тот же загрузчик', function () { $fake = fakePages(['https://k.ru' => 'x']); $site = (new XfetchDirectoryFetcher($fake))->site('https://k.ru'); From 82f40ca98646449f31265acc061edf4d4ebdec71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 16:29:09 +0300 Subject: [PATCH 074/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=20=E2=80=94=20=D1=88=D0=B0=D0=B3=202=20=D0=B2?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BD=20=D0=B2=20=D0=B1=D0=BE?= =?UTF-8?q?=D0=B5=D0=B2=D0=BE=D0=B9=20=D0=BF=D1=83=D1=82=D1=8C=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=20=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D1=8B=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обновлён GOTOVNOST: интеграция шага 2 (провод реального агента, где-нашли→БД→API, код города по региону, ретрай 2ГИС) сделана и проверена вживую на kraslombard24.ru. Следующий блок — шаг 1 (реальный findCompetitors). Co-Authored-By: Claude Opus 4.8 --- .../findings/2026-06-30-GOTOVNOST-status.md | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/docs/superpowers/findings/2026-06-30-GOTOVNOST-status.md b/docs/superpowers/findings/2026-06-30-GOTOVNOST-status.md index daf7a7cc..14be9ffd 100644 --- a/docs/superpowers/findings/2026-06-30-GOTOVNOST-status.md +++ b/docs/superpowers/findings/2026-06-30-GOTOVNOST-status.md @@ -5,16 +5,18 @@ --- -## КОРОТКИЙ ВЕРДИКТ +## КОРОТКИЙ ВЕРДИКТ (обновлён 30.06 вечер) -- **К внедрению на боевой — НЕ готов.** +- **Шаг 2 (сбор источников по конкуренту) — СОБРАН И ВСТРОЕН В БОЕВОЙ ПУТЬ, проверен живым прогоном.** ✅ + `AutopodborServiceProvider` байндит настоящий `RealCompetitorAgent`; «где нашли»/офис/подтверждения + доходят до БД и API; код города по региону подключён; 2ГИС с ретраем. Тесты 131/131 + 13/13. + Осталось до выката: приёмка/безопасность + прод-деплой за тумблером. - **Шаг 1 (поиск конкурента и его площадок) — реальная логика НИКОГДА не писалась (всегда заглушка); обвязка готова. Из репозитория ничего не терялось** (проверено по git — см. раздел ниже). -- **Шаг 2 (сбор источников по конкуренту) — собран ПО ЧАСТЯМ и проверен в изоляции, но в портал НЕ встроен.** + **Это главный следующий блок.** -В приложении и для поиска (шаг 1), и для изучения (шаг 2) сейчас работает **заглушка `FakeCompetitorAgent`** -(`AutopodborServiceProvider` байндит её). Настоящий движок шага 2 (`RealCompetitorAgent`) написан и -протестирован, но **одной строкой провайдера ещё не подключён**. +Для ИЗУЧЕНИЯ (шаг 2) теперь работает настоящий движок. Для ПОИСКА (шаг 1) по-прежнему заглушка +`FakeCompetitorAgent` (`RealCompetitorAgent` делегирует find/resolve ей через fallback). --- @@ -57,22 +59,31 @@ - **Доказано на живом КрасЛомбаре** (через изолированные харнессы, НЕ через боевой API): 29 настоящих номеров с сайта + 11 филиалов 2ГИС с адресами. -### Что НЕ сделано — нужно для внедрения 🔴 -1. **Провод реального агента**: заменить `FakeCompetitorAgent` → `RealCompetitorAgent` в провайдере; - собрать `RealCompetitorAgent` с `CompositeFetcher(curl-сайт + xfetch-справочники)`. -2. **Ключ xfetch в боевом окружении** (`XFETCH_API_KEY` в `.env` прода) + ретрай флаки 2ГИС в - `XfetchClient`/`XfetchDirectoryFetcher` (сейчас ретрай был только в харнессе). -3. **Бэкенд-провод «где нашли»**: список мест + подтверждения + адрес довести через - `StudyCompetitorResult` → таблицу `autopodbor_sources` (миграция) → API `FieldSourceDto`. - Сейчас движок собирает богатый список, но контракт схлопывает его в один провенанс. -4. **Проброс кода города (region) из запроса** в `HtmlPhoneScanner` через `CandidateBuilder` - (сейчас работает только «код со страницы»; запасной по региону — не подключён к боевому потоку). -5. **Сквозной прогон через настоящий портал** (кнопка «Изучить» → API → БД → экран), а не харнесс; - проверка денег/идемпотентности/RLS на реальном пути. -6. **Яндекс.Карты** — отдельный парсер под реальный DOM (формат карточек ≠ 2ГИС). -7. **Поиск других площадок конкурента** на шаге 2 (поле «сайт» в карточках 2ГИС + общий веб-поиск +### ✅ СДЕЛАНО 30.06 (вечер) — интеграция шага 2 в боевой путь (коммиты a42647c6…bfda9f8d) +1. ✅ **Провод реального агента**: `AutopodborServiceProvider` биндит `RealCompetitorAgent` с + `CompositeFetcher(CurlPlaywrightFetcher-сайт + XfetchDirectoryFetcher-справочники)`; find/resolve + делегированы заглушке (fallback). Тест привязки контейнера. +2. ✅ **Ретрай флаки 2ГИС**: `XfetchClient` повторяет при пустом ответе (до 3×); `XfetchDirectoryFetcher` + повторяет загрузку СПИСКА филиалов, пока не появятся ссылки (до 3×) — вскрыто живым прогоном + (непустая «оболочка» без карточек). Ключ `XFETCH_API_KEY` — в `app/.env` (на dev живой, баланс есть). +3. ✅ **Бэкенд-провод «где нашли»**: `CollectedSource`→`StudyCompetitorResult`(where_found/office/ + confirmations)→джоб→колонки `autopodbor_sources` (миграция `2026_06_30_120000`, schema v8.59, + RLS-review APPROVE)→`SourceResource`→фронт. Список мест + адрес + подтверждения доходят целиком. +4. ✅ **Проброс кода города по региону**: `RegionAreaCode::forSubject(subjectCode)` (только уверенные + 3-значные коды столиц, ключ по имени из `RussianRegions`, неизвестный→null) → `RealCompetitorAgent` + → `CandidateBuilder` → `HtmlPhoneScanner`. Срабатывает лишь когда на странице нет полных номеров. +5. ✅ **Сквозной живой прогон** настоящим джобом + контейнерным агентом на `kraslombard24.ru`: + статус `done`, 30 номеров сайта + 2ГИС-кросс-подтверждение центрального `8(391)292-00-00` с адресом + «улица Ладо Кецховели, 30», регион 29→391, where_found/office/confirmations в БД и API, **0 фальшивок**. + Бэкенд автоподбора **131/131**, фронт FieldCompetitor **13/13**. + +### Что ещё НЕ сделано 🔴 +6. **Шаг 1** (реальный `findCompetitors`) — всё ещё заглушка (см. раздел «ШАГ 1»). Главный следующий блок. +7. **Яндекс.Карты** — отдельный парсер под реальный DOM (формат карточек ≠ 2ГИС). +8. **Поиск других площадок конкурента** на шаге 2 (поле «сайт» в карточках 2ГИС + общий веб-поиск через firecrawl) — НЕ начато. -8. **SSRF-хвост** резервного Playwright-пути (`§6.4` сводки) — если этот путь оживёт. +9. **SSRF-хвост** резервного Playwright-пути (`§6.4` сводки) — если этот путь оживёт. +10. **Приёмка/безопасность шага 2** на боевом стенде + выкат за тумблером (прод-деплой ещё НЕ делался). --- From 829506a0c047892320170f66fca04bc3870c3d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 16:35:48 +0300 Subject: [PATCH 075/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=88=D0=B0=D0=B3=D0=B0=201=20=E2=80=94=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20findCompetitors,=20=D1=84?= =?UTF-8?q?=D1=83=D0=BD=D0=B4=D0=B0=D0=BC=D0=B5=D0=BD=D1=82-=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D1=8B=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Декомпозиция движка v4 (§12) на под-блоки A–H: резолвер (фундамент, офлайн-TDD) → слияние/похожесть → каналы А/В/0 → DaData → оркестрация+флип за флагом. Живые блоки с платными API — отдельными заходами после «го» владельца. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-30-step1-real-findcompetitors.md | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md diff --git a/docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md b/docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md new file mode 100644 index 00000000..a8d61e8c --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md @@ -0,0 +1,113 @@ +# Шаг 1 «Поиск конкурентов» (реальный findCompetitors) — план реализации + +> **Для исполнителя:** ОБЯЗАТЕЛЬНЫЙ СУБ-СКИЛЛ — superpowers:subagent-driven-development или +> superpowers:executing-plans, задача за задачей. Шаги — чекбоксы `- [ ]`. + +**Цель:** заменить заглушку `FakeCompetitorAgent.findCompetitors` настоящим движком поиска конкурентов +по дизайну брейншторма §12 (ФИНАЛЬНЫЙ ДВИЖОК v4), собирая его в PHP внутри `worktree-avtopodbor`. + +**Архитектура:** 3 канала добычи имён (0 «портал поставщика» / А «справочники 2ГИС+Яндекс» / В +«федералы через LLM») → **ЕДИНЫЙ РЕЗОЛВЕР** (имя → настоящая карточка) → DaData-телефоны → слияние+ +дедуп → эмбеддинги-похожесть → DTO `findCompetitors` (§7.2). Транспорт (HTTP/рендер) — за тонкой +границей `PageFetcher`/HTTP-клиент (как `Fetcher` в шаге 2), чтобы вся логика тестировалась офлайн на +фикстурах. Провайдер флипается на реальный движок ПОСЛЕДНИМ, за фиче-флагом. + +**Стек:** PHP 8.3 / Laravel 13 / Pest. Внешние сервисы (живые блоки, позже): 2ГИС-рендер (xfetch — +уже работает в шаге 2 / firecrawl как запас), Яндекс (локальный Playwright), DaData (clean/phone), +AITUNNEL (эмбеддинги text-embedding-3-small), портал поставщика crm.bp-gr.ru (Playwright-логин). + +> ⚠️ **Решения владельца, уже закрытые (§12, не пересматривать без «меняем»):** ИНН выкинут полностью +> (карточка = имя/сайт(ы)/телефоны/2ГИС/Яндекс); похожесть = эмбеддинги, НЕ оценка модели; агрегаторы +> отсекаются LLM-классификатором, не статиком; `is_federal` ПО ФАКТУ (нет местной карточки + есть сайт); +> система универсальна (всё клиентское — из конфига, в коде не зашито); телефоны не синтезируем +> (городской↔мобильный не конвертируется) — берём только опубликованные фирмой. + +--- + +## Декомпозиция на под-блоки (каждый — отдельная сборка, свой PR-чекпойнт) + +| # | Под-блок | Внешние API | Тестируемо офлайн? | +|---|---|---|---| +| **A** | **Резолвер** — имя(+firmId) → карточка из 2ГИС/Яндекс (name/site/phones/region/desc/is_federal). Реализует и `resolveByName`. | нет (за `PageFetcher`) | да, фикстуры | +| B | Канал А — 2ГИС/Яндекс категория-поиск + пагинация → список имён | нет (за `PageFetcher`) | да, фикстуры | +| C | Канал В — LLM-имена → LLM-фильтр агрегаторов → резолвер | AITUNNEL/Perplexity | логика да, вызовы за границей | +| D | DaData-телефоны — нормализация номеров карточки (тип/регион/годность) | DaData | логика да, клиент за границей | +| E | Слияние+дедуп (union-find по имени-транслит/корню домена/телефону) + вычет клиента | нет | да | +| F | Похожесть — эмбеддинги примеров клиента vs кандидата → косинус → % | AITUNNEL | логика да, клиент за границей | +| G | Канал 0 — портал поставщика (Playwright-логин → проекты клиента) | портал crm.bp-gr.ru | парсинг да, добыча за границей | +| H | Оркестрация `RealCompetitorAgent.findCompetitors` (A+B+0 → E → F → DTO) + флип провайдера за флагом | все | сборка да | + +**Порядок:** A (фундамент, нужен всем) → E+F (слияние/похожесть, чистая логика) → B → C/D → G → H. +Живые блоки (C/D/F/G) — после готовности их ключей и отдельного «го» владельца. + +--- + +## ПОД-БЛОК A — Резолвер (этот заход) + +**Граница транспорта:** переиспользуем существующий `PageFetcher` (шаг 2): `html(string $url): string`. +Резолвер получает HTML карточки и парсит — без сети, фикстуры в тестах. + +**Файлы:** +- Create: `app/Services/Autopodbor/Agent/Resolve/ResolvedCompetitor.php` — DTO результата резолва + (`name, siteUrl, phones[], directoryUrl, source, region, description, isFederal`). +- Create: `app/Services/Autopodbor/Agent/Resolve/TwoGisResolver.php` — парсер карточки 2ГИС firm. +- Create: `app/Services/Autopodbor/Agent/Resolve/YandexResolver.php` — парсер Яндекс org. +- Create: `app/Services/Autopodbor/Agent/Resolve/CompetitorResolver.php` — оркестратор: 2ГИС → иначе + Яндекс → иначе `local=false`; решает `is_federal`. +- Test: `tests/Unit/Autopodbor/Resolve/*Test.php` (+ фикстуры HTML в `tests/fixtures/autopodbor/`). + +### Задача A1 — DTO ResolvedCompetitor +- [ ] **Шаг 1: тест** `tests/Unit/Autopodbor/Resolve/ResolvedCompetitorTest.php` — конструирует DTO, + проверяет поля и значения по умолчанию (phones=[], isFederal=false, region=null). +- [ ] **Шаг 2: запуск — FAIL** (класса нет). +- [ ] **Шаг 3: реализация** `ResolvedCompetitor` (readonly-проперти, как `CollectedSource`). +- [ ] **Шаг 4: запуск — PASS.** +- [ ] **Шаг 5: коммит** (эскейп). + +### Задача A2 — TwoGisResolver: карточка 2ГИС → name/site/phones/region/description +- [ ] **Шаг 1: тест** на реальной фикстуре карточки 2ГИС firm (взять из живого прогона шага 2): + парсит имя из ``, сайт из contact_groups (type=website, чистый, не `link.2gis`), телефоны + (type=phone/value), город из адреса. Терпим к `?stat=` после firm-id. +- [ ] **Шаг 2: запуск — FAIL.** +- [ ] **Шаг 3: реализация** `TwoGisResolver::parse(string $html, string $url): ?ResolvedCompetitor` + (переиспользовать наработки `DirectoryParser` из шага 2 для tel/адреса). +- [ ] **Шаг 4: запуск — PASS.** +- [ ] **Шаг 5: коммит** (эскейп). + +### Задача A3 — YandexResolver: org → проверка имя+категория+город +- [ ] **Шаг 1: тест** на фикстуре Яндекс org: парсит phones[].value, urls[] (сайт), categories[] + (описание), прямую ссылку `/maps/org/<seo>/<id>`; ОТВЕРГАет чужую фирму того же имени (город/ + категория не совпали). +- [ ] **Шаг 2: запуск — FAIL.** +- [ ] **Шаг 3: реализация** `YandexResolver::parse(string $html, string $url, string $expectName, string $city): ?ResolvedCompetitor`. +- [ ] **Шаг 4: запуск — PASS.** +- [ ] **Шаг 5: коммит** (эскейп). + +### Задача A4 — CompetitorResolver: оркестрация + is_federal + local=false +- [ ] **Шаг 1: тест** (подставной `PageFetcher`): имя → 2ГИС-в-городе найден → карточка местная + (is_federal=false); 2ГИС нет, Яндекс нет, но сайт есть → is_federal=true; ничего → `local=false`, + карточка с пометкой «нет филиала в регионе». +- [ ] **Шаг 2: запуск — FAIL.** +- [ ] **Шаг 3: реализация** `CompetitorResolver::resolve(string $name, ?string $firmId, string $city): ResolvedCompetitor`. +- [ ] **Шаг 4: запуск — PASS.** +- [ ] **Шаг 5: коммит** (эскейп). + +### Задача A5 — resolveByName через резолвер +- [ ] **Шаг 1: тест** — новый `ResolvingAgent` (или метод в будущем RealCompetitorAgent) отдаёт + `ResolveByNameResult` из `CompetitorResolver` (контракт §7.2: name/description/site_url/directory_urls/provenance). +- [ ] **Шаг 2–4: red→green.** +- [ ] **Шаг 5: коммит** (эскейп). + +**Выход под-блока A:** имя → настоящая карточка из справочников, офлайн-протестировано; `resolveByName` +работает реально. Это фундамент для каналов B/C/0. Провайдер НЕ флипается (findCompetitors всё ещё +заглушка до под-блока H). + +--- + +## Самопроверка плана +- Покрытие §12: резолвер (§12.3) ✅ A; каналы (§12.1) B/C/G; телефоны (§12.4) D; эмбеддинги (§12.5) F; + агрегаторы (§12.6) C; слияние/дедуп E; оркестрация+флип H. Похожесть-«не модель» — F. ✅ +- Без заглушек-плейсхолдеров: каждая задача A1–A5 несёт конкретный тест+класс. +- Согласованность типов: `ResolvedCompetitor` определён в A1, используется A2–A5. +- ⚠️ Открытый под-вопрос к владельцу перед живыми блоками: транспорт 2ГИС — xfetch (уже работает в + шаге 2) vs firecrawl (§12.2 после падения xfetch). Решать на под-блоке B/H, не на A (A офлайн). From 4003e89935cb6ee5108f9f28045d39ec32d64c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= <noreply@anthropic.com> Date: Tue, 30 Jun 2026 16:37:05 +0300 Subject: [PATCH 076/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=88=D0=B0=D0=B3=201=20A1?= =?UTF-8?q?=20=E2=80=94=20DTO=20ResolvedCompetitor=20(=D1=84=D1=83=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0=D0=BC=D0=B5=D0=BD=D1=82=20=D1=80=D0=B5=D0=B7=D0=BE?= =?UTF-8?q?=D0=BB=D0=B2=D0=B5=D1=80=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка резолва имени: name/site/phones/directoryUrl/source/region/description/ isFederal + isLocal(). Без ИНН (решение владельца). Офлайн-тест 3/3. Фундамент под-блока A плана шага 1. НЕ на проде, воркстри avtopodbor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../Agent/Resolve/ResolvedCompetitor.php | 33 +++++++++++++ .../Resolve/ResolvedCompetitorTest.php | 47 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Resolve/ResolvedCompetitor.php create mode 100644 app/tests/Unit/Autopodbor/Resolve/ResolvedCompetitorTest.php diff --git a/app/app/Services/Autopodbor/Agent/Resolve/ResolvedCompetitor.php b/app/app/Services/Autopodbor/Agent/Resolve/ResolvedCompetitor.php new file mode 100644 index 00000000..ef9485fb --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Resolve/ResolvedCompetitor.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace App\Services\Autopodbor\Agent\Resolve; + +/** + * Результат резолва одного имени конкурента в настоящую карточку справочника (§12.3). + * Если местной карточки нет (ни 2ГИС-в-городе, ни Яндекс-совпал) — directoryUrl/source = null, + * {@see isLocal()} = false (UI «нет филиала в регионе»). + * + * ИНН-полей НЕТ намеренно (решение владельца 29.06): карточка = имя/сайт/телефоны/справочник. + */ +final class ResolvedCompetitor +{ + /** @param list<string> $phones номера в формате 7XXXXXXXXXX */ + public function __construct( + public readonly string $name, + public readonly ?string $siteUrl = null, + public readonly array $phones = [], + public readonly ?string $directoryUrl = null, // прямая ссылка 2ГИС firm/<id> или Яндекс org/<seo>/<id> + public readonly ?string $source = null, // 2ГИС | Яндекс.Карты | null + public readonly ?string $region = null, // город из адреса карточки + public readonly ?string $description = null, // рубрики/категории — для эмбеддинг-похожести + public readonly bool $isFederal = false, // нет местной карточки + есть сайт = федерал/онлайн + ) {} + + /** Есть ли настоящая местная карточка в справочнике (иначе — «нет филиала в регионе»). */ + public function isLocal(): bool + { + return $this->directoryUrl !== null; + } +} diff --git a/app/tests/Unit/Autopodbor/Resolve/ResolvedCompetitorTest.php b/app/tests/Unit/Autopodbor/Resolve/ResolvedCompetitorTest.php new file mode 100644 index 00000000..7097223a --- /dev/null +++ b/app/tests/Unit/Autopodbor/Resolve/ResolvedCompetitorTest.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +use App\Services\Autopodbor\Agent\Resolve\ResolvedCompetitor; + +it('несёт поля карточки и разумные значения по умолчанию', function () { + $c = new ResolvedCompetitor( + name: 'КрасЛомбард', + siteUrl: 'kraslombard24.ru', + phones: ['73912920000'], + directoryUrl: 'https://2gis.ru/krasnoyarsk/firm/111', + source: '2ГИС', + region: 'Красноярск', + description: 'Ломбард, автоломбард', + isFederal: false, + ); + + expect($c->name)->toBe('КрасЛомбард'); + expect($c->siteUrl)->toBe('kraslombard24.ru'); + expect($c->phones)->toBe(['73912920000']); + expect($c->directoryUrl)->toBe('https://2gis.ru/krasnoyarsk/firm/111'); + expect($c->source)->toBe('2ГИС'); + expect($c->region)->toBe('Красноярск'); + expect($c->description)->toBe('Ломбард, автоломбард'); + expect($c->isFederal)->toBeFalse(); +}); + +it('минимальная карточка: только имя, остальное по умолчанию', function () { + $c = new ResolvedCompetitor(name: 'Только Имя'); + + expect($c->name)->toBe('Только Имя'); + expect($c->siteUrl)->toBeNull(); + expect($c->phones)->toBe([]); + expect($c->directoryUrl)->toBeNull(); + expect($c->source)->toBeNull(); + expect($c->region)->toBeNull(); + expect($c->description)->toBeNull(); + expect($c->isFederal)->toBeFalse(); + // local=false ⇒ нет местной карточки: ни 2ГИС, ни Яндекс + expect($c->isLocal())->toBeFalse(); +}); + +it('isLocal() истинно, когда есть ссылка на карточку справочника', function () { + $c = new ResolvedCompetitor(name: 'X', directoryUrl: 'https://2gis.ru/krasnoyarsk/firm/9', source: '2ГИС'); + expect($c->isLocal())->toBeTrue(); +}); From a1443401c81dd4b8f637cc1c7e78bd1e0db321ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= <noreply@anthropic.com> Date: Tue, 30 Jun 2026 16:51:40 +0300 Subject: [PATCH 077/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D1=82-=D0=BF=D1=80=D0=BE=D0=BC=D0=BF=D1=82?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B4=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=88=D0=B0=D0=B3=D0=B0=201=20=E2=80=94=20=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D1=83=20=D0=B4=D0=B2?= =?UTF-8?q?=D0=B8=D0=B6=D0=BE=D0=BA=20v4=20(2=20=D0=B4=D0=BD=D1=8F=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=BE=D0=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Резюме для подхвата после компакта: брать ПОСЛЕДНИЙ вариант (§12 v4), ранние не воскрешать (ИНН выкинут, похожесть=эмбеддинги, агрегаторы=LLM, is_federal по факту). Следующая задача A2 TwoGisResolver. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../plans/2026-06-30-KONTEKST-step1.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-KONTEKST-step1.md diff --git a/docs/superpowers/plans/2026-06-30-KONTEKST-step1.md b/docs/superpowers/plans/2026-06-30-KONTEKST-step1.md new file mode 100644 index 00000000..8b6fc4fe --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-KONTEKST-step1.md @@ -0,0 +1,62 @@ +# Контекст-промпт продолжения ШАГА 1 «Автоподбора» (после компакта) + +> Скопируй блок ниже в новый промпт. Воркстри `worktree-avtopodbor`. **НЕ прод. Коммит — спросив +> «эскейп». Пуш — только gitea (GitHub suspended). Простым языком — владелец не программист.** + +--- + +Продолжаем фичу **«Автоподбор конкурентов» / «Конкурентное поле»** Лидерры, **ШАГ 1 (поиск +конкурентов, реальный `findCompetitors`)**. Воркстри `worktree-avtopodbor` (код под `app/`). НЕ прод. +Руками 2ГИС/Яндекс не парсить (антибот) — только через сервисы. Коммит — спросив «эскейп». Пуш — gitea. + +🔴🔴 **ГЛАВНОЕ — ЗА ОСНОВУ БЕРИ ПОСЛЕДНИЙ ВАРИАНТ ДВИЖКА (v4).** Дизайн шага 1 **2 дня реально +гоняли вживую** — он выстрадан, не теоретический. Источник истины — **§12 «ФИНАЛЬНЫЙ ДВИЖОК v4»** в +`docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-brainstorm-notes.md` (⚠️ в ОСНОВНОМ репо, не +воркстри). **Ранние варианты НЕ воскрешать** — они уже отвергнуты владельцем по итогам прогонов: +- ❌ ИНН-якорь / контрольные суммы / DaData-party-по-ИНН (v3) — **ИНН ВЫКИНУТ ПОЛНОСТЬЮ** (29.06). + Карточка конкурента = **имя / сайт(ы) / телефоны / ссылка 2ГИС / ссылка Яндекс** — и всё. +- ❌ «модель сама оценит % похожести» — провалилось → **похожесть = ЭМБЕДДИНГИ** (AITUNNEL + text-embedding-3-small, косинус по имя+описание), описание обязательно. +- ❌ статический список агрегаторов → **LLM-классификатор** «поставщик услуги или площадка?». +- ❌ «модель решает федерал/местный» → **is_federal ПО ФАКТУ** (нет местной карточки + есть сайт = федерал). +- Система **универсальна** для любых клиентов/рынков; всё клиентское (регион/город/профиль/запросы) — + из конфига, в коде не зашито (Омега — лишь тест-пример). +- Телефоны **не синтезируем** (городской↔мобильный не конвертируется — доказано) — только + опубликованные фирмой номера. + +АРХИТЕКТУРА v4 (§12.1): 3 канала добычи имён → ЕДИНЫЙ РЕЗОЛВЕР → DaData-телефоны → слияние+дедуп → +эмбеддинги-похожесть → DTO. Каналы: **0** портал поставщика (Playwright-логин lkomega на crm.bp-gr.ru +→ проекты клиента «уже на прозвоне»), **А** справочники 2ГИС+Яндекс (мульти-запрос+пагинация), **В** +федералы (LLM-имена → LLM-фильтр агрегаторов → резолвер). Реальная логика жила в Node-спайке, который +самоочистился — **в PHP её ещё нет, собираем заново по §12**. + +ПРОЧИТАЙ В НАЧАЛЕ (durable, не по памяти): +1. **План шага 1:** `docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md` — декомпозиция + на под-блоки A–H, фундамент-первым, TDD-задачи. ГЛАВНЫЙ рабочий файл. +2. **Дизайн v4:** брейншторм §12 (см. выше, основной репо) + контракт розетки §7 в + `…-autopodbor-konkurentov-design.md`. +3. **Статус готовности:** `docs/superpowers/findings/2026-06-30-GOTOVNOST-status.md`. + +ГДЕ МЫ: **Шаг 2 — завершён** (встроен в боевой путь, проверен живым прогоном; не трогать). +**Шаг 1 — стартовал:** план A–H написан; **A1 готов** — DTO `ResolvedCompetitor` +(`app/Services/Autopodbor/Agent/Resolve/`, офлайн-тест 3/3). Всё на gitea (HEAD `2cf8b747`). + +СЛЕДУЮЩАЯ ЗАДАЧА — **A2 `TwoGisResolver`**: снять РЕАЛЬНУЮ фикстуру карточки 2ГИС firm живым fetch +(xfetch уже работает, ключ свежий в `app/.env`) → сохранить в `tests/fixtures/autopodbor/` → по TDD +парсер карточки → имя (из `<title>`) / сайт (contact_groups type=website, чистый, не `link.2gis`) / +телефоны (type=phone) / город (из адреса) / описание (рубрики). Регексп терпим к `?stat=` после firm-id. +Дальше A3 (YandexResolver), A4 (CompetitorResolver + is_federal + local=false), A5 (resolveByName). +Провайдер НЕ флипать до под-блока H (findCompetitors остаётся заглушкой, пока движок не собран). + +ИНСТРУМЕНТЫ/КЛЮЧИ: 2ГИС — **xfetch** (`POST https://xf4.ru/fetch`, render:true, base64; ключ +`XFETCH_API_KEY` в `app/.env`, СВЕЖИЙ, баланс есть — проверен HTTP 200). §12.2 писал «xfetch умер» — это +был временный сбой, СЕЙЧАС работает (на нём весь шаг 2). Транспорт 2ГИС (xfetch vs firecrawl) — +открытый под-вопрос на блоке B/H, НЕ на A (A офлайн на фикстурах). Яндекс — локальный Playwright +(`scripts/render-*.cjs`, node v24). DaData/AITUNNEL/exa — для живых блоков C/D/F, ключи по §12.9. +Секреты — только в `app/.env`/окружении, НИКОГДА в гит. + +СРЕДА: тесты `cd app && ./vendor/bin/pest tests/Unit/Autopodbor`; PHP `c:/tools/php83/php`; тест-БД +`liderra_testing` (миграции уже применены); lefthook не в PATH (коммиты проходят); 22 фронт-теста +падают пред-существующе (не чинить). Бэкенд автоподбора сейчас 131/131 + новые тесты резолвера. + +Стартуй с A2. Перед коммитом — «эскейп». Пуш — gitea. From a6d9dcd98cc5f2afdc1db394f7b272946ca68587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= <noreply@anthropic.com> Date: Tue, 30 Jun 2026 17:07:33 +0300 Subject: [PATCH 078/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=88=D0=B0=D0=B31=20A2=20?= =?UTF-8?q?=E2=80=94=20TwoGisResolver,=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=202?= =?UTF-8?q?=D0=93=D0=98=D0=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разбор реальной карточки филиала 2ГИС (/<город>/firm/<id>) в ResolvedCompetitor: имя из <title>, чистый сайт из contact_groups (url, не редирект link.2gis), телефоны type=phone (нормализованы к 7XXXXXXXXXX), город из title, описание из rubrics. Терпим к хвосту ?stat=. Пустая оболочка 2ГИС → null. Фикстура — реальная карточка КрасЛомбарда из живого прогона шага 2 (публичный бизнес-телефон конкурента, не клиентские ПДн); allowlist gitleaks для app/tests/fixtures/. Тесты: A2 3/3; модуль Автоподбора unit 64/64. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .gitleaks.toml | 5 + .../Agent/Resolve/TwoGisResolver.php | 115 ++++++++++++++++++ .../Autopodbor/Resolve/TwoGisResolverTest.php | 40 ++++++ .../autopodbor/2gis-firm-kraslombard.html | 9 ++ 4 files changed, 169 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Resolve/TwoGisResolver.php create mode 100644 app/tests/Unit/Autopodbor/Resolve/TwoGisResolverTest.php create mode 100644 app/tests/fixtures/autopodbor/2gis-firm-kraslombard.html diff --git a/.gitleaks.toml b/.gitleaks.toml index d57b9ccf..c2707987 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -87,6 +87,11 @@ paths = [ '''app/composer\.lock''', # Pest-тесты с фиктивными data-фикстурами (не реальные ПДн) '''app/tests/.*\.php''', + # Тест-фикстуры (HTML/JSON/CSV) — снятые публичные страницы справочников и + # синтетика для парсеров. Напр. карточка 2ГИС с ПУБЛИЧНЫМ бизнес-телефоном + # конкурента (опубликован в открытом справочнике), не клиентские ПДн. + # Та же категория, что app/tests/*.php выше. + '''app/tests/fixtures/.*''', # Database seeders с демо-данными (admin@demo.local + +7916123XXXX демо-телефоны) '''app/database/seeders/.*\.php''', # Database factories — генераторы тестовых фикстур (фейковые телефоны/ИНН, diff --git a/app/app/Services/Autopodbor/Agent/Resolve/TwoGisResolver.php b/app/app/Services/Autopodbor/Agent/Resolve/TwoGisResolver.php new file mode 100644 index 00000000..dacd7b0a --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Resolve/TwoGisResolver.php @@ -0,0 +1,115 @@ +<?php + +declare(strict_types=1); + +namespace App\Services\Autopodbor\Agent\Resolve; + +/** + * Разбор карточки филиала 2ГИС (/<город>/firm/<id>) в {@see ResolvedCompetitor}. + * Чистый: на вход — уже отрендеренный HTML карточки, на выход — карточка конкурента + * (имя/сайт/телефоны/город/рубрики) либо null, если данных карточки нет (пустая «оболочка» 2ГИС). + * + * Источники полей в HTML 2ГИС: + * - имя/город — <title> вида «Имя, адрес…, Город — 2ГИС» (первый/последний сегмент); + * - сайт — contact_groups, объект type=website, поле url (ЧИСТЫЙ адрес, НЕ редирект link.2gis в value); + * - телефоны — contact_groups, объекты type=phone, поле value (нормализуем к 7XXXXXXXXXX); + * - описание — rubrics[].name (для эмбеддинг-похожести на следующих под-блоках). + */ +final class TwoGisResolver +{ + public function parse(string $html, string $url): ?ResolvedCompetitor + { + [$name, $city] = $this->nameAndCity($html); + if ($name === null) { + return null; // нет имени фирмы в title — это не карточка филиала + } + + return new ResolvedCompetitor( + name: $name, + siteUrl: $this->website($html), + phones: $this->phones($html), + directoryUrl: $this->cleanUrl($url), + source: '2ГИС', + region: $city, + description: $this->description($html), + isFederal: false, // найдена местная карточка справочника + ); + } + + /** @return array{0: ?string, 1: ?string} [имя, город] из <title>; имя=null если title не похож на карточку */ + private function nameAndCity(string $html): array + { + if (! preg_match('#<title>(.*?)#is', $html, $m)) { + return [null, null]; + } + $title = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $title = preg_replace('/[\s\x{00A0}]+/u', ' ', $title) ?? $title; + $title = trim($title); + // отрезаем хвост « — 2ГИС» / « — Яндекс Карты» + $title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title; + + $parts = array_values(array_filter(array_map('trim', explode(',', $title)), fn ($p) => $p !== '')); + if (count($parts) < 2) { + return [null, null]; // «2gis.ru» и прочие оболочки без «Имя, …, Город» + } + + return [$parts[0], $parts[count($parts) - 1]]; + } + + /** Чистый сайт из contact_groups (url перед type=website), а НЕ редирект link.2gis из value. */ + private function website(string $html): ?string + { + if (preg_match('/"url":"(https?:\/\/[^"]+)"[^{}]*?"type":"website"/i', $html, $m)) { + return $m[1]; + } + + return null; + } + + /** @return list телефоны из contact_groups type=phone, нормализованы к 7XXXXXXXXXX */ + private function phones(string $html): array + { + $out = []; + if (preg_match_all('/"type":"phone","value":"([+0-9]+)"/i', $html, $m)) { + foreach ($m[1] as $raw) { + $digits = preg_replace('/\D+/', '', $raw) ?? ''; + if (strlen($digits) === 11 && $digits[0] === '8') { + $digits = '7'.substr($digits, 1); + } + if ($digits !== '' && ! in_array($digits, $out, true)) { + $out[] = $digits; + } + } + } + + return $out; + } + + /** Описание = названия рубрик карточки (rubrics[].name). */ + private function description(string $html): ?string + { + if (! preg_match('/"rubrics":\s*\[(.*?)\]/s', $html, $rm)) { + return null; + } + if (! preg_match_all('/"name":"([^"]+)"/', $rm[1], $nm)) { + return null; + } + $names = []; + foreach ($nm[1] as $n) { + $n = trim($n); + if ($n !== '' && ! in_array($n, $names, true)) { + $names[] = $n; + } + } + + return $names === [] ? null : implode(', ', $names); + } + + /** Прямая ссылка карточки без хвоста ?stat=… (2ГИС дописывает к firm/). */ + private function cleanUrl(string $url): string + { + $q = strpos($url, '?'); + + return $q === false ? $url : substr($url, 0, $q); + } +} diff --git a/app/tests/Unit/Autopodbor/Resolve/TwoGisResolverTest.php b/app/tests/Unit/Autopodbor/Resolve/TwoGisResolverTest.php new file mode 100644 index 00000000..4e66a423 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Resolve/TwoGisResolverTest.php @@ -0,0 +1,40 @@ +parse(twoGisFixture(), $url); + + expect($c)->not->toBeNull(); + expect($c->name)->toBe('КрасЛомбард'); // из , первый сегмент + expect($c->siteUrl)->toBe('http://kraslombard24.ru'); // чистый url, НЕ редирект link.2gis + expect($c->phones)->toContain('73912920000'); // нормализован к 7XXXXXXXXXX + expect($c->region)->toBe('Красноярск'); // город — последний сегмент title + expect($c->description)->toContain('Ломбарды'); // рубрики + expect($c->source)->toBe('2ГИС'); + expect($c->directoryUrl)->toBe('https://2gis.ru/krasnoyarsk/firm/70000001040792812'); // ?stat срезан + expect($c->isLocal())->toBeTrue(); + expect($c->isFederal)->toBeFalse(); +}); + +it('НЕ берёт редирект link.2gis как сайт конкурента', function () { + $url = 'https://2gis.ru/krasnoyarsk/firm/70000001040792812'; + $c = (new TwoGisResolver)->parse(twoGisFixture(), $url); + + expect($c->siteUrl)->not->toContain('link.2gis.ru'); +}); + +it('возвращает null, когда в HTML нет данных карточки (пустая оболочка 2ГИС)', function () { + $c = (new TwoGisResolver)->parse('<html><head><title>2gis.ru', 'https://2gis.ru/x/firm/1'); + + expect($c)->toBeNull(); +}); diff --git a/app/tests/fixtures/autopodbor/2gis-firm-kraslombard.html b/app/tests/fixtures/autopodbor/2gis-firm-kraslombard.html new file mode 100644 index 00000000..6acba607 --- /dev/null +++ b/app/tests/fixtures/autopodbor/2gis-firm-kraslombard.html @@ -0,0 +1,9 @@ + + +КрасЛомбард, улица 60 лет Октября, 54, Красноярск — 2ГИС + + + + From 05b7fd7f71d68ed9afa3a84765209786ac3d5738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 17:17:33 +0300 Subject: [PATCH 079/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=88=D0=B0=D0=B31=20A3=20?= =?UTF-8?q?=E2=80=94=20YandexResolver=20+=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9?= =?UTF-8?q?=20DirectoryFields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разбор реальной org-карточки Яндекс.Карт (/maps/org//) в ResolvedCompetitor: имя/город из , чистый сайт из business-urls (itemprop=url, без utm), телефоны type=phone (→7XXXXXXXXXX), описание из categories[].name. Отбраковка чужой фирмы того же имени: имя и город обязаны совпасть с ожидаемыми, иначе null. Рефактор: общие извлекатели имя+город и телефоны вынесены в DirectoryFields (используют и 2ГИС, и Яндекс) — без дублирования. Фикстура — реальная карточка КрасЛомбарда, снята локальным Playwright (render-page.cjs). Тесты: модуль Автоподбора unit 68/68; Pint чисто. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../Agent/Resolve/DirectoryFields.php | 62 +++++++++++ .../Agent/Resolve/TwoGisResolver.php | 43 +------- .../Agent/Resolve/YandexResolver.php | 100 ++++++++++++++++++ .../Autopodbor/Resolve/YandexResolverTest.php | 55 ++++++++++ .../autopodbor/yandex-org-kraslombard.html | 12 +++ 5 files changed, 231 insertions(+), 41 deletions(-) create mode 100644 app/app/Services/Autopodbor/Agent/Resolve/DirectoryFields.php create mode 100644 app/app/Services/Autopodbor/Agent/Resolve/YandexResolver.php create mode 100644 app/tests/Unit/Autopodbor/Resolve/YandexResolverTest.php create mode 100644 app/tests/fixtures/autopodbor/yandex-org-kraslombard.html diff --git a/app/app/Services/Autopodbor/Agent/Resolve/DirectoryFields.php b/app/app/Services/Autopodbor/Agent/Resolve/DirectoryFields.php new file mode 100644 index 00000000..dd7b132b --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Resolve/DirectoryFields.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +namespace App\Services\Autopodbor\Agent\Resolve; + +/** + * Общие извлекатели полей из карточек справочников (2ГИС/Яндекс) — то, что у резолверов + * совпадает байт-в-байт: имя+город из <title> и телефоны из contact_groups/phones. + * Чистые статические функции, без состояния. + */ +final class DirectoryFields +{ + /** + * Имя (первый сегмент) и город (последний сегмент) из <title> карточки. + * Заголовок справочников: «Имя, …адрес/рубрика…, Город — 2ГИС|Яндекс Карты». + * + * @return array{0: ?string, 1: ?string} [имя, город]; имя=null если title не похож на карточку + */ + public static function nameAndCity(string $html): array + { + if (! preg_match('#<title>(.*?)#is', $html, $m)) { + return [null, null]; + } + $title = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $title = preg_replace('/[\s\x{00A0}]+/u', ' ', $title) ?? $title; + $title = trim($title); + // отрезаем хвост « — 2ГИС» / « — Яндекс Карты» + $title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title; + + $parts = array_values(array_filter(array_map('trim', explode(',', $title)), fn ($p) => $p !== '')); + if (count($parts) < 2) { + return [null, null]; + } + + return [$parts[0], $parts[count($parts) - 1]]; + } + + /** + * Телефоны из встроенного JSON карточки (объекты type=phone, поле value), + * нормализованы к 7XXXXXXXXXX (8→7, без +/скобок/пробелов). + * + * @return list + */ + public static function phones(string $html): array + { + $out = []; + if (preg_match_all('/"type":"phone","value":"([+0-9]+)"/i', $html, $m)) { + foreach ($m[1] as $raw) { + $digits = preg_replace('/\D+/', '', $raw) ?? ''; + if (strlen($digits) === 11 && $digits[0] === '8') { + $digits = '7'.substr($digits, 1); + } + if ($digits !== '' && ! in_array($digits, $out, true)) { + $out[] = $digits; + } + } + } + + return $out; + } +} diff --git a/app/app/Services/Autopodbor/Agent/Resolve/TwoGisResolver.php b/app/app/Services/Autopodbor/Agent/Resolve/TwoGisResolver.php index dacd7b0a..9ba56a88 100644 --- a/app/app/Services/Autopodbor/Agent/Resolve/TwoGisResolver.php +++ b/app/app/Services/Autopodbor/Agent/Resolve/TwoGisResolver.php @@ -19,7 +19,7 @@ final class TwoGisResolver { public function parse(string $html, string $url): ?ResolvedCompetitor { - [$name, $city] = $this->nameAndCity($html); + [$name, $city] = DirectoryFields::nameAndCity($html); if ($name === null) { return null; // нет имени фирмы в title — это не карточка филиала } @@ -27,7 +27,7 @@ final class TwoGisResolver return new ResolvedCompetitor( name: $name, siteUrl: $this->website($html), - phones: $this->phones($html), + phones: DirectoryFields::phones($html), directoryUrl: $this->cleanUrl($url), source: '2ГИС', region: $city, @@ -36,26 +36,6 @@ final class TwoGisResolver ); } - /** @return array{0: ?string, 1: ?string} [имя, город] из ; имя=null если title не похож на карточку */ - private function nameAndCity(string $html): array - { - if (! preg_match('#<title>(.*?)#is', $html, $m)) { - return [null, null]; - } - $title = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'); - $title = preg_replace('/[\s\x{00A0}]+/u', ' ', $title) ?? $title; - $title = trim($title); - // отрезаем хвост « — 2ГИС» / « — Яндекс Карты» - $title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title; - - $parts = array_values(array_filter(array_map('trim', explode(',', $title)), fn ($p) => $p !== '')); - if (count($parts) < 2) { - return [null, null]; // «2gis.ru» и прочие оболочки без «Имя, …, Город» - } - - return [$parts[0], $parts[count($parts) - 1]]; - } - /** Чистый сайт из contact_groups (url перед type=website), а НЕ редирект link.2gis из value. */ private function website(string $html): ?string { @@ -66,25 +46,6 @@ final class TwoGisResolver return null; } - /** @return list телефоны из contact_groups type=phone, нормализованы к 7XXXXXXXXXX */ - private function phones(string $html): array - { - $out = []; - if (preg_match_all('/"type":"phone","value":"([+0-9]+)"/i', $html, $m)) { - foreach ($m[1] as $raw) { - $digits = preg_replace('/\D+/', '', $raw) ?? ''; - if (strlen($digits) === 11 && $digits[0] === '8') { - $digits = '7'.substr($digits, 1); - } - if ($digits !== '' && ! in_array($digits, $out, true)) { - $out[] = $digits; - } - } - } - - return $out; - } - /** Описание = названия рубрик карточки (rubrics[].name). */ private function description(string $html): ?string { diff --git a/app/app/Services/Autopodbor/Agent/Resolve/YandexResolver.php b/app/app/Services/Autopodbor/Agent/Resolve/YandexResolver.php new file mode 100644 index 00000000..89cd82f3 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Resolve/YandexResolver.php @@ -0,0 +1,100 @@ +/) в {@see ResolvedCompetitor}. + * Чистый: на вход — отрендеренный HTML карточки + ожидаемое имя и город (для отбраковки + * чужой фирмы того же имени из другого города/профиля). Возвращает null, если это не наша + * карточка (имя/город не совпали) или данных карточки нет. + * + * Источники полей: + * - имя/город — «Имя, рубрика, адрес…, Город — Яндекс Карты» (первый/последний сегмент); + * - сайт — business-urls, тег itemprop="url" (ЧИСТЫЙ адрес, без utm-хвоста action-кнопки); + * - телефоны — phones[].value (нормализуем к 7XXXXXXXXXX); + * - описание — categories[].name. + */ +final class YandexResolver +{ + public function parse(string $html, string $url, string $expectName, string $city): ?ResolvedCompetitor + { + [$name, $cardCity] = DirectoryFields::nameAndCity($html); + if ($name === null) { + return null; // не карточка организации + } + // Отбраковка чужой фирмы того же имени: имя и город должны совпасть с ожидаемыми. + if (! $this->namesMatch($name, $expectName)) { + return null; + } + if ($cardCity === null || ! $this->sameCity($cardCity, $city)) { + return null; + } + + return new ResolvedCompetitor( + name: $name, + siteUrl: $this->website($html), + phones: DirectoryFields::phones($html), + directoryUrl: $this->cleanUrl($url), + source: 'Яндекс.Карты', + region: $cardCity, + description: $this->description($html), + isFederal: false, + ); + } + + private function namesMatch(string $cardName, string $expect): bool + { + $a = mb_strtolower(trim($cardName)); + $b = mb_strtolower(trim($expect)); + + return $a !== '' && $b !== '' && (str_contains($a, $b) || str_contains($b, $a)); + } + + private function sameCity(string $cardCity, string $expect): bool + { + return mb_strtolower(trim($cardCity)) === mb_strtolower(trim($expect)); + } + + /** Чистый сайт из business-urls (itemprop=url), без utm-хвоста action-кнопки. */ + private function website(string $html): ?string + { + if (preg_match('/itemprop="url"[^>]*href="(https?:\/\/[^"?]+)/i', $html, $m)) { + return rtrim($m[1], '/'); + } + + return null; + } + + /** Описание = названия рубрик карточки (categories[].name). */ + private function description(string $html): ?string + { + if (! preg_match('/"categories":\s*\[(.*?)\]/s', $html, $cm)) { + return null; + } + if (! preg_match_all('/"name":"([^"]+)"/', $cm[1], $nm)) { + return null; + } + $names = []; + foreach ($nm[1] as $n) { + $n = trim($n); + if ($n !== '' && ! in_array($n, $names, true)) { + $names[] = $n; + } + } + + return $names === [] ? null : implode(', ', $names); + } + + /** Прямая ссылка /maps/org/<seo>/<id> без хвоста ?ll=… и завершающего слеша. */ + private function cleanUrl(string $url): string + { + $q = strpos($url, '?'); + if ($q !== false) { + $url = substr($url, 0, $q); + } + + return rtrim($url, '/'); + } +} diff --git a/app/tests/Unit/Autopodbor/Resolve/YandexResolverTest.php b/app/tests/Unit/Autopodbor/Resolve/YandexResolverTest.php new file mode 100644 index 00000000..60bb77d7 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Resolve/YandexResolverTest.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +use App\Services\Autopodbor\Agent\Resolve\YandexResolver; + +function yandexFixture(): string +{ + return file_get_contents(base_path('tests/fixtures/autopodbor/yandex-org-kraslombard.html')); +} + +it('разбирает реальную org-карточку Яндекс.Карт в карточку конкурента', function () { + // хвост ?ll=… Яндекс дописывает к /maps/org/<seo>/<id> — резолвер его терпит и срезает + $url = 'https://yandex.ru/maps/org/kraslombard/175852236692/?ll=92.8%2C56.0&z=16'; + $c = (new YandexResolver)->parse(yandexFixture(), $url, expectName: 'КрасЛомбард', city: 'Красноярск'); + + expect($c)->not->toBeNull(); + expect($c->name)->toBe('КрасЛомбард'); // первый сегмент title + expect($c->siteUrl)->toBe('http://kraslombard24.ru'); // чистый сайт (itemprop=url), без utm + expect($c->siteUrl)->not->toContain('utm_'); + expect($c->phones)->not->toBeEmpty(); + expect($c->phones)->toContain('73912828585'); // нормализован к 7XXXXXXXXXX + expect($c->region)->toBe('Красноярск'); // город — последний сегмент title + expect($c->description)->toContain('Ломбард'); // categories[].name + expect($c->source)->toBe('Яндекс.Карты'); + expect($c->directoryUrl)->toBe('https://yandex.ru/maps/org/kraslombard/175852236692'); // ?ll срезан, слеш убран + expect($c->isLocal())->toBeTrue(); + expect($c->isFederal)->toBeFalse(); +}); + +it('отбраковывает карточку из другого города (чужая фирма того же имени)', function () { + $url = 'https://yandex.ru/maps/org/kraslombard/175852236692/'; + // имя совпало, но искали в Москве, а карточка — в Красноярске → не наша + $c = (new YandexResolver)->parse(yandexFixture(), $url, expectName: 'КрасЛомбард', city: 'Москва'); + + expect($c)->toBeNull(); +}); + +it('отбраковывает карточку с чужим именем', function () { + $url = 'https://yandex.ru/maps/org/kraslombard/175852236692/'; + $c = (new YandexResolver)->parse(yandexFixture(), $url, expectName: 'Совсем Другая Фирма', city: 'Красноярск'); + + expect($c)->toBeNull(); +}); + +it('возвращает null на пустой оболочке без данных карточки', function () { + $c = (new YandexResolver)->parse( + '<html><head><title>Яндекс Карты', + 'https://yandex.ru/maps/org/x/1', + expectName: 'X', + city: 'Красноярск', + ); + + expect($c)->toBeNull(); +}); diff --git a/app/tests/fixtures/autopodbor/yandex-org-kraslombard.html b/app/tests/fixtures/autopodbor/yandex-org-kraslombard.html new file mode 100644 index 00000000..9c8435d3 --- /dev/null +++ b/app/tests/fixtures/autopodbor/yandex-org-kraslombard.html @@ -0,0 +1,12 @@ + + +КрасЛомбард, ломбард, ул. Чернышевского, 100, Красноярск — Яндекс Карты + + +
+
+ +
+ From a98cf603c0f2786adcfe386cf9fc094b902a064c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 17:28:14 +0300 Subject: [PATCH 080/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=88=D0=B0=D0=B31=20A4+A5?= =?UTF-8?q?=20=E2=80=94=20CompetitorResolver=20+=20resolveByName=20(=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4-=D0=B1=D0=BB=D0=BE=D0=BA=20A=20=D0=B3=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A4 CompetitorResolver (§12.3): имя+город → 2ГИС-в-городе (прямая ссылка канала А) → иначе Яндекс (поиск имя+город → первый org → проверка) → иначе local=false. is_federal ПО ФАКТУ: нет местной карточки + есть сайт = федерал; есть карточка = местный. Транспорт за PageFetcher — вся логика офлайн на фикстурах. A5 ResolvingAgent (§7.2 resolveByName): имя+region_code → кандидат {name,description,site_url,directory_urls,provenance} из настоящей карточки; нет филиала → честный кандидат-заглушка. Свободное сравнение город↔субъект (DirectoryFields::localeMatches). Хелперы тестов (stubPages/autopodborFixture) вынесены в tests/Pest.php. Провайдер НЕ флипнут (findCompetitors всё ещё заглушка до под-блока H). Тесты: Resolve 17/17; модуль Автоподбора unit 75/75; Pint чисто. Co-Authored-By: Claude Opus 4.8 --- .../Agent/Resolve/CompetitorResolver.php | 73 +++++++++++++++++++ .../Agent/Resolve/DirectoryFields.php | 13 ++++ .../Agent/Resolve/ResolvingAgent.php | 45 ++++++++++++ .../Agent/Resolve/YandexResolver.php | 2 +- app/tests/Pest.php | 35 +++++++++ .../Resolve/CompetitorResolverTest.php | 68 +++++++++++++++++ .../Autopodbor/Resolve/ResolvingAgentTest.php | 42 +++++++++++ 7 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 app/app/Services/Autopodbor/Agent/Resolve/CompetitorResolver.php create mode 100644 app/app/Services/Autopodbor/Agent/Resolve/ResolvingAgent.php create mode 100644 app/tests/Unit/Autopodbor/Resolve/CompetitorResolverTest.php create mode 100644 app/tests/Unit/Autopodbor/Resolve/ResolvingAgentTest.php diff --git a/app/app/Services/Autopodbor/Agent/Resolve/CompetitorResolver.php b/app/app/Services/Autopodbor/Agent/Resolve/CompetitorResolver.php new file mode 100644 index 00000000..bfedc13c --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Resolve/CompetitorResolver.php @@ -0,0 +1,73 @@ + из канала А, если есть + * @param string $city город клиента (регион поиска) + * @param ?string $knownSite сайт кандидата, если известен из канала (для пометки федерала) + */ + public function resolve(string $name, ?string $twoGisUrl, string $city, ?string $knownSite = null): ResolvedCompetitor + { + // 1) 2ГИС по прямой ссылке из канала А — самый чистый сигнал. + if ($twoGisUrl !== null && $twoGisUrl !== '') { + $card = $this->twoGis->parse($this->pages->html($twoGisUrl), $twoGisUrl); + if ($card !== null && $this->inCity($card->region, $city)) { + return $card; // местная карточка 2ГИС + } + } + + // 2) Яндекс: поиск по имени+городе → первый org → проверка имя/город внутри YandexResolver. + $ya = $this->resolveYandex($name, $city); + if ($ya !== null) { + return $ya; + } + + // 3) Местной карточки нет. Есть сайт → федерал/онлайн; иначе — «нет филиала в регионе». + if ($knownSite !== null && $knownSite !== '') { + return new ResolvedCompetitor(name: $name, siteUrl: $knownSite, region: $city, isFederal: true); + } + + return new ResolvedCompetitor(name: $name, region: $city, isFederal: false); + } + + /** Поиск в Яндекс.Картах по «имя город» → первый org → проверка имя/город в YandexResolver. */ + private function resolveYandex(string $name, string $city): ?ResolvedCompetitor + { + $searchUrl = 'https://yandex.ru/maps/?text='.rawurlencode($name.' '.$city); + $searchHtml = $this->pages->html($searchUrl); + if (! preg_match('#/maps/org/[a-z0-9_-]+/\d+#i', $searchHtml, $m)) { + return null; // в выдаче нет ни одной организации + } + $orgUrl = 'https://yandex.ru'.$m[0]; + + return $this->yandex->parse($this->pages->html($orgUrl), $orgUrl, $name, $city); + } + + /** Карточка считается местной, если её город совпал с городом/регионом клиента. */ + private function inCity(?string $cardCity, string $city): bool + { + return $cardCity !== null && DirectoryFields::localeMatches($cardCity, $city); + } +} diff --git a/app/app/Services/Autopodbor/Agent/Resolve/DirectoryFields.php b/app/app/Services/Autopodbor/Agent/Resolve/DirectoryFields.php index dd7b132b..1a693f7a 100644 --- a/app/app/Services/Autopodbor/Agent/Resolve/DirectoryFields.php +++ b/app/app/Services/Autopodbor/Agent/Resolve/DirectoryFields.php @@ -59,4 +59,17 @@ final class DirectoryFields return $out; } + + /** + * Свободное сравнение локаций (город ↔ субъект): без регистра, по вхождению в любую сторону. + * Так город карточки «Красноярск» совпадает с регионом «Красноярский край» (ручной резолв + * по region_code), но «Красноярск» НЕ совпадает с «Москва». Консервативно: при сомнении — мимо. + */ + public static function localeMatches(string $a, string $b): bool + { + $a = mb_strtolower(trim($a)); + $b = mb_strtolower(trim($b)); + + return $a !== '' && $b !== '' && (str_contains($a, $b) || str_contains($b, $a)); + } } diff --git a/app/app/Services/Autopodbor/Agent/Resolve/ResolvingAgent.php b/app/app/Services/Autopodbor/Agent/Resolve/ResolvingAgent.php new file mode 100644 index 00000000..31dc55f5 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Resolve/ResolvingAgent.php @@ -0,0 +1,45 @@ +regionCode] ?? ''; + $card = $this->resolver->resolve($r->name, twoGisUrl: null, city: $locale); + + return new ResolveByNameResult([$this->toCandidate($card)]); + } + + /** + * Карточка резолвера → кандидат §7.2 {name, description, site_url, directory_urls[], provenance}. + * + * @return array{name:string,description:?string,site_url:?string,directory_urls:list,is_federal:bool,provenance:array{via:string,source:?string}} + */ + private function toCandidate(ResolvedCompetitor $c): array + { + return [ + 'name' => $c->name, + 'description' => $c->description, + 'site_url' => $c->siteUrl, + 'directory_urls' => $c->directoryUrl !== null ? [$c->directoryUrl] : [], + 'is_federal' => $c->isFederal, + 'provenance' => ['via' => 'name-search', 'source' => $c->source], + ]; + } +} diff --git a/app/app/Services/Autopodbor/Agent/Resolve/YandexResolver.php b/app/app/Services/Autopodbor/Agent/Resolve/YandexResolver.php index 89cd82f3..7118e522 100644 --- a/app/app/Services/Autopodbor/Agent/Resolve/YandexResolver.php +++ b/app/app/Services/Autopodbor/Agent/Resolve/YandexResolver.php @@ -54,7 +54,7 @@ final class YandexResolver private function sameCity(string $cardCity, string $expect): bool { - return mb_strtolower(trim($cardCity)) === mb_strtolower(trim($expect)); + return DirectoryFields::localeMatches($cardCity, $expect); } /** Чистый сайт из business-urls (itemprop=url), без utm-хвоста action-кнопки. */ diff --git a/app/tests/Pest.php b/app/tests/Pest.php index d70d5289..62859391 100644 --- a/app/tests/Pest.php +++ b/app/tests/Pest.php @@ -2,6 +2,7 @@ use App\Models\Project; use App\Models\SupplierProject; +use App\Services\Autopodbor\Agent\Fetch\PageFetcher; use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Date; @@ -65,6 +66,40 @@ function something() // .. } +/** + * Читает фикстуру автоподбора из tests/fixtures/autopodbor/ (реальные карточки 2ГИС/Яндекс). + */ +function autopodborFixture(string $name): string +{ + return file_get_contents(base_path("tests/fixtures/autopodbor/{$name}")); +} + +/** + * Подставной загрузчик страниц для офлайн-тестов резолвера: по подстроке URL отдаёт + * заранее заданный HTML (иначе '' — как настоящий PageFetcher при неудаче). + * + * @param array $byNeedle подстрока URL → HTML + */ +function stubPages(array $byNeedle): PageFetcher +{ + return new class($byNeedle) implements PageFetcher + { + /** @param array $map */ + public function __construct(private array $map) {} + + public function html(string $url): string + { + foreach ($this->map as $needle => $html) { + if (str_contains($url, $needle)) { + return $html; + } + } + + return ''; + } + }; +} + /** * Link a Лидерра-project to a supplier_project via the M:N pivot * (Plan 1 model). Post-Plan-2 LeadRouter eligibility queries the pivot diff --git a/app/tests/Unit/Autopodbor/Resolve/CompetitorResolverTest.php b/app/tests/Unit/Autopodbor/Resolve/CompetitorResolverTest.php new file mode 100644 index 00000000..279d77ed --- /dev/null +++ b/app/tests/Unit/Autopodbor/Resolve/CompetitorResolverTest.php @@ -0,0 +1,68 @@ + autopodborFixture('2gis-firm-kraslombard.html')]); + $r = new CompetitorResolver($pages); + + $c = $r->resolve('КрасЛомбард', twoGisUrl: 'https://2gis.ru/krasnoyarsk/firm/70000001040792812', city: 'Красноярск'); + + expect($c->source)->toBe('2ГИС'); + expect($c->isLocal())->toBeTrue(); + expect($c->isFederal)->toBeFalse(); + expect($c->region)->toBe('Красноярск'); + expect($c->siteUrl)->toBe('http://kraslombard24.ru'); +}); + +it('2ГИС нет, Яндекс совпал → местная карточка с Яндекса', function () { + $pages = stubPages([ + 'maps/?text=' => 'org', + '/maps/org/' => autopodborFixture('yandex-org-kraslombard.html'), + ]); + $r = new CompetitorResolver($pages); + + $c = $r->resolve('КрасЛомбард', twoGisUrl: null, city: 'Красноярск'); + + expect($c->source)->toBe('Яндекс.Карты'); + expect($c->isLocal())->toBeTrue(); + expect($c->isFederal)->toBeFalse(); +}); + +it('2ГИС-карточка из другого города отбраковывается (не местная)', function () { + $pages = stubPages(['/firm/' => autopodborFixture('2gis-firm-kraslombard.html')]); // карточка в Красноярске + $r = new CompetitorResolver($pages); + + // искали в Москве — карточка Красноярска не считается местной, Яндекса в стабе нет + $c = $r->resolve('КрасЛомбард', twoGisUrl: 'https://2gis.ru/krasnoyarsk/firm/1', city: 'Москва'); + + expect($c->isLocal())->toBeFalse(); +}); + +it('ни 2ГИС, ни Яндекс, но есть сайт → федерал (is_federal=true, local=false)', function () { + $r = new CompetitorResolver(stubPages([])); + + $c = $r->resolve('CarMoney', twoGisUrl: null, city: 'Москва', knownSite: 'https://carmoney.ru'); + + expect($c->isFederal)->toBeTrue(); + expect($c->isLocal())->toBeFalse(); + expect($c->siteUrl)->toBe('https://carmoney.ru'); + expect($c->directoryUrl)->toBeNull(); + expect($c->name)->toBe('CarMoney'); +}); + +it('ничего не найдено и сайта нет → «нет филиала в регионе» (local=false, не федерал)', function () { + $r = new CompetitorResolver(stubPages([])); + + $c = $r->resolve('Загадочная Фирма', twoGisUrl: null, city: 'Москва'); + + expect($c->isLocal())->toBeFalse(); + expect($c->isFederal)->toBeFalse(); + expect($c->directoryUrl)->toBeNull(); + expect($c->name)->toBe('Загадочная Фирма'); + expect($c->region)->toBe('Москва'); +}); diff --git a/app/tests/Unit/Autopodbor/Resolve/ResolvingAgentTest.php b/app/tests/Unit/Autopodbor/Resolve/ResolvingAgentTest.php new file mode 100644 index 00000000..86aeda58 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Resolve/ResolvingAgentTest.php @@ -0,0 +1,42 @@ + 'org', + '/maps/org/' => autopodborFixture('yandex-org-kraslombard.html'), + ]); + $agent = new ResolvingAgent(new CompetitorResolver($pages)); + + // 29 = Красноярский край (конституционный порядок RussianRegions) + $res = $agent->resolve(new ResolveByNameRequest('КрасЛомбард', 29)); + + expect($res->candidates)->toHaveCount(1); + $c = $res->candidates[0]; + expect($c['name'])->toBe('КрасЛомбард'); + expect($c['site_url'])->toBe('http://kraslombard24.ru'); + expect($c['directory_urls'])->toContain('https://yandex.ru/maps/org/kraslombard/175852236692'); + expect($c['description'])->toContain('Ломбард'); + expect($c['is_federal'])->toBeFalse(); + expect($c['provenance']['via'])->toBe('name-search'); +}); + +it('resolveByName: имя не найдено → кандидат-заглушка «нет филиала» (без ссылок, не федерал)', function () { + $agent = new ResolvingAgent(new CompetitorResolver(stubPages([]))); + + $res = $agent->resolve(new ResolveByNameRequest('Неведомая Фирма', 29)); + + expect($res->candidates)->toHaveCount(1); + $c = $res->candidates[0]; + expect($c['name'])->toBe('Неведомая Фирма'); + expect($c['directory_urls'])->toBe([]); + expect($c['site_url'])->toBeNull(); + expect($c['is_federal'])->toBeFalse(); +}); From 25412bcbcea0c5b440f65369ba16609faab7fdec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 17:29:53 +0300 Subject: [PATCH 081/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=88=D0=B0=D0=B3=D0=B01=20=E2=80=94=20=D0=BF=D0=BE=D0=B4-?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BA=20A=20(=D1=80=D0=B5=D0=B7=D0=BE=D0=BB?= =?UTF-8?q?=D0=B2=D0=B5=D1=80)=20=D0=BE=D1=82=D0=BC=D0=B5=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D1=8B=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-30-step1-real-findcompetitors.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md b/docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md index a8d61e8c..7de705a1 100644 --- a/docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md +++ b/docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md @@ -42,7 +42,15 @@ AITUNNEL (эмбеддинги text-embedding-3-small), портал поста --- -## ПОД-БЛОК A — Резолвер (этот заход) +## ПОД-БЛОК A — Резолвер (этот заход) — ✅ ГОТОВ (gitea b2c89d12) + +> **Статус 30.06:** A1–A5 собраны по TDD на РЕАЛЬНЫХ фикстурах (карточки 2ГИС firm и Яндекс org +> сняты живым прогоном/Playwright). Тесты резолвера 17/17, модуль Автоподбора unit 75/75, Pint чисто. +> Классы: `ResolvedCompetitor` (A1), `TwoGisResolver` (A2), `YandexResolver` (A3), `CompetitorResolver` +> (A4, оркестратор+is_federal), `ResolvingAgent` (A5, `resolveByName` §7.2). Общее — `DirectoryFields` +> (имя+город из title, телефоны, свободное сравнение локаций). Провайдер НЕ флипнут (до H). +> **Следующий — под-блок E** (слияние+дедуп, чистая офлайн-логика). + **Граница транспорта:** переиспользуем существующий `PageFetcher` (шаг 2): `html(string $url): string`. Резолвер получает HTML карточки и парсит — без сети, фикстуры в тестах. From 63004449f1045c690c2c4108f7c4a7d31fe166e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 17:36:59 +0300 Subject: [PATCH 082/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=88=D0=B0=D0=B31=20E=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=B8=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D1=83=D1=80=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20(union-find=20+=20?= =?UTF-8?q?=D0=B2=D1=8B=D1=87=D0=B5=D1=82=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AutopodborDedup::mergeCompetitors — склейка одного конкурента из 3 каналов под разными написаниями, доменом-vs-именем или общим телефоном (union-find по корню имени/домена/номеру). Объединяет ссылки справочников и телефоны, is_federal — местная карточка перевешивает. Вычитает самого клиента (его имя/сайт не попадают в конкурентов). Усиливает §12.11. Размещено в существующем backend-дедупе (§7.2 «дедуп на стороне бэкенда»), а не дублем в движке. Старый dedupCompetitors и его тесты не тронуты. Нормалайзер: nameKey/domainRoot (alnum-ключ, ё→е) — имя «Драйв займ» сцепляется с «драйвзайм.рф». Тесты: merge 6/6; модуль Автоподбора unit 81/81; feature dedup 3/3; Pint чисто. Co-Authored-By: Claude Opus 4.8 --- .../Services/Autopodbor/AutopodborDedup.php | 178 ++++++++++++++++++ .../Autopodbor/AutopodborNormalizer.php | 44 ++++- .../Unit/Autopodbor/AutopodborMergeTest.php | 77 ++++++++ 3 files changed, 294 insertions(+), 5 deletions(-) create mode 100644 app/tests/Unit/Autopodbor/AutopodborMergeTest.php diff --git a/app/app/Services/Autopodbor/AutopodborDedup.php b/app/app/Services/Autopodbor/AutopodborDedup.php index ab58906d..0a71518a 100644 --- a/app/app/Services/Autopodbor/AutopodborDedup.php +++ b/app/app/Services/Autopodbor/AutopodborDedup.php @@ -76,4 +76,182 @@ final class AutopodborDedup return $out; } + + /** + * Сильное слияние конкурентов из 3 каналов (§12 движка v4): union-find по ЛЮБОМУ общему + * ключу — корню имени / корню домена / телефону. Так один конкурент под разными + * написаниями, доменом-vs-именем или общим номером схлопывается в одну карточку. + * Дополнительно вычитает самого клиента (его имя/сайт не должны попасть в конкуренты). + * + * Сильнее {@see dedupCompetitors} (одиночный ключ) — для финальной сборки findCompetitors. + * + * @param array,phones?:array}> $candidates + * @param list $clientKeys сырые идентификаторы клиента (имя и/или сайт) — для вычета себя + * @return array + */ + public function mergeCompetitors(array $candidates, array $clientKeys = []): array + { + $candidates = array_values($candidates); + $n = count($candidates); + if ($n === 0) { + return []; + } + + $keysOf = []; + foreach ($candidates as $i => $c) { + $keysOf[$i] = $this->candidateKeys($c); + } + + // union-find: общий ключ → одна группа + $parent = range(0, $n - 1); + $find = function (int $x) use (&$parent): int { + while ($parent[$x] !== $x) { + $parent[$x] = $parent[$parent[$x]]; + $x = $parent[$x]; + } + + return $x; + }; + $keyToIdx = []; + foreach ($keysOf as $i => $keys) { + foreach ($keys as $k) { + if (isset($keyToIdx[$k])) { + $a = $find($i); + $b = $find($keyToIdx[$k]); + if ($a !== $b) { + $parent[$a] = $b; + } + } else { + $keyToIdx[$k] = $i; + } + } + } + + // ключи клиента (для вычета самого себя) + $client = []; + foreach ($clientKeys as $ck) { + foreach ($this->candidateKeys(['name' => $ck, 'site_url' => $ck]) as $k) { + $client[$k] = true; + } + } + + $groups = []; + foreach ($candidates as $i => $c) { + $groups[$find($i)][] = $i; + } + + $out = []; + foreach ($groups as $members) { + // если любая часть группы — это сам клиент, выкидываем всю группу + $isClient = false; + foreach ($members as $i) { + foreach ($keysOf[$i] as $k) { + if (isset($client[$k])) { + $isClient = true; + break 2; + } + } + } + if ($isClient) { + continue; + } + + $out[] = $this->mergeGroup(array_map(fn ($i) => $candidates[$i], $members)); + } + + return array_values($out); + } + + /** + * Ключи кандидата для union-find: корень имени и корень домена под общим префиксом + * (чтобы имя сцеплялось с доменом), телефоны — под отдельным. + * + * @param array{name?:string,site_url?:?string,phones?:array} $c + * @return list + */ + private function candidateKeys(array $c): array + { + $keys = []; + $name = isset($c['name']) ? (string) $c['name'] : ''; + if ($name !== '') { + $nk = $this->norm->nameKey($name); + if ($nk !== '') { + $keys[] = 'k:'.$nk; + } + } + if (! empty($c['site_url'])) { + $dr = $this->norm->domainRoot((string) $c['site_url']); + if ($dr !== '') { + $keys[] = 'k:'.$dr; + } + } + foreach ($c['phones'] ?? [] as $p) { + $pp = $this->norm->phone((string) $p); + if ($pp !== '') { + $keys[] = 'p:'.$pp; + } + } + + return array_values(array_unique($keys)); + } + + /** + * Сливает группу совпавших кандидатов в одну карточку: имя/сайт/описание — первое непустое, + * ссылки справочников и телефоны — объединением, is_federal — местная карточка перевешивает. + * + * @param array $group + */ + private function mergeGroup(array $group): array + { + $name = null; + $site = null; + $desc = null; + $isFederal = true; + $hasFederalFlag = false; + $dirs = []; + $phones = []; + + foreach ($group as $c) { + if ($name === null && ! empty($c['name'])) { + $name = $c['name']; + } + if ($site === null && ! empty($c['site_url'])) { + $site = $c['site_url']; + } + if ($desc === null && ! empty($c['description'])) { + $desc = $c['description']; + } + if (array_key_exists('is_federal', $c)) { + $hasFederalFlag = true; + if (! $c['is_federal']) { + $isFederal = false; // нашлась местная карточка — группа местная + } + } + foreach ($c['directory_urls'] ?? [] as $d) { + if (! in_array($d, $dirs, true)) { + $dirs[] = $d; + } + } + foreach ($c['phones'] ?? [] as $p) { + if (! in_array($p, $phones, true)) { + $phones[] = $p; + } + } + } + + $merged = [ + 'name' => $name ?? '', + 'site_url' => $site, + 'directory_urls' => $dirs, + 'phones' => $phones, + ]; + if ($desc !== null) { + $merged['description'] = $desc; + } + if ($hasFederalFlag) { + $merged['is_federal'] = $isFederal; + } + + return $merged; + } } diff --git a/app/app/Services/Autopodbor/AutopodborNormalizer.php b/app/app/Services/Autopodbor/AutopodborNormalizer.php index 7d9cb9e4..542d66b1 100644 --- a/app/app/Services/Autopodbor/AutopodborNormalizer.php +++ b/app/app/Services/Autopodbor/AutopodborNormalizer.php @@ -46,11 +46,12 @@ final class AutopodborNormalizer // Fallback: оставить только цифры и привести к 7xxxxxxxxxx $digits = preg_replace('/\D+/', '', $raw) ?? ''; if (strlen($digits) === 11 && ($digits[0] === '8' || $digits[0] === '7')) { - return '7' . substr($digits, 1); + return '7'.substr($digits, 1); } if (strlen($digits) === 10) { - return '7' . $digits; + return '7'.$digits; } + return $digits; } @@ -68,7 +69,7 @@ final class AutopodborNormalizer ? $this->phone($identifier) : $this->domainHead($identifier); - return $type . ':' . $id; + return $type.':'.$id; } /** @@ -94,12 +95,45 @@ final class AutopodborNormalizer public function competitorKey(string $name, ?string $siteUrl): string { if ($siteUrl !== null) { - return 'site:' . $this->domainHead($siteUrl); + return 'site:'.$this->domainHead($siteUrl); } // Нижний регистр + схлопываем пробелы $normalized = preg_replace('#\s+#u', ' ', trim(mb_strtolower($name))); - return 'name:' . $normalized; + return 'name:'.$normalized; + } + + /** + * Сжатый ключ имени для union-find слияния: нижний регистр, ё→е, только буквы/цифры. + * Намеренно совпадает по форме с {@see domainRoot}, чтобы «Драйв займ» (имя) сцепился + * с «драйвзайм.рф» (корень домена). Примеры: «Драйв займ» → «драйвзайм»; «ОКНА-КОМФОРТ» → «окнакомфорт». + */ + public function nameKey(string $name): string + { + return $this->alnumKey($name); + } + + /** + * Корень домена для union-find: «голова» домена без TLD, только буквы/цифры. + * Примеры: «драйвзайм.рф» → «драйвзайм»; «https://okna-komfort.ru/contacts» → «окнакомфорт» (лат.). + */ + public function domainRoot(string $site): string + { + $host = $this->domainHead($site); + $parts = explode('.', $host); + if (count($parts) > 1) { + array_pop($parts); // срезаем зону (.ru/.рф/...) + } + + return $this->alnumKey(implode('.', $parts)); + } + + /** Нижний регистр + ё→е + только буквы/цифры (Unicode). */ + private function alnumKey(string $s): string + { + $s = str_replace('ё', 'е', mb_strtolower(trim($s))); + + return preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? ''; } } diff --git a/app/tests/Unit/Autopodbor/AutopodborMergeTest.php b/app/tests/Unit/Autopodbor/AutopodborMergeTest.php new file mode 100644 index 00000000..d821d85d --- /dev/null +++ b/app/tests/Unit/Autopodbor/AutopodborMergeTest.php @@ -0,0 +1,77 @@ +mergeCompetitors([ + ['name' => 'Драйв займ'], // только имя (из канала В) + ['name' => 'Драйв Займ', 'site_url' => 'https://драйвзайм.рф/'], // имя + домен (из канала А) + ]); + + // имя «драйвзайм» = корень домена «драйвзайм» → одна карточка + expect($merged)->toHaveCount(1); + expect($merged[0]['site_url'])->toBe('https://драйвзайм.рф/'); +}); + +it('склеивает по общему домену при разном написании имени', function () { + $merged = mergeService()->mergeCompetitors([ + ['name' => 'Окна Комфорт', 'site_url' => 'https://okna-komfort.ru/contacts'], + ['name' => 'ОКНА-КОМФОРТ', 'site_url' => 'okna-komfort.ru'], + ]); + + expect($merged)->toHaveCount(1); +}); + +it('склеивает по общему телефону', function () { + $merged = mergeService()->mergeCompetitors([ + ['name' => 'Фирма А', 'phones' => ['73912920000']], + ['name' => 'Фирма Б', 'phones' => ['73912920000', '79991112233']], + ]); + + expect($merged)->toHaveCount(1); + expect($merged[0]['phones'])->toContain('73912920000')->toContain('79991112233'); +}); + +it('объединяет ссылки справочников и телефоны внутри группы', function () { + $merged = mergeService()->mergeCompetitors([ + ['name' => 'X', 'site_url' => 'x.ru', 'directory_urls' => ['https://2gis.ru/firm/1'], 'phones' => ['71112223344']], + ['name' => 'X', 'site_url' => 'https://x.ru/', 'directory_urls' => ['https://yandex.ru/maps/org/x/2'], 'phones' => ['75556667788']], + ]); + + expect($merged)->toHaveCount(1); + expect($merged[0]['directory_urls'])->toContain('https://2gis.ru/firm/1')->toContain('https://yandex.ru/maps/org/x/2'); + expect($merged[0]['phones'])->toContain('71112223344')->toContain('75556667788'); +}); + +it('не склеивает разных конкурентов', function () { + $merged = mergeService()->mergeCompetitors([ + ['name' => 'Окна Комфорт', 'site_url' => 'okna-komfort.ru'], + ['name' => 'Пластика Окон', 'site_url' => 'plastika.ru'], + ]); + + expect($merged)->toHaveCount(2); +}); + +it('вычитает самого клиента (по домену и по имени)', function () { + $merged = mergeService()->mergeCompetitors( + [ + ['name' => 'Мой Бизнес', 'site_url' => 'moy-biznes.ru'], // это сам клиент по домену + ['name' => 'Драйв займ', 'site_url' => 'драйвзайм.рф'], + ['name' => 'Мой Бизнес Плюс'], // сам клиент по имени + ], + clientKeys: ['https://moy-biznes.ru', 'Мой Бизнес Плюс'], + ); + + $names = array_column($merged, 'name'); + expect($names)->not->toContain('Мой Бизнес'); + expect($names)->not->toContain('Мой Бизнес Плюс'); + expect($names)->toContain('Драйв займ'); +}); From 44a338ed2ef77f9927d135be688d90282fac0d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 17:40:10 +0300 Subject: [PATCH 083/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=88=D0=B0=D0=B31=20F=20(?= =?UTF-8?q?=D0=BE=D1=84=D0=BB=D0=B0=D0=B9=D0=BD)=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=85=D0=BE=D0=B6=D0=B5=D1=81=D1=82=D1=8C=20=D1=8D=D0=BC?= =?UTF-8?q?=D0=B1=D0=B5=D0=B4=D0=B4=D0=B8=D0=BD=D0=B3=D0=B0=D0=BC=D0=B8=20?= =?UTF-8?q?(=D0=BA=D0=BE=D1=81=D0=B8=D0=BD=D1=83=D1=81),=20=D0=BD=D0=B5=20?= =?UTF-8?q?=C2=AB=D0=BC=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EmbeddingRelevance: профиль клиента (примеры имя+описание) → центроид; кандидат (имя+описание) → косинус → relevance_pct [0..100]; сортировка по убыванию (§12.5). Векторы — за границей Embedder (живой AITUNNEL text-embedding-3-small подключается на блоке H/живом прогоне, ключ по §12.9). Вся логика ранжирования протестирована офлайн на детерминированном эмбеддере. Тесты: similarity 3/3; модуль Автоподбора unit 84/84; Pint чисто. Co-Authored-By: Claude Opus 4.8 --- .../Autopodbor/Agent/Similarity/Embedder.php | 20 ++++ .../Agent/Similarity/EmbeddingRelevance.php | 108 ++++++++++++++++++ .../Similarity/EmbeddingRelevanceTest.php | 74 ++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Similarity/Embedder.php create mode 100644 app/app/Services/Autopodbor/Agent/Similarity/EmbeddingRelevance.php create mode 100644 app/tests/Unit/Autopodbor/Similarity/EmbeddingRelevanceTest.php diff --git a/app/app/Services/Autopodbor/Agent/Similarity/Embedder.php b/app/app/Services/Autopodbor/Agent/Similarity/Embedder.php new file mode 100644 index 00000000..34608e88 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Similarity/Embedder.php @@ -0,0 +1,20 @@ + $texts + * @return list> + */ + public function embed(array $texts): array; +} diff --git a/app/app/Services/Autopodbor/Agent/Similarity/EmbeddingRelevance.php b/app/app/Services/Autopodbor/Agent/Similarity/EmbeddingRelevance.php new file mode 100644 index 00000000..df2ab218 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Similarity/EmbeddingRelevance.php @@ -0,0 +1,108 @@ + $clientExamples тексты-примеры клиента (имя+описание) + * @param array $candidates + * @return array кандидаты с relevance_pct, отсортированы по убыванию + */ + public function rank(array $clientExamples, array $candidates): array + { + if ($candidates === []) { + return []; + } + + $centroid = $clientExamples === [] + ? [] + : $this->centroid($this->embedder->embed(array_values($clientExamples))); + + $candTexts = array_map(fn (array $c): string => $this->text($c), $candidates); + $candVecs = $this->embedder->embed(array_values($candTexts)); + + $scored = []; + foreach (array_values($candidates) as $i => $c) { + $cos = $centroid === [] ? 0.0 : $this->cosine($candVecs[$i], $centroid); + $c['relevance_pct'] = (int) round(max(0.0, min(1.0, $cos)) * 100); + $scored[] = $c; + } + + // стабильная сортировка по убыванию похожести (исходный порядок при равенстве) + usort($scored, fn (array $a, array $b): int => $b['relevance_pct'] <=> $a['relevance_pct']); + + return $scored; + } + + /** Текст кандидата для эмбеддинга: имя + описание. */ + private function text(array $c): string + { + return trim((string) ($c['name'] ?? '').' '.(string) ($c['description'] ?? '')); + } + + /** + * Покомпонентный центроид (среднее) набора векторов. + * + * @param list> $vectors + * @return list + */ + private function centroid(array $vectors): array + { + $vectors = array_values(array_filter($vectors, fn (array $v): bool => $v !== [])); + if ($vectors === []) { + return []; + } + $dim = count($vectors[0]); + $sum = array_fill(0, $dim, 0.0); + foreach ($vectors as $v) { + for ($j = 0; $j < $dim; $j++) { + $sum[$j] += (float) ($v[$j] ?? 0.0); + } + } + $n = count($vectors); + + return array_map(fn (float $x): float => $x / $n, $sum); + } + + /** + * Косинусная близость двух векторов (0, если любой нулевой/пустой). + * + * @param list $a + * @param list $b + */ + private function cosine(array $a, array $b): float + { + $dim = min(count($a), count($b)); + if ($dim === 0) { + return 0.0; + } + $dot = 0.0; + $na = 0.0; + $nb = 0.0; + for ($i = 0; $i < $dim; $i++) { + $x = (float) $a[$i]; + $y = (float) $b[$i]; + $dot += $x * $y; + $na += $x * $x; + $nb += $y * $y; + } + if ($na <= 0.0 || $nb <= 0.0) { + return 0.0; + } + + return $dot / (sqrt($na) * sqrt($nb)); + } +} diff --git a/app/tests/Unit/Autopodbor/Similarity/EmbeddingRelevanceTest.php b/app/tests/Unit/Autopodbor/Similarity/EmbeddingRelevanceTest.php new file mode 100644 index 00000000..5b5e2802 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Similarity/EmbeddingRelevanceTest.php @@ -0,0 +1,74 @@ +> $map */ + public function __construct(private array $map) {} + + public function embed(array $texts): array + { + return array_map(function (string $t): array { + foreach ($this->map as $needle => $vec) { + if (str_contains($t, $needle)) { + return $vec; + } + } + + return [0.0, 0.0, 0.0]; // нет совпадения — нулевая похожесть + }, $texts); + } + }; +} + +it('ранжирует кандидатов по близости к профилю клиента (косинус)', function () { + // профиль клиента — «автоломбард / займ под залог авто» + $embedder = fakeEmbedder([ + 'залог авто' => [1.0, 0.0, 0.0], // клиентский профиль и близкие к нему + 'автозайм' => [0.9, 0.1, 0.0], + 'пластиковые окна' => [0.0, 1.0, 0.0], // совсем другое + ]); + $rel = new EmbeddingRelevance($embedder); + + $ranked = $rel->rank( + clientExamples: ['Автоломбард Клиента залог авто'], + candidates: [ + ['name' => 'Окна Комфорт', 'description' => 'пластиковые окна и балконы'], + ['name' => 'АвтоДеньги', 'description' => 'автозайм под ПТС'], + ['name' => 'ЗалогЦентр', 'description' => 'займ под залог авто'], + ], + ); + + // ближайший — «займ под залог авто» (точное совпадение профиля), затем «автозайм», окна — последними + expect(array_column($ranked, 'name'))->toBe(['ЗалогЦентр', 'АвтоДеньги', 'Окна Комфорт']); + expect($ranked[0]['relevance_pct'])->toBe(100); + expect($ranked[2]['relevance_pct'])->toBe(0); + foreach ($ranked as $c) { + expect($c['relevance_pct'])->toBeGreaterThanOrEqual(0)->toBeLessThanOrEqual(100); + } +}); + +it('без профиля клиента похожесть 0, порядок сохраняется', function () { + $rel = new EmbeddingRelevance(fakeEmbedder([])); + + $ranked = $rel->rank(clientExamples: [], candidates: [ + ['name' => 'А', 'description' => 'x'], + ['name' => 'Б', 'description' => 'y'], + ]); + + expect($ranked)->toHaveCount(2); + expect($ranked[0]['relevance_pct'])->toBe(0); + expect($ranked[1]['relevance_pct'])->toBe(0); +}); + +it('пустой список кандидатов → пустой результат', function () { + $rel = new EmbeddingRelevance(fakeEmbedder([])); + expect($rel->rank(['что-то'], []))->toBe([]); +}); From d846aaf9178e9d662a3911744abf85b05933dd71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 17:46:44 +0300 Subject: [PATCH 084/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=88=D0=B0=D0=B31=20B=20(?= =?UTF-8?q?=D0=BE=D1=84=D0=BB=D0=B0=D0=B9=D0=BD)=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D1=81=D0=B5=D1=80=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B8=202=D0=93=D0=98=D0=A1/=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=20(=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=20=D0=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SearchResultsParser: страница категория-поиска → список фирм/организаций {ссылка, имя-подсказка}. 2ГИС — путь /<город>/firm/ + имя из вложенного span; Яндекс — /maps/org// + aria-label. Дедуп по ссылке. Авторитетное имя/поля даёт резолвер (под-блок A), открывая каждую карточку. Пагинация/мульти-запрос — живая часть (блок H/живой прогон). Фикстуры — реальные сниппеты выдачи (Красломбард, Красноярск): 2ГИС через xfetch, Яндекс локальным Playwright. Тесты: search 3/3; модуль Автоподбора unit 87/87; Pint чисто. Co-Authored-By: Claude Opus 4.8 --- .../Agent/Search/SearchResultsParser.php | 64 +++++++++++++++++++ .../Search/SearchResultsParserTest.php | 39 +++++++++++ .../autopodbor/2gis-search-kraslombard.html | 7 ++ .../autopodbor/yandex-search-kraslombard.html | 8 +++ 4 files changed, 118 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Search/SearchResultsParser.php create mode 100644 app/tests/Unit/Autopodbor/Search/SearchResultsParserTest.php create mode 100644 app/tests/fixtures/autopodbor/2gis-search-kraslombard.html create mode 100644 app/tests/fixtures/autopodbor/yandex-search-kraslombard.html diff --git a/app/app/Services/Autopodbor/Agent/Search/SearchResultsParser.php b/app/app/Services/Autopodbor/Agent/Search/SearchResultsParser.php new file mode 100644 index 00000000..e0919cd2 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Search/SearchResultsParser.php @@ -0,0 +1,64 @@ +/firm/ + имя из вложенного . + * Дедуп по пути (одна фирма — один раз). + * + * @return list + */ + public function twoGis(string $html): array + { + $out = []; + $seen = []; + if (preg_match_all('#]*>(.*?)#is', $html, $m, PREG_SET_ORDER)) { + foreach ($m as $hit) { + $path = $hit[1]; + if (isset($seen[$path])) { + continue; + } + $seen[$path] = true; + $name = preg_match('#([^<]{2,})#u', $hit[2], $nm) + ? trim(html_entity_decode($nm[1], ENT_QUOTES | ENT_HTML5, 'UTF-8')) + : null; + $out[] = ['path' => $path, 'name' => $name !== '' ? $name : null]; + } + } + + return $out; + } + + /** + * Организации из выдачи Яндекс.Карт: прямая ссылка /maps/org// + имя из aria-label. + * Дедуп по ссылке. + * + * @return list + */ + public function yandex(string $html): array + { + $out = []; + $seen = []; + if (preg_match_all('#class="link-overlay" href="(/maps/org/[a-z0-9_-]+/\d+)/?"[^>]*aria-label="([^"]+)"#i', $html, $m, PREG_SET_ORDER)) { + foreach ($m as $hit) { + $url = $hit[1]; + if (isset($seen[$url])) { + continue; + } + $seen[$url] = true; + $out[] = ['url' => $url, 'name' => trim(html_entity_decode($hit[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'))]; + } + } + + return $out; + } +} diff --git a/app/tests/Unit/Autopodbor/Search/SearchResultsParserTest.php b/app/tests/Unit/Autopodbor/Search/SearchResultsParserTest.php new file mode 100644 index 00000000..7f1c2c22 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Search/SearchResultsParserTest.php @@ -0,0 +1,39 @@ +twoGis(autopodborFixture('2gis-search-kraslombard.html')); + + expect(count($items))->toBe(4); + foreach ($items as $it) { + expect($it['path'])->toMatch('#^/[a-z0-9_-]+/firm/\d+$#'); + expect($it['name'])->toBe('КрасЛомбард'); + } + // ссылки уникальны + $paths = array_column($items, 'path'); + expect($paths)->toBe(array_values(array_unique($paths))); +}); + +it('вытаскивает организации из реальной выдачи Яндекса (ссылка + имя)', function () { + $items = (new SearchResultsParser)->yandex(autopodborFixture('yandex-search-kraslombard.html')); + + expect(count($items))->toBe(5); + $names = array_column($items, 'name'); + expect($names)->toContain('КрасЛомбард')->toContain('Сибломбард')->toContain('Енисейская скупка золота'); + foreach ($items as $it) { + expect($it['url'])->toMatch('#^/maps/org/[a-z0-9_-]+/\d+$#'); + } + $urls = array_column($items, 'url'); + expect($urls)->toBe(array_values(array_unique($urls))); +}); + +it('пустая/неподходящая страница → пустой список', function () { + $p = new SearchResultsParser; + expect($p->twoGis('ничего'))->toBe([]); + expect($p->yandex('ничего'))->toBe([]); +}); diff --git a/app/tests/fixtures/autopodbor/2gis-search-kraslombard.html b/app/tests/fixtures/autopodbor/2gis-search-kraslombard.html new file mode 100644 index 00000000..5bae1843 --- /dev/null +++ b/app/tests/fixtures/autopodbor/2gis-search-kraslombard.html @@ -0,0 +1,7 @@ +Красломбард в Красноярске — 2ГИС + +КрасЛомбард  +КрасЛомбард  +КрасЛомбард  +КрасЛомбард  + diff --git a/app/tests/fixtures/autopodbor/yandex-search-kraslombard.html b/app/tests/fixtures/autopodbor/yandex-search-kraslombard.html new file mode 100644 index 00000000..20cc9f95 --- /dev/null +++ b/app/tests/fixtures/autopodbor/yandex-search-kraslombard.html @@ -0,0 +1,8 @@ +КрасЛомбард в Красноярске — Яндекс Карты + +КрасЛомбард +КрасЛомбард +Сибломбард +Енисейская скупка золота +Региональная скупка золота + From f9253d44d2d45bc78df902479095902d8c0e1ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 17:47:30 +0300 Subject: [PATCH 085/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=88=D0=B0=D0=B3=D0=B01=20=E2=80=94=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D1=80=D0=B5=D1=81=D1=81=20A/E/F/B=20=D0=B3=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B,=20=D0=BE=D1=81=D1=82=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=D1=81=D1=8C=20=D0=B6=D0=B8=D0=B2=D0=BE=D0=B5=20C/D/G/H?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-30-step1-real-findcompetitors.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md b/docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md index 7de705a1..6502767d 100644 --- a/docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md +++ b/docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md @@ -40,6 +40,16 @@ AITUNNEL (эмбеддинги text-embedding-3-small), портал поста **Порядок:** A (фундамент, нужен всем) → E+F (слияние/похожесть, чистая логика) → B → C/D → G → H. Живые блоки (C/D/F/G) — после готовности их ключей и отдельного «го» владельца. +> **ПРОГРЕСС 30.06 (все офлайн-блоки готовы, на gitea):** +> - ✅ **A** Резолвер (A1–A5) — `b2c89d12` +> - ✅ **E** Слияние+дедуп union-find + вычет клиента — `64d6703b` +> - ✅ **F (офлайн)** Похожесть-эмбеддинги (косинус/ранжирование за границей `Embedder`) — `b900874a` +> - ✅ **B (офлайн)** Парсер выдачи 2ГИС/Яндекс (`SearchResultsParser`) — `0d5e06e8` +> - 🔴 **Осталось — ЖИВЫЕ блоки (нужны ключи + «го» владельца):** C (LLM-имена+фильтр агрегаторов), +> D (DaData-телефоны), G (портал поставщика lkomega), живые реализации `Embedder`/пагинации, +> **H** (оркестрация `RealCompetitorAgent.findCompetitors` + флип провайдера за фиче-флагом). +> Тесты модуля Автоподбора: **87/87** unit, Pint чисто. + --- ## ПОД-БЛОК A — Резолвер (этот заход) — ✅ ГОТОВ (gitea b2c89d12) From 6850050f5232cc0b11a2e40a96e9944f15dad3a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 17:57:22 +0300 Subject: [PATCH 086/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=88=D0=B0=D0=B31=20D=20?= =?UTF-8?q?=E2=80=94=20=D0=BE=D0=B1=D0=BE=D0=B3=D0=B0=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=82=D0=B5=D0=BB=D0=B5=D1=84=D0=BE=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20DaData=20(=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D1=83=D0=B5=D1=82=20=D0=B6=D0=B8=D0=B2=D0=BE=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=BD=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompetitorPhoneEnricher: телефоны карточки → тип (city/mobile/tollfree) + регион + годность (qc=0) через существующий DaDataPhoneClient (тот же, что резолв региона лида — §12.4, не дубль). DaData недоступна → деградация: тип по префиксу (PhoneType), регион пуст, негоден — подбор не падает. Номера не синтезируются, только классифицируются (берём опубликованные фирмой). Ключ DaData — в config services.dadata (env, прод). Тест офлайн через Http::fake (реальная форма ответа). Тесты: phone 4/4; модуль Автоподбора unit 91/91; Pint чисто. Co-Authored-By: Claude Opus 4.8 --- .../Agent/Phone/CompetitorPhoneEnricher.php | 72 +++++++++++++++++++ .../Phone/CompetitorPhoneEnricherTest.php | 55 ++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Phone/CompetitorPhoneEnricher.php create mode 100644 app/tests/Unit/Autopodbor/Phone/CompetitorPhoneEnricherTest.php diff --git a/app/app/Services/Autopodbor/Agent/Phone/CompetitorPhoneEnricher.php b/app/app/Services/Autopodbor/Agent/Phone/CompetitorPhoneEnricher.php new file mode 100644 index 00000000..c217b5e3 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Phone/CompetitorPhoneEnricher.php @@ -0,0 +1,72 @@ + $phones номера 7XXXXXXXXXX (из карточки конкурента) + * @return list + */ + public function enrich(array $phones): array + { + $out = []; + foreach ($phones as $phone) { + try { + $r = $this->client->cleanPhone($phone); + $out[] = [ + 'phone' => $phone, + 'type' => $this->slug($r->type, $phone), + 'region' => $r->region, + 'valid' => $r->qc === 0, + ]; + } catch (DaDataException) { + // DaData недоступна/ошибка — деградируем, не роняя весь подбор. + $out[] = [ + 'phone' => $phone, + 'type' => PhoneType::of($phone), + 'region' => null, + 'valid' => false, + ]; + } + } + + return $out; + } + + /** Тип DaData (рус.) → slug city/mobile/tollfree; неизвестный — по префиксу номера. */ + private function slug(?string $type, string $phone): string + { + $t = mb_strtolower(trim((string) $type)); + if ($t === '') { + return PhoneType::of($phone); + } + if (str_contains($t, 'мобильн')) { + return 'mobile'; + } + if (str_contains($t, 'стационар') || str_contains($t, 'городск')) { + return 'city'; + } + if (str_contains($t, 'бесплатн') || str_contains($t, 'колл')) { + return 'tollfree'; + } + + return PhoneType::of($phone); + } +} diff --git a/app/tests/Unit/Autopodbor/Phone/CompetitorPhoneEnricherTest.php b/app/tests/Unit/Autopodbor/Phone/CompetitorPhoneEnricherTest.php new file mode 100644 index 00000000..94e29244 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Phone/CompetitorPhoneEnricherTest.php @@ -0,0 +1,55 @@ + Http::response([[ + 'qc' => 0, 'type' => 'Мобильный', 'phone' => '+7 921 555-12-34', + 'provider' => 'МегаФон', 'region' => 'Санкт-Петербург и область', + ]], 200)]); + + $out = app(CompetitorPhoneEnricher::class)->enrich(['79215551234']); + + expect($out)->toHaveCount(1); + expect($out[0])->toMatchArray([ + 'phone' => '79215551234', + 'type' => 'mobile', + 'region' => 'Санкт-Петербург и область', + 'valid' => true, + ]); +}); + +it('qc!=0 (неоднозначный номер) → негоден', function () { + Http::fake(['cleaner.dadata.ru/*' => Http::response([[ + 'qc' => 3, 'type' => 'Мобильный', 'region' => 'Москва', + ]], 200)]); + + $out = app(CompetitorPhoneEnricher::class)->enrich(['79991234567']); + + expect($out[0]['valid'])->toBeFalse(); +}); + +it('тип 8-800 из DaData → tollfree', function () { + Http::fake(['cleaner.dadata.ru/*' => Http::response([[ + 'qc' => 0, 'type' => 'Бесплатный', 'region' => 'Россия', + ]], 200)]); + + $out = app(CompetitorPhoneEnricher::class)->enrich(['78001234567']); + + expect($out[0]['type'])->toBe('tollfree'); + expect($out[0]['valid'])->toBeTrue(); +}); + +it('DaData недоступна → не роняем подбор: тип по префиксу, регион пуст, негоден', function () { + Http::fake(fn () => throw new ConnectionException('timeout')); + + $out = app(CompetitorPhoneEnricher::class)->enrich(['78001234567', '79991112233']); + + expect($out)->toHaveCount(2); + expect($out[0])->toMatchArray(['phone' => '78001234567', 'type' => 'tollfree', 'region' => null, 'valid' => false]); + expect($out[1])->toMatchArray(['phone' => '79991112233', 'type' => 'mobile', 'region' => null, 'valid' => false]); +}); From bf58406a307160305417249d1868b6e2095c7d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 17:59:47 +0300 Subject: [PATCH 087/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=88=D0=B0=D0=B31=20C=20?= =?UTF-8?q?=E2=80=94=20=D0=BE=D1=82=D1=81=D0=B5=D0=B2=20=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D0=B5=D0=B3=D0=B0=D1=82=D0=BE=D1=80=D0=BE=D0=B2=20LLM-=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D1=81=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=BC=20(=D0=BD=D0=B5=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D0=BA-=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AggregatorFilter + граница AggregatorClassifier (§12.6): «поставщик услуги или площадка?» — LLM-рассуждением (за границей), а не мёртвым статик-списком. Консервативно: выкидываем только при уверенном «агрегатор»; при неуверенности (null) конкурента не теряем. Тесты: aggregator 3/3; Pint чисто. (живой LLM-классификатор подключается на H/живом прогоне.) Co-Authored-By: Claude Opus 4.8 --- .../Agent/Aggregator/AggregatorClassifier.php | 19 +++++++ .../Agent/Aggregator/AggregatorFilter.php | 37 ++++++++++++++ .../Aggregator/AggregatorFilterTest.php | 49 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Aggregator/AggregatorClassifier.php create mode 100644 app/app/Services/Autopodbor/Agent/Aggregator/AggregatorFilter.php create mode 100644 app/tests/Unit/Autopodbor/Aggregator/AggregatorFilterTest.php diff --git a/app/app/Services/Autopodbor/Agent/Aggregator/AggregatorClassifier.php b/app/app/Services/Autopodbor/Agent/Aggregator/AggregatorClassifier.php new file mode 100644 index 00000000..a13f8d1e --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Aggregator/AggregatorClassifier.php @@ -0,0 +1,19 @@ + $candidates + * @return array + */ + public function filter(array $candidates): array + { + $out = []; + foreach ($candidates as $c) { + $verdict = $this->classifier->isAggregator( + (string) ($c['name'] ?? ''), + $c['site_url'] ?? null, + $c['description'] ?? null, + ); + if ($verdict === true) { + continue; // площадка — не конкурент + } + $out[] = $c; + } + + return array_values($out); + } +} diff --git a/app/tests/Unit/Autopodbor/Aggregator/AggregatorFilterTest.php b/app/tests/Unit/Autopodbor/Aggregator/AggregatorFilterTest.php new file mode 100644 index 00000000..9698f78f --- /dev/null +++ b/app/tests/Unit/Autopodbor/Aggregator/AggregatorFilterTest.php @@ -0,0 +1,49 @@ + $map */ + public function __construct(private array $map) {} + + public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool + { + return $this->map[$name] ?? null; + } + }; +} + +it('выкидывает площадки-агрегаторы, оставляет поставщиков услуги', function () { + $classifier = fakeClassifier([ + 'Авито' => true, // площадка + 'Банки.ру' => true, // площадка + 'АвтоДеньги' => false, // поставщик + ]); + $filtered = (new AggregatorFilter($classifier))->filter([ + ['name' => 'Авито', 'site_url' => 'avito.ru'], + ['name' => 'АвтоДеньги', 'site_url' => 'avtodengi.ru'], + ['name' => 'Банки.ру', 'site_url' => 'banki.ru'], + ]); + + expect(array_column($filtered, 'name'))->toBe(['АвтоДеньги']); +}); + +it('при неуверенности классификатора (null) конкурента НЕ теряем (консервативно)', function () { + $classifier = fakeClassifier([]); // на всё отвечает null + $filtered = (new AggregatorFilter($classifier))->filter([ + ['name' => 'Непонятная Фирма', 'site_url' => 'x.ru'], + ]); + + expect(array_column($filtered, 'name'))->toBe(['Непонятная Фирма']); +}); + +it('пустой вход → пустой выход', function () { + expect((new AggregatorFilter(fakeClassifier([])))->filter([]))->toBe([]); +}); From fc4c8eb758c8510d01d2d89b81c99289a390170a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 18:03:25 +0300 Subject: [PATCH 088/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=88=D0=B0=D0=B31=20H-?= =?UTF-8?q?=D1=8F=D0=B4=D1=80=D0=BE=20=E2=80=94=20=D1=81=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20findCompetitors=20(=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=E2=86=92=D1=81=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=E2=86=92=D0=BF=D0=BE=D1=85=D0=BE=D0=B6=D0=B5=D1=81=D1=82=D1=8C?= =?UTF-8?q?=E2=86=92DTO)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FindCompetitorsAssembler: резолвленные кандидаты каналов → отсев агрегаторов (C) → слияние+дедуп+вычет клиента (E) → отсев федералов если не нужны → похожесть-эмбеддинги (F) → срез top-N → DTO FindCompetitorsResult (§7.2). Чистая сборка — всё ядро движка v4 складывается вместе и протестировано офлайн на реальных сервисах + фейках границ. НЕ флипает провайдер: добыча страниц/имён каналов (живые A-fetch/B/0) и включение боевого движка за флагом — отдельный шаг (нужно «го» + живой прогон). Тесты: assembler 2/2; модуль Автоподбора unit 96/96; Pint чисто. Co-Authored-By: Claude Opus 4.8 --- .../Agent/FindCompetitorsAssembler.php | 71 ++++++++++++ .../FindCompetitorsAssemblerTest.php | 109 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php create mode 100644 app/tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php diff --git a/app/app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php b/app/app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php new file mode 100644 index 00000000..cb689937 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php @@ -0,0 +1,71 @@ + $candidates резолвленные кандидаты (имя/сайт/описание/телефоны/ссылки/is_federal) + * @param list $clientExamples тексты-примеры клиента (для похожести) + * @param list $clientKeys имя/сайт клиента (для вычета себя) + */ + public function assemble( + array $candidates, + array $clientExamples, + array $clientKeys, + bool $includeFederal, + int $maxCompetitors, + ): FindCompetitorsResult { + $filtered = $this->aggregatorFilter->filter($candidates); + $merged = $this->dedup->mergeCompetitors($filtered, $clientKeys); + + if (! $includeFederal) { + $merged = array_values(array_filter($merged, fn (array $c): bool => empty($c['is_federal']))); + } + + $ranked = $this->relevance->rank($clientExamples, $merged); + + if ($maxCompetitors > 0) { + $ranked = array_slice($ranked, 0, $maxCompetitors); + } + + return new FindCompetitorsResult(array_map(fn (array $c): array => $this->toCompetitor($c), $ranked)); + } + + /** + * Карточка ядра → конкурент §7.2 {name, description, is_federal, relevance_pct, site_url, directory_urls, provenance}. + */ + private function toCompetitor(array $c): array + { + return [ + 'name' => (string) ($c['name'] ?? ''), + 'description' => $c['description'] ?? null, + 'is_federal' => (bool) ($c['is_federal'] ?? false), + 'relevance_pct' => $c['relevance_pct'] ?? null, + 'site_url' => $c['site_url'] ?? null, + 'directory_urls' => $c['directory_urls'] ?? [], + 'provenance' => $c['provenance'] ?? ['via' => 'engine'], + ]; + } +} diff --git a/app/tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php b/app/tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php new file mode 100644 index 00000000..17e8d648 --- /dev/null +++ b/app/tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php @@ -0,0 +1,109 @@ +map[$name] ?? null; + } + }; + $embedder = new class($vecByNeedle) implements Embedder + { + public function __construct(private array $map) {} + + public function embed(array $texts): array + { + return array_map(function (string $t): array { + foreach ($this->map as $needle => $vec) { + if (str_contains($t, $needle)) { + return $vec; + } + } + + return [0.0, 0.0, 0.0]; + }, $texts); + } + }; + + return new FindCompetitorsAssembler( + new AggregatorFilter($classifier), + new AutopodborDedup(new AutopodborNormalizer), + new EmbeddingRelevance($embedder), + ); +} + +function sampleCandidates(): array +{ + return [ + ['name' => 'Авито', 'site_url' => 'avito.ru', 'description' => 'объявления'], // агрегатор + ['name' => 'АвтоДеньги', 'site_url' => 'avtodengi.ru', 'description' => 'займ под залог авто', 'directory_urls' => ['https://2gis.ru/firm/1'], 'is_federal' => false], + ['name' => 'АвтоДеньги', 'site_url' => 'https://avtodengi.ru/', 'description' => 'автозайм', 'directory_urls' => ['https://yandex.ru/maps/org/x/2'], 'is_federal' => false], // дубль по домену + ['name' => 'Окна Комфорт', 'site_url' => 'okna.ru', 'description' => 'пластиковые окна', 'is_federal' => false], // нерелевантный + ['name' => 'Мой Бизнес', 'site_url' => 'moy-biznes.ru', 'description' => 'это клиент'], // сам клиент + ['name' => 'CarMoney', 'site_url' => 'carmoney.ru', 'description' => 'залог авто онлайн', 'is_federal' => true], // федерал + ]; +} + +it('собирает ядро: отсев агрегаторов → слияние/вычет клиента → похожесть → DTO §7.2', function () { + $a = assembler( + aggByName: ['Авито' => true], + vecByNeedle: ['залог авто' => [1.0, 0.0, 0.0], 'пластиковые окна' => [0.0, 1.0, 0.0]], + ); + + $res = $a->assemble( + candidates: sampleCandidates(), + clientExamples: ['Автоломбард Клиента залог авто'], + clientKeys: ['moy-biznes.ru'], + includeFederal: false, + maxCompetitors: 10, + ); + + $names = array_column($res->competitors, 'name'); + expect($names)->not->toContain('Авито') // агрегатор отсеян + ->not->toContain('Мой Бизнес') // клиент вычтен + ->not->toContain('CarMoney'); // федерал исключён (includeFederal=false) + expect($names)->toBe(['АвтоДеньги', 'Окна Комфорт']); // отсортированы по похожести + + $top = $res->competitors[0]; + expect($top['name'])->toBe('АвтоДеньги'); + expect($top['directory_urls'])->toContain('https://2gis.ru/firm/1')->toContain('https://yandex.ru/maps/org/x/2'); // дубль слит + expect($top['relevance_pct'])->toBe(100); + expect($top['relevance_pct'])->toBeGreaterThan($res->competitors[1]['relevance_pct']); + // форма §7.2 + expect($top)->toHaveKeys(['name', 'description', 'is_federal', 'relevance_pct', 'site_url', 'directory_urls', 'provenance']); +}); + +it('includeFederal=true оставляет федерала; maxCompetitors режет хвост', function () { + $a = assembler( + aggByName: ['Авито' => true], + vecByNeedle: ['залог авто' => [1.0, 0.0, 0.0]], + ); + + $res = $a->assemble( + candidates: sampleCandidates(), + clientExamples: ['залог авто'], + clientKeys: ['moy-biznes.ru'], + includeFederal: true, + maxCompetitors: 1, + ); + + expect($res->competitors)->toHaveCount(1); // срез до 1 + // CarMoney (залог авто, федерал) теперь допустим как кандидат верхнего уровня + $a2 = assembler(['Авито' => true], ['залог авто' => [1.0, 0.0, 0.0]]); + $full = $a2->assemble(sampleCandidates(), ['залог авто'], ['moy-biznes.ru'], true, 10); + expect(array_column($full->competitors, 'name'))->toContain('CarMoney'); +}); From a5a6eba0307a4359f23c0cdf1d9ff96f6f55d5f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 18:04:21 +0300 Subject: [PATCH 089/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=88=D0=B0=D0=B3=D0=B01=20=E2=80=94=20=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D1=8C=20=D0=B4=D0=B2=D0=B8=D0=B6=D0=BE=D0=BA=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=BD=20(A=E2=80=93F+C+D+H-=D1=8F=D0=B4?= =?UTF-8?q?=D1=80=D0=BE),=20=D0=BE=D1=81=D1=82=D0=B0=D0=BB=D0=BE=D1=81?= =?UTF-8?q?=D1=8C=20=D0=B6=D0=B8=D0=B2=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5+=D1=84=D0=BB?= =?UTF-8?q?=D0=B8=D0=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-30-step1-real-findcompetitors.md | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md b/docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md index 6502767d..ec17d8fc 100644 --- a/docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md +++ b/docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md @@ -40,15 +40,21 @@ AITUNNEL (эмбеддинги text-embedding-3-small), портал поста **Порядок:** A (фундамент, нужен всем) → E+F (слияние/похожесть, чистая логика) → B → C/D → G → H. Живые блоки (C/D/F/G) — после готовности их ключей и отдельного «го» владельца. -> **ПРОГРЕСС 30.06 (все офлайн-блоки готовы, на gitea):** +> **ПРОГРЕСС 30.06 — ВЕСЬ ДВИЖОК СОБРАН (логика готова и протестирована, на gitea):** > - ✅ **A** Резолвер (A1–A5) — `b2c89d12` > - ✅ **E** Слияние+дедуп union-find + вычет клиента — `64d6703b` -> - ✅ **F (офлайн)** Похожесть-эмбеддинги (косинус/ранжирование за границей `Embedder`) — `b900874a` -> - ✅ **B (офлайн)** Парсер выдачи 2ГИС/Яндекс (`SearchResultsParser`) — `0d5e06e8` -> - 🔴 **Осталось — ЖИВЫЕ блоки (нужны ключи + «го» владельца):** C (LLM-имена+фильтр агрегаторов), -> D (DaData-телефоны), G (портал поставщика lkomega), живые реализации `Embedder`/пагинации, -> **H** (оркестрация `RealCompetitorAgent.findCompetitors` + флип провайдера за фиче-флагом). -> Тесты модуля Автоподбора: **87/87** unit, Pint чисто. +> - ✅ **F** Похожесть-эмбеддинги (косинус/ранжирование за границей `Embedder`) — `b900874a` +> - ✅ **B** Парсер выдачи 2ГИС/Яндекс (`SearchResultsParser`) — `0d5e06e8` +> - ✅ **D** Телефоны DaData (`CompetitorPhoneEnricher`, переиспользует живой `DaDataPhoneClient`) — `026bc48d` +> - ✅ **C** Отсев агрегаторов (`AggregatorFilter` + граница `AggregatorClassifier`) — `0fc58979` +> - ✅ **H-ядро** Сборка `findCompetitors` (`FindCompetitorsAssembler`: C→E→федерал→F→DTO §7.2) — `fcff8ecd` +> - 🔴 **Осталось — только «подключить к живому и включить» (нужно «го» + живые прогоны):** +> 1) **G** канал 0 — портал поставщика lkomega (нужен живой логин, чтобы снять фикстуру списка проектов); +> 2) **проводка каналов** в `RealCompetitorAgent.findCompetitors` к живому fetch (2ГИС xfetch / Яндекс +> Playwright / поиск) + живые `Embedder`(AITUNNEL)/`AggregatorClassifier`(LLM) реализации; +> 3) **флип провайдера** на боевой движок за фиче-флагом (production go-live — отдельное решение владельца); +> 4) **сквозной живой прогон** на нише клиента. +> Тесты модуля Автоподбора: **96/96** unit, Pint чисто. Провайдер НЕ флипнут — на проде ничего не изменилось. --- From ea76ab3777420ccafdf9e2ece18d579c03666b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 18:23:29 +0300 Subject: [PATCH 090/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=88=D0=B0=D0=B31=20?= =?UTF-8?q?=E2=80=94=20=D0=B6=D0=B8=D0=B2=D0=BE=D0=B9=20findCompetitors=20?= =?UTF-8?q?(=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=20=D0=90:=20=D0=BD=D0=B8=D1=88?= =?UTF-8?q?=D0=B0=20=E2=86=92=202=D0=93=D0=98=D0=A1+=D0=AF=D0=BD=D0=B4?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=20=E2=86=92=20=D1=80=D0=B5=D0=B7=D0=BE=D0=BB?= =?UTF-8?q?=D0=B2=20=E2=86=92=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiveFindCompetitors: ниша из формы → поиск 2ГИС (по слагу города) + Яндекс (по «ниша город») → парсер выдачи → резолв каждой карточки напрямую → FindCompetitorsAssembler (фильтр/слияние/ похожесть → DTO §7.2). Добыча за PageFetcher — обход тестируется офлайн на фикстурах. RegionCity — слаг/имя города центра субъекта (как RegionAreaCode, только уверенные). Тесты: live 3/3 (склейка 2ГИС+Яндекс одной фирмы, вычет своего сайта, пустая выдача); модуль Автоподбора unit 99/99; Pint чисто. Провайдер ещё не флипнут — следующий шаг. Co-Authored-By: Claude Opus 4.8 --- .../Autopodbor/Agent/LiveFindCompetitors.php | 119 ++++++++++++++++++ app/app/Support/RegionCity.php | 62 +++++++++ .../Autopodbor/LiveFindCompetitorsTest.php | 116 +++++++++++++++++ 3 files changed, 297 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php create mode 100644 app/app/Support/RegionCity.php create mode 100644 app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php diff --git a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php new file mode 100644 index 00000000..a3770b71 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php @@ -0,0 +1,119 @@ +aboutSelf[0] ?? '')); + $city = RegionCity::name($r->regionCode) ?? ''; + + $candidates = []; + if ($niche !== '') { + $candidates = array_merge( + $this->fromTwoGis($niche, $r->regionCode), + $this->fromYandex($niche, $city), + ); + } + + // Свои сайт/имя (about_self) — для вычета себя из конкурентов. + $clientKeys = array_values(array_filter(array_map( + static fn ($v): string => (string) $v, + $r->aboutSelf, + ), static fn (string $v): bool => $v !== '')); + + // examples — для похожести (если подключён живой эмбеддер); без него ранжирование = 0. + $examples = array_values(array_filter(array_map( + static fn ($v): string => (string) $v, + $r->examples, + ), static fn (string $v): bool => $v !== '')); + + return $this->assembler->assemble($candidates, $examples, $clientKeys, $r->includeFederal, $r->maxCompetitors); + } + + /** 2ГИС: /<город>/search/<ниша> → карточки firm (только если знаем слаг города). */ + private function fromTwoGis(string $niche, int $regionCode): array + { + $slug = RegionCity::slug($regionCode); + if ($slug === null) { + return []; // нет уверенного слага — пропускаем 2ГИС, остаётся Яндекс + } + $searchUrl = "https://2gis.ru/{$slug}/search/".rawurlencode($niche); + $items = array_slice($this->parser->twoGis($this->pages->html($searchUrl)), 0, self::PER_SOURCE_CAP); + + $out = []; + foreach ($items as $it) { + $url = 'https://2gis.ru'.$it['path']; + $card = $this->twoGis->parse($this->pages->html($url), $url); + if ($card !== null) { + $out[] = $this->toArray($card); + } + } + + return $out; + } + + /** Яндекс: поиск «ниша город» → карточки org (имя/город проверяет резолвер). */ + private function fromYandex(string $niche, string $city): array + { + $searchUrl = 'https://yandex.ru/maps/?text='.rawurlencode(trim($niche.' '.$city)); + $items = array_slice($this->parser->yandex($this->pages->html($searchUrl)), 0, self::PER_SOURCE_CAP); + + $out = []; + foreach ($items as $it) { + $url = 'https://yandex.ru'.$it['url']; + $card = $this->yandex->parse($this->pages->html($url), $url, $it['name'], $city); + if ($card !== null) { + $out[] = $this->toArray($card); + } + } + + return $out; + } + + /** Карточка резолвера → строка-кандидат для сборщика. */ + private function toArray(ResolvedCompetitor $c): array + { + return [ + 'name' => $c->name, + 'site_url' => $c->siteUrl, + 'description' => $c->description, + 'is_federal' => $c->isFederal, + 'directory_urls' => $c->directoryUrl !== null ? [$c->directoryUrl] : [], + 'phones' => $c->phones, + 'provenance' => ['via' => 'engine', 'source' => $c->source], + ]; + } +} diff --git a/app/app/Support/RegionCity.php b/app/app/Support/RegionCity.php new file mode 100644 index 00000000..a4ba8310 --- /dev/null +++ b/app/app/Support/RegionCity.php @@ -0,0 +1,62 @@ +/search/<запрос>`, Яндекс — по тексту «<ниша> <город>». + * + * Как и {@see RegionAreaCode}: внесены ТОЛЬКО уверенные центры (ключ — каноничное имя + * субъекта из RussianRegions::CODE_TO_NAME); неизвестный субъект → null, и движок не + * выдумывает город (для 2ГИС просто пропустит этот источник, останется Яндекс по имени субъекта). + * Карта расширяема. + */ +final class RegionCity +{ + /** @var array имя субъекта => {слаг 2ГИС, имя города} */ + private const BY_NAME = [ + 'Москва' => ['slug' => 'moscow', 'name' => 'Москва'], + 'Санкт-Петербург' => ['slug' => 'spb', 'name' => 'Санкт-Петербург'], + 'Красноярский край' => ['slug' => 'krasnoyarsk', 'name' => 'Красноярск'], + 'Новосибирская область' => ['slug' => 'novosibirsk', 'name' => 'Новосибирск'], + 'Свердловская область' => ['slug' => 'ekaterinburg', 'name' => 'Екатеринбург'], + 'Республика Татарстан' => ['slug' => 'kazan', 'name' => 'Казань'], + 'Нижегородская область' => ['slug' => 'n_novgorod', 'name' => 'Нижний Новгород'], + 'Самарская область' => ['slug' => 'samara', 'name' => 'Самара'], + 'Ростовская область' => ['slug' => 'rostov', 'name' => 'Ростов-на-Дону'], + 'Воронежская область' => ['slug' => 'voronezh', 'name' => 'Воронеж'], + 'Пермский край' => ['slug' => 'perm', 'name' => 'Пермь'], + 'Приморский край' => ['slug' => 'vladivostok', 'name' => 'Владивосток'], + 'Республика Башкортостан' => ['slug' => 'ufa', 'name' => 'Уфа'], + 'Челябинская область' => ['slug' => 'chelyabinsk', 'name' => 'Челябинск'], + 'Краснодарский край' => ['slug' => 'krasnodar', 'name' => 'Краснодар'], + ]; + + /** Слаг города для URL 2ГИС (`krasnoyarsk`) или null, если уверенного нет. */ + public static function slug(int $subjectCode): ?string + { + return self::entry($subjectCode)['slug'] ?? null; + } + + /** Имя города («Красноярск») или имя субъекта как запасной вариант (для запроса Яндекса). */ + public static function name(int $subjectCode): ?string + { + $name = RussianRegions::CODE_TO_NAME[$subjectCode] ?? null; + + return self::entry($subjectCode)['name'] ?? $name; + } + + /** @return array{slug:string, name:string}|array{} */ + private static function entry(int $subjectCode): array + { + $name = RussianRegions::CODE_TO_NAME[$subjectCode] ?? null; + if ($name === null) { + return []; + } + + return self::BY_NAME[$name] ?? []; + } +} diff --git a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php new file mode 100644 index 00000000..41fa1161 --- /dev/null +++ b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php @@ -0,0 +1,116 @@ + [0.0], $texts); + } + }; + $assembler = new FindCompetitorsAssembler( + new AggregatorFilter($nullClassifier), + new AutopodborDedup(new AutopodborNormalizer), + new EmbeddingRelevance($zeroEmbedder), + ); + + return new LiveFindCompetitors( + stubPages([ + // выдача + '2gis.ru/krasnoyarsk/search/' => autopodborFixture('2gis-search-kraslombard.html'), + 'maps/?text=' => autopodborFixture('yandex-search-kraslombard.html'), + // карточки + '/firm/' => autopodborFixture('2gis-firm-kraslombard.html'), + '/maps/org/' => autopodborFixture('yandex-org-kraslombard.html'), + ]), + new SearchResultsParser, + new TwoGisResolver, + new YandexResolver, + $assembler, + ); +} + +it('живой поиск по нише → настоящий конкурент, склеенный из 2ГИС и Яндекса', function () { + $res = liveEngine()->find(new FindCompetitorsRequest( + regionCode: 29, // Красноярский край → город Красноярск, слаг krasnoyarsk + examples: [], + aboutSelf: ['ломбард', 'moy-lombard.ru'], // ниша + ВАШ сайт (другой — себя НЕ вычитаем) + includeFederal: true, + maxCompetitors: 20, + )); + + $names = array_column($res->competitors, 'name'); + expect($names)->toContain('КрасЛомбард'); // найден живым поиском по нише + + $kl = collect($res->competitors)->firstWhere('name', 'КрасЛомбард'); + expect($kl['site_url'])->toBe('http://kraslombard24.ru'); + // склейка одной фирмы из 2ГИС + Яндекс: в «где нашли» обе ссылки справочников + expect(collect($kl['directory_urls'])->contains(fn (string $u): bool => str_contains($u, '/firm/')))->toBeTrue(); + expect(collect($kl['directory_urls'])->contains(fn (string $u): bool => str_contains($u, '/maps/org/')))->toBeTrue(); +}); + +it('свой сайт вычитается из конкурентов', function () { + $res = liveEngine()->find(new FindCompetitorsRequest( + regionCode: 29, + examples: [], + aboutSelf: ['ломбард', 'kraslombard24.ru'], // теперь КрасЛомбард = это «мы» + includeFederal: true, + maxCompetitors: 20, + )); + + expect(array_column($res->competitors, 'name'))->not->toContain('КрасЛомбард'); +}); + +it('пустая выдача → пустой список, без падения', function () { + $engine = new LiveFindCompetitors( + stubPages([]), // на всё '' + new SearchResultsParser, + new TwoGisResolver, + new YandexResolver, + new FindCompetitorsAssembler( + new AggregatorFilter(new class implements AggregatorClassifier + { + public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool + { + return null; + } + }), + new AutopodborDedup(new AutopodborNormalizer), + new EmbeddingRelevance(new class implements Embedder + { + public function embed(array $texts): array + { + return array_map(fn () => [0.0], $texts); + } + }), + ), + ); + + $res = $engine->find(new FindCompetitorsRequest(29, [], ['ничего', 'self.ru'], false, 10)); + expect($res->competitors)->toBe([]); +}); From 1b25b64d58e23de9028210cc454e87f60f0718c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 18:26:38 +0300 Subject: [PATCH 091/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=88=D0=B0=D0=B31=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D1=80=D0=BE=D0=B2=D0=BE=D0=B4=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B6=D0=B8=D0=B2=D0=BE=D0=B3=D0=BE=20findCompetitors=20=D0=B7?= =?UTF-8?q?=D0=B0=20=D1=84=D0=BB=D0=B0=D0=B3=D0=BE=D0=BC=20autopodbor.real?= =?UTF-8?q?=5Ffind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LivePageFetcher (2ГИС→xfetch, Яндекс→xfetch+fallback локальный Playwright). Провайдер при config('autopodbor.real_find')=true собирает LiveFindCompetitors (без ИИ-ключа: null-классификатор + нулевой эмбеддер → сырой список без отсева площадок и без %). RealCompetitorAgent.findCompetitors использует живой движок, если подключён, иначе заглушку. Флаг по умолчанию ВЫКЛ — на проде без изменений. Тесты: Автоподбор unit+feature 172/172 (флаг выкл — биндинг цел); Pint чисто. Co-Authored-By: Claude Opus 4.8 --- .../Providers/AutopodborServiceProvider.php | 63 +++++++++++++++++-- .../Agent/Fetch/LivePageFetcher.php | 59 +++++++++++++++++ .../Autopodbor/Agent/RealCompetitorAgent.php | 6 +- app/config/autopodbor.php | 15 +++++ 4 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 app/app/Services/Autopodbor/Agent/Fetch/LivePageFetcher.php create mode 100644 app/config/autopodbor.php diff --git a/app/app/Providers/AutopodborServiceProvider.php b/app/app/Providers/AutopodborServiceProvider.php index 6c3f89df..fa6041ab 100644 --- a/app/app/Providers/AutopodborServiceProvider.php +++ b/app/app/Providers/AutopodborServiceProvider.php @@ -2,24 +2,38 @@ namespace App\Providers; +use App\Services\Autopodbor\Agent\Aggregator\AggregatorClassifier; +use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter; use App\Services\Autopodbor\Agent\CompetitorAgent; use App\Services\Autopodbor\Agent\FakeCompetitorAgent; use App\Services\Autopodbor\Agent\Fetch\CompositeFetcher; use App\Services\Autopodbor\Agent\Fetch\CurlPlaywrightFetcher; +use App\Services\Autopodbor\Agent\Fetch\LivePageFetcher; use App\Services\Autopodbor\Agent\Fetch\XfetchClient; use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher; +use App\Services\Autopodbor\Agent\FindCompetitorsAssembler; +use App\Services\Autopodbor\Agent\LiveFindCompetitors; use App\Services\Autopodbor\Agent\RealCompetitorAgent; +use App\Services\Autopodbor\Agent\Resolve\TwoGisResolver; +use App\Services\Autopodbor\Agent\Resolve\YandexResolver; +use App\Services\Autopodbor\Agent\Search\SearchResultsParser; +use App\Services\Autopodbor\Agent\Similarity\Embedder; +use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance; +use App\Services\Autopodbor\AutopodborDedup; +use App\Services\Autopodbor\AutopodborNormalizer; use Illuminate\Support\ServiceProvider; class AutopodborServiceProvider extends ServiceProvider { public function register(): void { - // Шаг 2 (изучение конкурента) — настоящий движок: сайт конкурента берём обычным - // curl + локальный Playwright (бесплатно), справочники 2ГИС/Яндекс — через антибот - // xfetch.ru. Поиск/резолв (шаг 1) ещё не реальны — RealCompetitorAgent делегирует - // их заглушке FakeCompetitorAgent (fallback). Когда шаг 1 будет готов — заменить - // fallback на настоящую реализацию find/resolve. + // Шаг 2 (изучение конкурента) — настоящий движок: сайт конкурента берём обычным curl + + // локальный Playwright, справочники 2ГИС/Яндекс — через антибот xfetch.ru. + // + // Шаг 1 (поиск конкурентов): при включённом флаге autopodbor.real_find подключается ЖИВОЙ + // движок (ниша → поиск 2ГИС/Яндекс → резолв → сборка). Без ИИ-ключа отсев агрегаторов и + // похожесть-% отключены (null-классификатор + нулевой эмбеддер) — выдаётся сырой список. + // Флаг ВЫКЛ → findCompetitors отдаёт демо-заглушку (как раньше). resolveByName — заглушка. $this->app->bind(CompetitorAgent::class, function ($app): CompetitorAgent { $xfetch = new XfetchClient( apiKey: config('services.xfetch.key'), @@ -31,7 +45,44 @@ class AutopodborServiceProvider extends ServiceProvider directoryFetcher: new XfetchDirectoryFetcher($xfetch), ); - return new RealCompetitorAgent($fetcher, new FakeCompetitorAgent); + $liveFind = config('autopodbor.real_find') + ? $this->buildLiveFind($xfetch) + : null; + + return new RealCompetitorAgent($fetcher, new FakeCompetitorAgent, liveFind: $liveFind); }); } + + /** Живой движок поиска шага 1 (канал А). Без ИИ-ключа — без отсева агрегаторов и без %. */ + private function buildLiveFind(XfetchClient $xfetch): LiveFindCompetitors + { + $nullClassifier = new class implements AggregatorClassifier + { + public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool + { + return null; // нет ИИ — никого не выкидываем + } + }; + $zeroEmbedder = new class implements Embedder + { + public function embed(array $texts): array + { + return array_map(static fn (): array => [0.0], $texts); + } + }; + + $assembler = new FindCompetitorsAssembler( + new AggregatorFilter($nullClassifier), + new AutopodborDedup(new AutopodborNormalizer), + new EmbeddingRelevance($zeroEmbedder), + ); + + return new LiveFindCompetitors( + new LivePageFetcher($xfetch), + new SearchResultsParser, + new TwoGisResolver, + new YandexResolver, + $assembler, + ); + } } diff --git a/app/app/Services/Autopodbor/Agent/Fetch/LivePageFetcher.php b/app/app/Services/Autopodbor/Agent/Fetch/LivePageFetcher.php new file mode 100644 index 00000000..be508438 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Fetch/LivePageFetcher.php @@ -0,0 +1,59 @@ +xfetch->html($url); + } + + if (str_contains($url, 'yandex.')) { + $html = $this->xfetch->html($url); + + return $html !== '' ? $html : $this->renderLocally($url); + } + + return ''; + } + + /** Локальный Playwright-рендер (бесплатный запас для Яндекса). */ + private function renderLocally(string $url): string + { + try { + $process = new Process([$this->nodeBin, base_path($this->renderScript), $url]); + $process->setTimeout($this->renderTimeoutSec); + $process->run(); + + if (! $process->isSuccessful()) { + return ''; + } + + $decoded = json_decode($process->getOutput(), true); + + return is_array($decoded) && isset($decoded['html']) ? (string) $decoded['html'] : ''; + } catch (ProcessTimedOutException|\Throwable) { + return ''; + } + } +} diff --git a/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php b/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php index fcfffd7d..9b3875c4 100644 --- a/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php +++ b/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php @@ -21,15 +21,17 @@ final class RealCompetitorAgent implements CompetitorAgent { public function __construct( private Fetcher $fetcher, - private CompetitorAgent $fallback, // для find/resolve, пока они не реальны + private CompetitorAgent $fallback, // для resolve, и для find пока не подключён живой private CandidateBuilder $builder = new CandidateBuilder, private SourceAggregator $aggregator = new SourceAggregator, private AutopodborNormalizer $norm = new AutopodborNormalizer, + private ?LiveFindCompetitors $liveFind = null, // живой поиск шага 1 (если подключён за флагом) ) {} public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult { - return $this->fallback->findCompetitors($r); + // Подключён живой движок поиска — используем его; иначе заглушка (демо-данные). + return $this->liveFind?->find($r) ?? $this->fallback->findCompetitors($r); } public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult diff --git a/app/config/autopodbor.php b/app/config/autopodbor.php new file mode 100644 index 00000000..e5443d32 --- /dev/null +++ b/app/config/autopodbor.php @@ -0,0 +1,15 @@ + env('AUTOPODBOR_REAL_FIND', false), +]; From 8f902abb77a32549beb9142d7dd630524c68ee48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 18:44:27 +0300 Subject: [PATCH 092/417] =?UTF-8?q?fix(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B6=D0=B8=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=20=D1=80=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2=20=D0=AF=D0=BD?= =?UTF-8?q?=D0=B4=D0=B5=D0=BA=D1=81=D0=B0=20(=D0=B3=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=20=D0=B2=20=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B5=20=D0=BD=D0=B5=20=D0=BD=D0=B0=20=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81.=20=D0=BC=D0=B5=D1=81=D1=82=D0=B5)=20+=20=D1=80=D0=B5?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=B9=20=D1=84=D0=BB=D0=B0=D0=BA=D1=83=D1=8E?= =?UTF-8?q?=D1=89=D0=B5=D0=B9=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено живым прогоном по нише «ломбард/Красноярск»: 1) Яндекс-заголовок «Имя, рубрика, ГОРОД, улица, дом» — город НЕ последний сегмент; брал «дом» как город → все карточки отбраковывались. Теперь YandexResolver: имя=первый сегмент, город — по НАЛИЧИЮ в заголовке (DirectoryFields::titleName/titleHasCity). 2ГИС (город последний) — без изменений. 2) ResolvingAgent (ручной resolveByName) теперь берёт ГОРОД центра субъекта (RegionCity), не имя региона. 3) Страница выдачи 2ГИС/Яндекс флакует (капча/пустой layout) → LiveFindCompetitors повторяет поиск, пока не появятся фирмы (searchRetries). Тесты: модуль Автоподбора unit 99/99 + live 4/4; Pint чисто. Co-Authored-By: Claude Opus 4.8 --- .../Autopodbor/Agent/LiveFindCompetitors.php | 32 ++++++++++-- .../Agent/Resolve/DirectoryFields.php | 44 +++++++++++++--- .../Agent/Resolve/ResolvingAgent.php | 7 +-- .../Agent/Resolve/YandexResolver.php | 15 +++--- .../Autopodbor/LiveFindCompetitorsTest.php | 52 +++++++++++++++++++ 5 files changed, 126 insertions(+), 24 deletions(-) diff --git a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php index a3770b71..547efd94 100644 --- a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php +++ b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php @@ -24,15 +24,19 @@ use App\Support\RegionCity; */ final class LiveFindCompetitors { - /** Предел резолвов на источник — ограничивает число живых запросов/трат за один подбор. */ - private const PER_SOURCE_CAP = 20; - public function __construct( private readonly PageFetcher $pages, private readonly SearchResultsParser $parser, private readonly TwoGisResolver $twoGis, private readonly YandexResolver $yandex, private readonly FindCompetitorsAssembler $assembler, + // Пределы резолвов на источник — ограничивают число живых запросов/трат за подбор. + // Яндекс (запасной рендер локальным Playwright ~медленный) держим ниже 2ГИС (xfetch). + private readonly int $twoGisCap = 15, + private readonly int $yandexCap = 6, + // Страница выдачи 2ГИС/Яндекс флакует (капча/пустой layout) — повторяем поиск, пока + // не появятся ссылки на фирмы (как ретрай списка филиалов в шаге 2). + private readonly int $searchRetries = 3, ) {} public function find(FindCompetitorsRequest $r): FindCompetitorsResult @@ -71,7 +75,7 @@ final class LiveFindCompetitors return []; // нет уверенного слага — пропускаем 2ГИС, остаётся Яндекс } $searchUrl = "https://2gis.ru/{$slug}/search/".rawurlencode($niche); - $items = array_slice($this->parser->twoGis($this->pages->html($searchUrl)), 0, self::PER_SOURCE_CAP); + $items = array_slice($this->searchWithRetry($searchUrl, fn (string $h): array => $this->parser->twoGis($h)), 0, $this->twoGisCap); $out = []; foreach ($items as $it) { @@ -89,7 +93,7 @@ final class LiveFindCompetitors private function fromYandex(string $niche, string $city): array { $searchUrl = 'https://yandex.ru/maps/?text='.rawurlencode(trim($niche.' '.$city)); - $items = array_slice($this->parser->yandex($this->pages->html($searchUrl)), 0, self::PER_SOURCE_CAP); + $items = array_slice($this->searchWithRetry($searchUrl, fn (string $h): array => $this->parser->yandex($h)), 0, $this->yandexCap); $out = []; foreach ($items as $it) { @@ -103,6 +107,24 @@ final class LiveFindCompetitors return $out; } + /** + * Загрузка страницы выдачи с ретраем: повторяем, пока парсер не вернёт ссылки (флак капчи/пустого + * layout). Первый непустой результат — наш. + * + * @param callable(string):array $parse + */ + private function searchWithRetry(string $url, callable $parse): array + { + for ($attempt = 1; $attempt <= max(1, $this->searchRetries); $attempt++) { + $items = $parse($this->pages->html($url)); + if ($items !== []) { + return $items; + } + } + + return []; + } + /** Карточка резолвера → строка-кандидат для сборщика. */ private function toArray(ResolvedCompetitor $c): array { diff --git a/app/app/Services/Autopodbor/Agent/Resolve/DirectoryFields.php b/app/app/Services/Autopodbor/Agent/Resolve/DirectoryFields.php index 1a693f7a..faff126d 100644 --- a/app/app/Services/Autopodbor/Agent/Resolve/DirectoryFields.php +++ b/app/app/Services/Autopodbor/Agent/Resolve/DirectoryFields.php @@ -19,21 +19,51 @@ final class DirectoryFields */ public static function nameAndCity(string $html): array { - if (! preg_match('#(.*?)#is', $html, $m)) { + $parts = self::titleParts($html); + if (count($parts) < 2) { return [null, null]; } + + return [$parts[0], $parts[count($parts) - 1]]; + } + + /** Имя фирмы — первый сегмент (для справочников, где город в заголовке не на фикс. месте). */ + public static function titleName(string $html): ?string + { + $parts = self::titleParts($html); + + return $parts === [] ? null : $parts[0]; + } + + /** Содержит ли заголовок карточки указанный город — устойчиво к позиции города в заголовке. */ + public static function titleHasCity(string $html, string $city): bool + { + $city = mb_strtolower(trim($city)); + if ($city === '') { + return false; + } + $title = mb_strtolower(implode(', ', self::titleParts($html))); + + return $title !== '' && self::localeMatches($title, $city); + } + + /** + * Сегменты <title> карточки: декодированы, схлопнуты пробелы, отрезан хвост « — 2ГИС/Яндекс». + * + * @return list<string> + */ + private static function titleParts(string $html): array + { + if (! preg_match('#<title>(.*?)#is', $html, $m)) { + return []; + } $title = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $title = preg_replace('/[\s\x{00A0}]+/u', ' ', $title) ?? $title; $title = trim($title); // отрезаем хвост « — 2ГИС» / « — Яндекс Карты» $title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title; - $parts = array_values(array_filter(array_map('trim', explode(',', $title)), fn ($p) => $p !== '')); - if (count($parts) < 2) { - return [null, null]; - } - - return [$parts[0], $parts[count($parts) - 1]]; + return array_values(array_filter(array_map('trim', explode(',', $title)), fn ($p) => $p !== '')); } /** diff --git a/app/app/Services/Autopodbor/Agent/Resolve/ResolvingAgent.php b/app/app/Services/Autopodbor/Agent/Resolve/ResolvingAgent.php index 31dc55f5..d1b42960 100644 --- a/app/app/Services/Autopodbor/Agent/Resolve/ResolvingAgent.php +++ b/app/app/Services/Autopodbor/Agent/Resolve/ResolvingAgent.php @@ -6,7 +6,7 @@ namespace App\Services\Autopodbor\Agent\Resolve; use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest; use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult; -use App\Support\RussianRegions; +use App\Support\RegionCity; /** * Ручной резолв по названию (контракт §7.2 `resolveByName`): имя + регион → кандидат(ы) @@ -20,8 +20,9 @@ final class ResolvingAgent public function resolve(ResolveByNameRequest $r): ResolveByNameResult { - $locale = RussianRegions::CODE_TO_NAME[$r->regionCode] ?? ''; - $card = $this->resolver->resolve($r->name, twoGisUrl: null, city: $locale); + // Город центра субъекта (для поиска/проверки карточки); запасной — имя субъекта. + $city = RegionCity::name($r->regionCode) ?? ''; + $card = $this->resolver->resolve($r->name, twoGisUrl: null, city: $city); return new ResolveByNameResult([$this->toCandidate($card)]); } diff --git a/app/app/Services/Autopodbor/Agent/Resolve/YandexResolver.php b/app/app/Services/Autopodbor/Agent/Resolve/YandexResolver.php index 7118e522..4383d630 100644 --- a/app/app/Services/Autopodbor/Agent/Resolve/YandexResolver.php +++ b/app/app/Services/Autopodbor/Agent/Resolve/YandexResolver.php @@ -20,15 +20,17 @@ final class YandexResolver { public function parse(string $html, string $url, string $expectName, string $city): ?ResolvedCompetitor { - [$name, $cardCity] = DirectoryFields::nameAndCity($html); + $name = DirectoryFields::titleName($html); if ($name === null) { return null; // не карточка организации } - // Отбраковка чужой фирмы того же имени: имя и город должны совпасть с ожидаемыми. + // Отбраковка чужой фирмы того же имени: имя должно совпасть, и заголовок карточки + // должен содержать ожидаемый город. Город у Яндекса в заголовке НЕ на фикс. месте + // («Имя, рубрика, Город, улица, дом»), поэтому проверяем по наличию, а не по сегменту. if (! $this->namesMatch($name, $expectName)) { return null; } - if ($cardCity === null || ! $this->sameCity($cardCity, $city)) { + if (! DirectoryFields::titleHasCity($html, $city)) { return null; } @@ -38,7 +40,7 @@ final class YandexResolver phones: DirectoryFields::phones($html), directoryUrl: $this->cleanUrl($url), source: 'Яндекс.Карты', - region: $cardCity, + region: $city, // искомый город (подтверждён в заголовке карточки) description: $this->description($html), isFederal: false, ); @@ -52,11 +54,6 @@ final class YandexResolver return $a !== '' && $b !== '' && (str_contains($a, $b) || str_contains($b, $a)); } - private function sameCity(string $cardCity, string $expect): bool - { - return DirectoryFields::localeMatches($cardCity, $expect); - } - /** Чистый сайт из business-urls (itemprop=url), без utm-хвоста action-кнопки. */ private function website(string $html): ?string { diff --git a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php index 41fa1161..28aef754 100644 --- a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php +++ b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); use App\Services\Autopodbor\Agent\Aggregator\AggregatorClassifier; use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter; use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest; +use App\Services\Autopodbor\Agent\Fetch\PageFetcher; use App\Services\Autopodbor\Agent\FindCompetitorsAssembler; use App\Services\Autopodbor\Agent\LiveFindCompetitors; use App\Services\Autopodbor\Agent\Resolve\TwoGisResolver; @@ -86,6 +87,57 @@ it('свой сайт вычитается из конкурентов', functio expect(array_column($res->competitors, 'name'))->not->toContain('КрасЛомбард'); }); +it('повторяет флакующую выдачу 2ГИС, пока не появятся фирмы', function () { + // первый запрос страницы 2ГИС-поиска — пусто (капча/флак), второй — реальная выдача + $pages = new class implements PageFetcher + { + private int $searchHits = 0; + + public function html(string $url): string + { + if (str_contains($url, '/search/')) { + $this->searchHits++; + + return $this->searchHits >= 2 ? autopodborFixture('2gis-search-kraslombard.html') : ''; + } + if (str_contains($url, '/firm/')) { + return autopodborFixture('2gis-firm-kraslombard.html'); + } + + return ''; // Яндекс пропускаем + } + }; + + $nullClassifier = new class implements AggregatorClassifier + { + public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool + { + return null; + } + }; + $zeroEmbedder = new class implements Embedder + { + public function embed(array $texts): array + { + return array_map(fn () => [0.0], $texts); + } + }; + $engine = new LiveFindCompetitors( + $pages, + new SearchResultsParser, + new TwoGisResolver, + new YandexResolver, + new FindCompetitorsAssembler( + new AggregatorFilter($nullClassifier), + new AutopodborDedup(new AutopodborNormalizer), + new EmbeddingRelevance($zeroEmbedder), + ), + ); + + $res = $engine->find(new FindCompetitorsRequest(29, [], ['ломбард', 'moy.ru'], true, 20)); + expect(array_column($res->competitors, 'name'))->toContain('КрасЛомбард'); +}); + it('пустая выдача → пустой список, без падения', function () { $engine = new LiveFindCompetitors( stubPages([]), // на всё '' From 886e5500f20e488511856eaa792eb0ca814a942f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 18:49:18 +0300 Subject: [PATCH 093/417] =?UTF-8?q?fix(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=82=D0=B0=D0=B9=D0=BC?= =?UTF-8?q?=D0=B0=D1=83=D1=82=20=D0=B7=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0=20900=D1=81=20(=D0=B6?= =?UTF-8?q?=D0=B8=D0=B2=D0=BE=D0=B9=20=D0=BF=D0=BE=D0=B4=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=20=D0=B8=D0=B4=D1=91=D1=82=20=D0=BC=D0=B8=D0=BD=D1=83=D1=82?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunAutopodborSearchJob.timeout=900 — живой поиск (2ГИС+Яндекс через антибот + обход карточек) идёт ~4-5 мин; дефолтные 60с убивали задание на середине. Очередь поднимать с --timeout>=900. --- app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php index 746de320..fac42f8e 100644 --- a/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php +++ b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php @@ -26,6 +26,10 @@ class RunAutopodborSearchJob implements ShouldQueue public array $backoff = [15, 60, 300]; + // Живой поиск (2ГИС+Яндекс через антибот, обход карточек) идёт минутами — даём запас, + // иначе фоновое задание убьётся дефолтным 60-сек таймаутом на середине прогона. + public int $timeout = 900; + public function __construct(public int $runId) {} public function handle(CompetitorAgent $agent, AutopodborDedup $dedup, AutopodborChargeService $charge): void From f5044228a21457d0c868a86d9839d34d5bbdd433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 18:56:05 +0300 Subject: [PATCH 094/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B6=D0=B8=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=98=D0=98=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20AITUNN?= =?UTF-8?q?EL=20=E2=80=94=20=D1=8D=D0=BC=D0=B1=D0=B5=D0=B4=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3-=D0=BF=D0=BE=D1=85=D0=BE=D0=B6=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20(%)=20+=20LLM-=D0=BE=D1=82=D1=81=D0=B5=D0=B2=20=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D0=B5=D0=B3=D0=B0=D1=82=D0=BE=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AitunnelEmbedder (text-embedding-3-small) → косинус-похожесть конкурента на профиль клиента (%). AitunnelAggregatorClassifier (chat) → «поставщик или площадка?» — авто-отсев Авито/Zoon/Банки.ру. Оба деградируют сами при пустом ключе (пустые векторы → 0%, null → не выкидываем), поэтому подключены всегда. Ключ — только в .env (services.aitunnel.key), в гит не попадает. Тесты: AITUNNEL клиенты 9/9; Автоподбор unit+feature 182/182 (флаг real_find в тестах принудительно ВЫКЛ через phpunit.xml — иначе findCompetitors ходил бы в сеть); Pint чисто. Co-Authored-By: Claude Opus 4.8 --- .../Providers/AutopodborServiceProvider.php | 29 +++------ .../AitunnelAggregatorClassifier.php | 59 +++++++++++++++++++ .../Agent/Similarity/AitunnelEmbedder.php | 59 +++++++++++++++++++ app/config/services.php | 12 ++++ app/phpunit.xml | 3 + .../AitunnelAggregatorClassifierTest.php | 43 ++++++++++++++ .../Similarity/AitunnelEmbedderTest.php | 39 ++++++++++++ 7 files changed, 224 insertions(+), 20 deletions(-) create mode 100644 app/app/Services/Autopodbor/Agent/Aggregator/AitunnelAggregatorClassifier.php create mode 100644 app/app/Services/Autopodbor/Agent/Similarity/AitunnelEmbedder.php create mode 100644 app/tests/Unit/Autopodbor/Aggregator/AitunnelAggregatorClassifierTest.php create mode 100644 app/tests/Unit/Autopodbor/Similarity/AitunnelEmbedderTest.php diff --git a/app/app/Providers/AutopodborServiceProvider.php b/app/app/Providers/AutopodborServiceProvider.php index fa6041ab..2f292519 100644 --- a/app/app/Providers/AutopodborServiceProvider.php +++ b/app/app/Providers/AutopodborServiceProvider.php @@ -2,8 +2,8 @@ namespace App\Providers; -use App\Services\Autopodbor\Agent\Aggregator\AggregatorClassifier; use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter; +use App\Services\Autopodbor\Agent\Aggregator\AitunnelAggregatorClassifier; use App\Services\Autopodbor\Agent\CompetitorAgent; use App\Services\Autopodbor\Agent\FakeCompetitorAgent; use App\Services\Autopodbor\Agent\Fetch\CompositeFetcher; @@ -17,7 +17,7 @@ use App\Services\Autopodbor\Agent\RealCompetitorAgent; use App\Services\Autopodbor\Agent\Resolve\TwoGisResolver; use App\Services\Autopodbor\Agent\Resolve\YandexResolver; use App\Services\Autopodbor\Agent\Search\SearchResultsParser; -use App\Services\Autopodbor\Agent\Similarity\Embedder; +use App\Services\Autopodbor\Agent\Similarity\AitunnelEmbedder; use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance; use App\Services\Autopodbor\AutopodborDedup; use App\Services\Autopodbor\AutopodborNormalizer; @@ -53,28 +53,17 @@ class AutopodborServiceProvider extends ServiceProvider }); } - /** Живой движок поиска шага 1 (канал А). Без ИИ-ключа — без отсева агрегаторов и без %. */ + /** + * Живой движок поиска шага 1 (канал А) с ИИ через AITUNNEL: эмбеддинг-похожесть (%) и + * LLM-отсев агрегаторов. Клиенты деградируют сами при пустом ключе (эмбеддер → пустые векторы + * → 0%, классификатор → null → никого не выкидываем), поэтому подключаем их всегда. + */ private function buildLiveFind(XfetchClient $xfetch): LiveFindCompetitors { - $nullClassifier = new class implements AggregatorClassifier - { - public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool - { - return null; // нет ИИ — никого не выкидываем - } - }; - $zeroEmbedder = new class implements Embedder - { - public function embed(array $texts): array - { - return array_map(static fn (): array => [0.0], $texts); - } - }; - $assembler = new FindCompetitorsAssembler( - new AggregatorFilter($nullClassifier), + new AggregatorFilter($this->app->make(AitunnelAggregatorClassifier::class)), new AutopodborDedup(new AutopodborNormalizer), - new EmbeddingRelevance($zeroEmbedder), + new EmbeddingRelevance($this->app->make(AitunnelEmbedder::class)), ); return new LiveFindCompetitors( diff --git a/app/app/Services/Autopodbor/Agent/Aggregator/AitunnelAggregatorClassifier.php b/app/app/Services/Autopodbor/Agent/Aggregator/AitunnelAggregatorClassifier.php new file mode 100644 index 00000000..7ab645e6 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Aggregator/AitunnelAggregatorClassifier.php @@ -0,0 +1,59 @@ +http + ->withToken($key) + ->timeout((int) ($cfg['timeout_sec'] ?? 30)) + ->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [ + 'model' => $cfg['chat_model'] ?? 'gpt-4o-mini', + 'messages' => [['role' => 'user', 'content' => $prompt]], + 'temperature' => 0, + ]); + + if (! $resp->successful()) { + return null; + } + + $content = mb_strtolower((string) $resp->json('choices.0.message.content')); + if (str_contains($content, 'aggregator')) { + return true; + } + if (str_contains($content, 'supplier')) { + return false; + } + + return null; + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/app/Services/Autopodbor/Agent/Similarity/AitunnelEmbedder.php b/app/app/Services/Autopodbor/Agent/Similarity/AitunnelEmbedder.php new file mode 100644 index 00000000..1800146a --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Similarity/AitunnelEmbedder.php @@ -0,0 +1,59 @@ + [], $texts); + if ($key === '') { + return $empty; + } + + try { + $resp = $this->http + ->withToken($key) + ->timeout((int) ($cfg['timeout_sec'] ?? 30)) + ->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/embeddings', [ + 'model' => $cfg['embed_model'] ?? 'text-embedding-3-small', + 'input' => $texts, + ]); + + if (! $resp->successful()) { + return $empty; + } + + $out = $empty; + foreach ((array) $resp->json('data') as $row) { + $i = $row['index'] ?? null; + if (is_int($i) && isset($out[$i]) && is_array($row['embedding'] ?? null)) { + $out[$i] = array_map('floatval', $row['embedding']); + } + } + + return $out; + } catch (\Throwable) { + return $empty; + } + } +} diff --git a/app/config/services.php b/app/config/services.php index 151529cf..f810b4fa 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -80,6 +80,18 @@ return [ 'topup_url' => env('DADATA_TOPUP_URL', 'https://dadata.ru/profile/#billing'), ], + // AITUNNEL (OpenAI-совместимый) для автоподбора: эмбеддинги (похожесть конкурента на + // профиль клиента) + LLM-классификатор «поставщик услуги или площадка-агрегатор?». + // Ключ — только в .env/окружении, НИКОГДА в гит. Пусто → движок отдаёт сырой список + // (без % и без авто-отсева площадок). + 'aitunnel' => [ + 'key' => env('AITUNNEL_API_KEY'), + 'base_url' => env('AITUNNEL_BASE_URL', 'https://api.aitunnel.ru/v1'), + 'embed_model' => env('AITUNNEL_EMBED_MODEL', 'text-embedding-3-small'), + 'chat_model' => env('AITUNNEL_CHAT_MODEL', 'gpt-4o-mini'), + 'timeout_sec' => (int) env('AITUNNEL_TIMEOUT_SEC', 30), + ], + // Плитка балансов (28.06): Yandex Cloud биллинг (серверы + Managed PG ~18к/мес). // OAuth владельца (interim) → IAM-токен → billing API. SA billing-reader создан, // миграция на него — follow-up (только источник токена сменится). console_billing_url diff --git a/app/phpunit.xml b/app/phpunit.xml index ca050ac8..47bd9c2a 100644 --- a/app/phpunit.xml +++ b/app/phpunit.xml @@ -34,6 +34,9 @@ + + diff --git a/app/tests/Unit/Autopodbor/Aggregator/AitunnelAggregatorClassifierTest.php b/app/tests/Unit/Autopodbor/Aggregator/AitunnelAggregatorClassifierTest.php new file mode 100644 index 00000000..aee02858 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Aggregator/AitunnelAggregatorClassifierTest.php @@ -0,0 +1,43 @@ + Http::response([ + 'choices' => [['message' => ['content' => $content]]], + ], 200)]); +} + +it('LLM «AGGREGATOR» → площадка (true)', function () { + config(['services.aitunnel.key' => 'sk', 'services.aitunnel.base_url' => 'https://api.aitunnel.ru/v1']); + fakeChat('AGGREGATOR'); + expect(app(AitunnelAggregatorClassifier::class)->isAggregator('Авито', 'avito.ru', 'объявления'))->toBeTrue(); +}); + +it('LLM «SUPPLIER» → поставщик (false)', function () { + config(['services.aitunnel.key' => 'sk']); + fakeChat('Supplier'); + expect(app(AitunnelAggregatorClassifier::class)->isAggregator('АвтоДеньги', 'ad.ru', 'займы'))->toBeFalse(); +}); + +it('без ключа → null (никого не выкидываем)', function () { + config(['services.aitunnel.key' => '']); + expect(app(AitunnelAggregatorClassifier::class)->isAggregator('X', null, null))->toBeNull(); +}); + +it('непонятный ответ → null', function () { + config(['services.aitunnel.key' => 'sk']); + fakeChat('не знаю'); + expect(app(AitunnelAggregatorClassifier::class)->isAggregator('X', null, null))->toBeNull(); +}); + +it('ошибка сети → null', function () { + config(['services.aitunnel.key' => 'sk']); + Http::fake(fn () => throw new ConnectionException('timeout')); + expect(app(AitunnelAggregatorClassifier::class)->isAggregator('X', null, null))->toBeNull(); +}); diff --git a/app/tests/Unit/Autopodbor/Similarity/AitunnelEmbedderTest.php b/app/tests/Unit/Autopodbor/Similarity/AitunnelEmbedderTest.php new file mode 100644 index 00000000..c4b104f7 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Similarity/AitunnelEmbedderTest.php @@ -0,0 +1,39 @@ + 'sk-test', 'services.aitunnel.base_url' => 'https://api.aitunnel.ru/v1']); + Http::fake(['api.aitunnel.ru/*' => Http::response([ + 'data' => [ + ['index' => 0, 'embedding' => [0.1, 0.2, 0.3]], + ['index' => 1, 'embedding' => [0.9, 0.8, 0.7]], + ], + ], 200)]); + + $vecs = app(AitunnelEmbedder::class)->embed(['ломбард', 'окна']); + + expect($vecs)->toBe([[0.1, 0.2, 0.3], [0.9, 0.8, 0.7]]); + Http::assertSent(fn ($r) => str_contains($r->url(), '/embeddings') + && $r->hasHeader('Authorization', 'Bearer sk-test') + && $r['model'] === 'text-embedding-3-small'); +}); + +it('без ключа → пустые векторы (движок отдаст 0% похожести)', function () { + config(['services.aitunnel.key' => '']); + expect(app(AitunnelEmbedder::class)->embed(['x', 'y']))->toBe([[], []]); +}); + +it('ошибка сети → пустые векторы, не падаем', function () { + config(['services.aitunnel.key' => 'sk']); + Http::fake(fn () => throw new ConnectionException('timeout')); + expect(app(AitunnelEmbedder::class)->embed(['x']))->toBe([[]]); +}); + +it('пустой вход → пустой выход', function () { + expect(app(AitunnelEmbedder::class)->embed([]))->toBe([]); +}); From dad1a31fa96a7d570e8d3a951f777304a4a37f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 19:02:28 +0300 Subject: [PATCH 095/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=BF=D0=B8=D1=81=D1=8C?= =?UTF-8?q?=D0=BC=D0=BE=20=C2=AB=D0=BF=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=C2=BB=20=E2=80=94=20=D0=BA=D0=BB?= =?UTF-8?q?=D0=B8=D0=B5=D0=BD=D1=82=20=D0=BD=D0=B5=20=D0=B6=D0=B4=D1=91?= =?UTF-8?q?=D1=82=20=D1=83=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0=20(?= =?UTF-8?q?=D1=84=D0=BE=D0=BD=20+=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunAutopodborSearchJob по завершении (done/empty) шлёт AutopodborReadyMail на contact_email тенанта (с числом найденных + ссылкой на портал). Письмо не роняет подбор при недоступной почте (try/catch). Клиент ставит задачу, работает дальше, получает «готово» письмом. Тесты: SearchJob 4/4 (вкл. Mail::assertSent); Автоподбор unit+feature 183/183; Pint чисто. Co-Authored-By: Claude Opus 4.8 --- .../Autopodbor/RunAutopodborSearchJob.php | 51 ++++++++++++----- app/app/Mail/AutopodborReadyMail.php | 47 ++++++++++++++++ .../views/mail/autopodbor-ready.blade.php | 17 ++++++ .../Autopodbor/RunAutopodborSearchJobTest.php | 55 +++++++++++++------ 4 files changed, 139 insertions(+), 31 deletions(-) create mode 100644 app/app/Mail/AutopodborReadyMail.php create mode 100644 app/resources/views/mail/autopodbor-ready.blade.php diff --git a/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php index fac42f8e..23a571c2 100644 --- a/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php +++ b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php @@ -4,12 +4,14 @@ declare(strict_types=1); namespace App\Jobs\Autopodbor; -use App\Models\AutopodborRun; +use App\Mail\AutopodborReadyMail; use App\Models\AutopodborCompetitor; +use App\Models\AutopodborRun; +use App\Models\Tenant; use App\Services\Autopodbor\Agent\CompetitorAgent; use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest; -use App\Services\Autopodbor\AutopodborDedup; use App\Services\Autopodbor\AutopodborChargeService; +use App\Services\Autopodbor\AutopodborDedup; use App\Support\SystemSettings; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -17,6 +19,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Mail; class RunAutopodborSearchJob implements ShouldQueue { @@ -78,25 +81,28 @@ class RunAutopodborSearchJob implements ShouldQueue if ($unique === []) { $run->update(['status' => 'empty', 'finished_at' => now()]); + $this->notifyReady($run, 0); + return; } - foreach (array_slice($unique, 0, $max) as $c) { + $saved = array_slice($unique, 0, $max); + foreach ($saved as $c) { AutopodborCompetitor::updateOrCreate( [ - 'tenant_id' => $run->tenant_id, + 'tenant_id' => $run->tenant_id, 'search_run_id' => $run->id, - 'dedup_key' => $c['dedup_key'], + 'dedup_key' => $c['dedup_key'], ], [ - 'name' => $c['name'], - 'description' => $c['description'] ?? null, - 'is_federal' => (bool) ($c['is_federal'] ?? false), - 'relevance_pct' => $c['relevance_pct'] ?? null, - 'origin' => 'auto', - 'site_url' => $c['site_url'] ?? null, + 'name' => $c['name'], + 'description' => $c['description'] ?? null, + 'is_federal' => (bool) ($c['is_federal'] ?? false), + 'relevance_pct' => $c['relevance_pct'] ?? null, + 'origin' => 'auto', + 'site_url' => $c['site_url'] ?? null, 'directory_urls' => $c['directory_urls'] ?? [], - 'provenance' => $c['provenance'] ?? [], + 'provenance' => $c['provenance'] ?? [], ] ); } @@ -105,13 +111,30 @@ class RunAutopodborSearchJob implements ShouldQueue $charge->chargeForRun($run, $price); $run->update(['status' => 'done', 'finished_at' => now()]); + $this->notifyReady($run, count($saved)); } catch (\Throwable $e) { $run->update([ - 'status' => 'failed', - 'error_code' => substr($e->getMessage(), 0, 64), + 'status' => 'failed', + 'error_code' => substr($e->getMessage(), 0, 64), 'finished_at' => now(), ]); throw $e; } } + + /** + * Письмо клиенту «подбор готов» — чтобы он не ждал у экрана. Не роняет успешный подбор, + * если почта недоступна (try/catch + report). + */ + private function notifyReady(AutopodborRun $run, int $found): void + { + try { + $email = Tenant::query()->whereKey($run->tenant_id)->value('contact_email'); + if (is_string($email) && $email !== '') { + Mail::to($email)->send(new AutopodborReadyMail($run, $found)); + } + } catch (\Throwable $e) { + report($e); + } + } } diff --git a/app/app/Mail/AutopodborReadyMail.php b/app/app/Mail/AutopodborReadyMail.php new file mode 100644 index 00000000..62a4ff33 --- /dev/null +++ b/app/app/Mail/AutopodborReadyMail.php @@ -0,0 +1,47 @@ +found > 0 + ? "Подбор конкурентов готов: {$this->found} — Лидерра" + : 'Подбор конкурентов готов — Лидерра'; + + return new Envelope(subject: $subject); + } + + public function content(): Content + { + return new Content( + markdown: 'mail.autopodbor-ready', + with: [ + 'found' => $this->found, + 'url' => rtrim((string) config('app.url'), '/').'/autopodbor', + ], + ); + } +} diff --git a/app/resources/views/mail/autopodbor-ready.blade.php b/app/resources/views/mail/autopodbor-ready.blade.php new file mode 100644 index 00000000..4030ee5f --- /dev/null +++ b/app/resources/views/mail/autopodbor-ready.blade.php @@ -0,0 +1,17 @@ +@component('mail::message') +# Подбор конкурентов готов + +@if ($found > 0) +Мы нашли **{{ $found }}** конкурентов по вашему запросу. Откройте портал, чтобы посмотреть список, +сайты, телефоны и ссылки — и выбрать тех, кого взять на проверку. +@else +По вашему запросу новых конкурентов не нашлось. Попробуйте уточнить направление или регион в портале. +@endif + +@component('mail::button', ['url' => $url]) +Открыть подбор +@endcomponent + +С уважением, +Лидерра +@endcomponent diff --git a/app/tests/Feature/Autopodbor/RunAutopodborSearchJobTest.php b/app/tests/Feature/Autopodbor/RunAutopodborSearchJobTest.php index 9ee0bd2e..25bfa4ea 100644 --- a/app/tests/Feature/Autopodbor/RunAutopodborSearchJobTest.php +++ b/app/tests/Feature/Autopodbor/RunAutopodborSearchJobTest.php @@ -2,16 +2,20 @@ declare(strict_types=1); -use App\Models\AutopodborRun; -use App\Models\AutopodborCompetitor; -use App\Models\Tenant; -use App\Models\SystemSetting; use App\Jobs\Autopodbor\RunAutopodborSearchJob; +use App\Mail\AutopodborReadyMail; +use App\Models\AutopodborCompetitor; +use App\Models\AutopodborRun; +use App\Models\SystemSetting; +use App\Models\Tenant; use App\Services\Autopodbor\Agent\CompetitorAgent; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Mail; +use Tests\Concerns\SharesSupplierPdo; +use Tests\Doubles\EmptyCompetitorAgent; -uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); function runSearchJob(int $runId): void { @@ -21,15 +25,15 @@ function runSearchJob(int $runId): void it('успешный подбор: сохраняет конкурентов, списывает, status=done', function () { $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']); SystemSetting::updateOrCreate(['key' => 'autopodbor_max_competitors'], ['value' => '15', 'type' => 'int']); $run = AutopodborRun::create([ - 'tenant_id' => $tenant->id, - 'kind' => 'search', - 'status' => 'queued', + 'tenant_id' => $tenant->id, + 'kind' => 'search', + 'status' => 'queued', 'region_code' => 16, - 'params' => ['examples' => ['okna.ru'], 'about_self' => [], 'include_federal' => true], + 'params' => ['examples' => ['okna.ru'], 'about_self' => [], 'include_federal' => true], ]); runSearchJob($run->id); @@ -40,17 +44,34 @@ it('успешный подбор: сохраняет конкурентов, с ->and((string) $tenant->fresh()->balance_rub)->toBe('99500.00'); }); +it('после готового подбора шлёт письмо «готово» на почту тенанта', function () { + Mail::fake(); + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00', 'contact_email' => 'client@demo.local']); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']); + SystemSetting::updateOrCreate(['key' => 'autopodbor_max_competitors'], ['value' => '15', 'type' => 'int']); + $run = AutopodborRun::create([ + 'tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'queued', + 'region_code' => 16, 'params' => ['examples' => ['okna.ru'], 'about_self' => [], 'include_federal' => true], + ]); + + runSearchJob($run->id); + + expect($run->fresh()->status)->toBe('done'); + Mail::assertSent(AutopodborReadyMail::class, fn ($m) => $m->hasTo('client@demo.local')); +}); + it('пустой результат: status=empty, без списания', function () { - app()->bind(CompetitorAgent::class, \Tests\Doubles\EmptyCompetitorAgent::class); + app()->bind(CompetitorAgent::class, EmptyCompetitorAgent::class); $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']); $run = AutopodborRun::create([ - 'tenant_id' => $tenant->id, - 'kind' => 'search', - 'status' => 'queued', + 'tenant_id' => $tenant->id, + 'kind' => 'search', + 'status' => 'queued', 'region_code' => 16, - 'params' => ['examples' => [], 'about_self' => [], 'include_federal' => false], + 'params' => ['examples' => [], 'about_self' => [], 'include_federal' => false], ]); runSearchJob($run->id); @@ -62,7 +83,7 @@ it('пустой результат: status=empty, без списания', fun it('повторный подбор не дублирует известных конкурентов и не списывает (сквозной дедуп)', function () { $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + 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']); From d5f081f6fc1e237ebdf7ee4cede753de2354ba27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 19:11:39 +0300 Subject: [PATCH 096/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D1=82-=D0=BF=D1=80=D0=BE=D0=BC=D0=BF=D1=82?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B4=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=E2=80=94=20=D0=B6=D0=B8=D0=B2=D0=BE=D0=B9=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=82=D0=B0=D0=BB=20:8001=20(=D0=B1=D0=B5=D0=BB?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD)=20+=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BE=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=8F=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-30-KONTEKST-portal-live.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-KONTEKST-portal-live.md diff --git a/docs/superpowers/plans/2026-06-30-KONTEKST-portal-live.md b/docs/superpowers/plans/2026-06-30-KONTEKST-portal-live.md new file mode 100644 index 00000000..e183e0c0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-KONTEKST-portal-live.md @@ -0,0 +1,56 @@ +# Контекст продолжения: живой портал автоподбора (после компакта) + +> Скопируй блок ниже в новый промпт. Воркстри `worktree-avtopodbor`. **НЕ прод. Коммит — спросив +> «эскейп». Пуш — только gitea. Простым языком — владелец не программист.** + +--- + +Продолжаем фичу **«Автоподбор конкурентов»** Лидерры. Движок шага 1 СОБРАН и подключён к живому порталу +за флагом; владелец пытается прогнать сам в браузере — **портал на :8001 НЕ ОТКРЫВАЕТСЯ (белый экран, +браузер крутит загрузку)**. Это ПЕРВОЕ, что чиним. Воркстри `worktree-avtopodbor` (код под `app/`). + +🔴 **ГЛАВНОЕ СЕЙЧАС — починить отдачу портала на http://127.0.0.1:8001** (белый экран). Это +локальный dev-портал ИЗ ВОРКСТРИ с настоящим движком. Гипотезы для systematic-debugging: +- `php artisan serve` = `php -S` **однопоточный** — если один запрос завис, остальные ждут. Проверить, + не висит ли серв на медленном запросе/редиректе. +- Жив ли серв-процесс :8001? `curl -s -m 5 http://127.0.0.1:8001 | head` — отдаёт HTML или висит/пусто? +- Vite/ассеты: `public/build/manifest.json` есть; но dev может ждать `npm run dev`. Проверить, не на + Vite-dev ли завязан layout (если да — собрать `npm run build` или поднять vite). +- Логи: `app/storage/logs/laravel.log` (хвост) — PHP-фатал/исключение при рендере? +- Не редирект ли на HTTPS / на внешний URL (APP_URL=http://localhost, порт 8001 не совпадает)? +- Проверить чистый эндпоинт: `curl -s -m 5 http://127.0.0.1:8001/api/health` (или любой лёгкий route). + +ЧТО УЖЕ СДЕЛАНО (всё на gitea, ветка `worktree-avtopodbor`, HEAD **062d46e1**): +- **Весь движок шага 1** A (резолвер) / B (парсер выдачи) / C (отсев агрегаторов) / D (телефоны DaData) / + E (слияние+дедуп union-find) / F (похожесть) / H-ядро (`FindCompetitorsAssembler`). Тесты модуля + Автоподбора **183/183**, Pint чисто. +- **Живой `findCompetitors`** (`LiveFindCompetitors`): ниша → поиск 2ГИС(xfetch)+Яндекс(xfetch+ + Playwright-fallback) → резолв карточек → сборка. `LivePageFetcher`. За флагом `config('autopodbor.real_find')`. +- **AITUNNEL подключён и ПРОВЕРЕН ЖИВЬЁМ:** эмбеддинги (1536-dim, 0.8с) = % похожести; LLM-классификатор + (gpt-4o-mini, ~20с/вызов) = отсев площадок (живой тест: «Авито»→агрегатор, «КрасЛомбард»→поставщик ✓). + Классы `AitunnelEmbedder`, `AitunnelAggregatorClassifier`. Деградируют при пустом ключе. +- **Письмо «подбор готов»** (`AutopodborReadyMail`) — фон + уведомление на `contact_email` тенанта, когда + задание завершилось (клиент не ждёт у экрана). `RunAutopodborSearchJob.timeout=900`. +- **ЖИВОЙ ПРОГОН ДВИЖКА УДАЛСЯ** (до AITUNNEL): по «ломбард/Красноярск» нашёл 5 реальных конкурентов + (Сибломбард, Ломбард Айклауд, Золотая жила, Свой Комиссионный, КрасЛомбард). Починены 2 бага: Яндекс-город + в заголовке не на фикс. месте (titleHasCity) + ретрай флакующей выдачи 2ГИС. + +ОКРУЖЕНИЕ (волатильное — перепроверить командой): +- Портал :8001 = `php -S` из воркстри (`php artisan serve --port=8001`), фронт собран + (`public/build/manifest.json`), БД локальная `liderra` (postgres), Redis Memurai :6379. +- **Очередь:** `php artisan queue:work redis --timeout=900` из воркстри (перезапускать при смене кода — + queue:work кэширует!). Был pid 18792 — перепроверить, жив ли. +- **Секреты в `app/.env`** (gitignored, на диске лежат): `AUTOPODBOR_REAL_FIND=1`, + `AITUNNEL_API_KEY=sk-aitunnel-iAfm51jHXBnFmfCNVoCD8TH0IMzOdgOE`, `XFETCH_API_KEY` (свежий). В гит — НИКОГДА. +- Тесты: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor tests/Feature/Autopodbor`; тест-БД + `liderra_testing`; флаг real_find в тестах принудительно ВЫКЛ через `phpunit.xml`. lefthook не в PATH. +- PHP `c:/tools/php83/php`. Скрипты живой проверки — в scratchpad (`live_find_run.php`, `aitunnel_check.php`). + +КАК ПРОГНАТЬ (цель владельца — нажать самому): :8001 → вход `admin@demo.local`/`password` → раздел +«Автоподбор/Конкурентное поле» → ниша «ломбард» + регион Красноярский край + свой сайт + ≥1 пример → +запуск (фон ~7–10 мин: живой обход + ИИ ~20с/компания) → результат + письмо «готово». Письмо идёт на +`contact_email` тенанта — у демо-тенанта проверить, что оно задано (иначе письма нет, но результат в UI есть). + +ПОРЯДОК: 1) починить белый экран :8001 (systematic-debugging, НЕ гадать); 2) дать владельцу прогнать; +3) по фидбэку владельца (он знает рынок) — калибровать. Канал 0 (портал lkomega) ВЫКИНУТ (владелец = Омега, +сам даёт нишу). Коммит — «эскейп», пуш — gitea. From 4fb60ba9c74e0f8934d8db1ae6f0a498e35f62da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 20:03:48 +0300 Subject: [PATCH 097/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B7=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=D0=B4=D0=BD=D0=B8=D0=B9=20=D0=B4=D0=B2=D0=B8?= =?UTF-8?q?=D0=B6=D0=BE=D0=BA=20=C2=A712=20v4=20+=20=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20PHP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Свод воедино: методика §12 (3 канала А/В/0 → резолвер → DaData → дедуп → эмбеддинги → DTO) + все дословные промпты (5 моделей-исследователей, нормализатор, фильтр агрегаторов). Честная сверка: PHP LiveFindCompetitors собрал только нижнюю половину (резолвер/дедуп/похожесть/телефоны) и берёт 1 фразу/1 страницу → 3 находки; верхняя половина (АНАЛИЗ+мульти-запрос+ пагинация канала А, канал В 5-моделей) не перенесена. Эталон работы движка — прогон 29.06 (72 конкурента). План переделки PHP под §12 один-в-один. Co-Authored-By: Claude Opus 4.8 --- ...6-30-DVIZHOK-voedino-metodika-i-prompty.md | 212 ++++++++++++++++++ ...AFIKSIROVANO-chto-est-chto-peredelyvaem.md | 94 ++++++++ 2 files changed, 306 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-30-DVIZHOK-voedino-metodika-i-prompty.md create mode 100644 docs/superpowers/specs/2026-06-30-ZAFIKSIROVANO-chto-est-chto-peredelyvaem.md diff --git a/docs/superpowers/specs/2026-06-30-DVIZHOK-voedino-metodika-i-prompty.md b/docs/superpowers/specs/2026-06-30-DVIZHOK-voedino-metodika-i-prompty.md new file mode 100644 index 00000000..6dd86e2f --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-DVIZHOK-voedino-metodika-i-prompty.md @@ -0,0 +1,212 @@ +# ДВИЖОК «Автоподбор конкурентов» — ВСЁ ВОЕДИНО: методика + промпты + честная сверка + +> **Зачем файл:** владелец 30.06 указал, что живой `findCompetitors` собран НЕ по проекту. +> Здесь сведены воедино: (1) методика финального движка **v4 (§12)** — её 2 дня гоняли вживую; +> (2) **все дословные промпты ИИ**; (3) методика шага 2 (изучение); (4) **честная сверка** — что +> из §12 реально собрано в PHP, а что я недоделал/сделал не так. +> +> **Источники истины (durable):** +> - §12 «ФИНАЛЬНЫЙ ДВИЖОК v4» + §8–§11 (промпты/проходы) — `docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-brainstorm-notes.md` **(ОСНОВНОЙ репо, не воркстри!)**, строки 336–675. +> - План сборки A–H — `docs/superpowers/plans/2026-06-30-step1-real-findcompetitors.md`. +> - Контракт «розетки» §7 — `…-autopodbor-konkurentov-design.md`. +> - Движок шага 2 — `plans/2026-06-30-real-engine-step2-core.md` (Plan A) + `…-step2-fetch-agent.md` (Plan B) + `findings/2026-06-30-real-engine-step2-research.md`. + +--- + +## 0. ЖЁСТКИЕ решения владельца (закрыты — не пересматривать без «меняем») + +- **ИНН ВЫКИНУТ ПОЛНОСТЬЮ** (29.06). Никаких ИНН/контрольных сумм/DaData-party-по-ИНН (это была отвергнутая v3, §11). Карточка конкурента = **имя / сайт(ы) / телефоны / ссылка 2ГИС / ссылка Яндекс** — и всё. +- **Система УНИВЕРСАЛЬНА** для любых клиентов/рынков. Омега — лишь тест-пример. Всё клиентское (регион/город/профиль/запросы/примеры) — из конфига/формы, в коде не зашито. +- **Похожесть = ЭМБЕДДИНГИ**, не «оценка модели» (ставка на «модель сама даст %» провалилась). AITUNNEL `text-embedding-3-small`, косинус по **имя+описание** (описание обязательно). +- **Агрегаторы отсекаются ИИ-классификатором** (рассуждением), не статическим списком. +- **`is_federal` ПО ФАКТУ**: нет местной карточки + есть сайт = федерал; есть карточка = местный. Модели тут НЕ верить (звала местный «Автоломбард Экспресс» федералом). +- **Телефоны НЕ синтезируем** (городской↔мобильный не конвертируется — доказано 5 источниками). Только опубликованные фирмой номера. +- **Якорю от модели верить НЕЛЬЗЯ** — модель фабрикует домены и firm-id. Модель даёт только ИМЕНА; адреса/сайты/телефоны добываются настоящими инструментами и проверяются нашим кодом/резолвером. + +--- + +## 1. АРХИТЕКТУРА v4 (§12.1) — 3 канала добычи имён → одно ядро + +``` +ВХОД (из формы клиента): регион + профиль деятельности + примеры конкурентов клиента + │ + ├─ КАНАЛ 0 «уже на прозвоне» — проекты клиента с портала поставщика crm.bp-gr.ru + │ (Playwright-логин lkomega → «Мои Проекты» → сайты/телефоны). Самый сильный сигнал. + ├─ КАНАЛ А справочники 2ГИС + Яндекс: МУЛЬТИ-ЗАПРОС (из шага АНАЛИЗ) + ПАГИНАЦИЯ всех страниц + └─ КАНАЛ В федералы/онлайн: 5 ИИ-моделей дают ИМЕНА → ИИ-фильтр агрегаторов → резолвер + │ + ▼ каждое имя → ЕДИНЫЙ РЕЗОЛВЕР: реальная карточка в регионе (2ГИС firm/, Яндекс org/) + ЛИБО честно «нет филиала в регионе»; + имя/описание/сайты/телефоны/регион/is_federal + ▼ ТЕЛЕФОНЫ → DaData clean/phone (7XXXXXXXXXX + тип город/моб/8-800 + регион + годность) + ▼ СЛИЯНИЕ+ДЕДУП (union-find по имени-транслит / корню домена / телефону) + вычет клиента + ▼ ПОХОЖЕСТЬ — эмбеддинги по имя+описание (НЕ «мнение модели») + ▼ DTO findCompetitors (§7.2) → карточки в портале (флаги: ☎ на прозвоне / 🌍 федерал / нет филиала) +``` + +**Доказано на живом тесте (Омега/Красноярск 29.06):** Канал А = 6 запросов × пагинация → **44 фирмы** (с одной страницы было 16 — «вижу половину»). Канал В → 5 истинных федералов. Канал 0 → 27 «на прозвоне». А+В = **53 чистой находки**, из них **12 вслепую совпали с прозвоном клиента** + 41 новый. Движок реально откапывает. + +--- + +## 2. КАНАЛ А — справочники (мульти-запрос + пагинация) + +1. **Шаг АНАЛИЗ** (по профилю + примерам клиента) → НЕСКОЛЬКО поисковых запросов-рубрик (в тесте Омеги — 6). Не одна строка, а набор формулировок ниши. +2. По каждому запросу — скрейп **страницы категории** 2ГИС: `2gis.ru/<город>/search/<запрос>` → реальные фирмы (firm-id + юр-название), **с листанием ВСЕХ страниц** (не первая). +3. То же по Яндекс.Картам (`/maps/?text=<запрос>`). +4. Каждое имя/firm-id → в единый резолвер. + +**Рендер (§12.2) — открытый под-вопрос транспорта:** +- v4 (29.06): «xfetch УМЕР» → 2ГИС через **firecrawl** (`api.firecrawl.dev/v1/scrape`, `formats:["rawHtml"]`, антибот). Локальный headless 2ГИС режет (пустая оболочка). +- Шаг 1 30.06: **xfetch снова жив** (на нём весь шаг 2; `POST https://xf4.ru/fetch`, `render:true`, base64). ⚠️ На СТРАНИЦЕ ПОИСКА 2ГИС firecrawl отдаёт капчу/пусто — соседние филиалы не перечисляет, поэтому discovery списка — только xfetch. +- Яндекс — **локальный Playwright** (бесплатно, рендерит отлично). +- Параллельность пул **3** (8 — сервисы захлёбываются). Кэш страниц на диск (НЕ обрезать base64 — ломает пагинацию). + +--- + +## 3. КАНАЛ В — федералы/онлайн (5 моделей → нормализатор → чистильщик → loop-until-dry) + +**Конструкция одного прохода (§10.1):** +`Форма клиента → 5 исследователей (ПАРАЛЛЕЛЬНО) → нормализатор deepseek-v4-flash (склейка) → чистильщик (НАШ КОД, curl/резолвер) → добавить живых в накопленный список.` +Многопроходность: повторять с **РАСТУЩИМ стоп-листом**, пока проход не «иссякнет» (≈0 живых новых). Тест Омеги: проход1=15 → проход2=+7 (=22) → проход3 иссяк (потолок ≈22). + +**5 моделей-исследователей** (`temperature:0`, параллельно): `sonar`, `sonar-pro`, `sonar-pro-search`, `sonar-reasoning-pro` (Perplexity) + `gpt-4o-mini-search-preview` (OpenAI). Рекомендованная тройка-по-умолчанию: `sonar` + `sonar-reasoning-pro` + `sonar-pro-search` (~1 мин, ≈22 ₽). `deep-research` НЕ нужен (дорого/медленно). + +### 3.1. ПРОМТ ИССЛЕДОВАТЕЛЯ (§10.2, нейтральный финальный — для ВСЕХ проходов) +> ⚠️ Без слова «N-й/второй проход» — оно усыпляет модели (возвращают пусто). + +**SYSTEM:** +``` +Ты — поисковик конкурентов для нашей компании. Используй интернет и справочники 2ГИС и Яндекс.Карты. Найди НОВЫХ реальных конкурентов в указанном регионе — чем больше, тем лучше. +В запросе дан список уже известных конкурентов (карточки). НЕ выводи их и не повторяй (ни под другим написанием, ни по другому домену) — ищи только тех, кого в списке НЕТ. Если новых не нашёл — верни пустой массив []. Не дублируй и не выдумывай ради объёма. +ПРАВИЛО ЯКОРЯ (для КАЖДОЙ новой компании обязательно). Укажи хотя бы одно из: +- site — голый домен СОБСТВЕННОГО сайта компании, ИЛИ +- card_2gis — карточка фирмы в 2ГИС: 2gis.ru/<город>/firm/ ИЛИ 2gis.ru/<город>/branches/, ИЛИ +- card_yandex — организация в Яндекс.Картах: yandex.ru/maps/org/.../. +Якорь — это ТОЛЬКО собственный сайт компании или её карточка фирмы в 2ГИС/Яндекс. Любая другая страница (каталог, агрегатор, рейтинг, статья, отзовик, 2ГИС-ПОИСК .../search/...) якорем НЕ является — её можно дать только в source. Домен НЕ выдумывай. Не нашёл ни сайта, ни карточки фирмы — компанию НЕ включай. +ФОРМА ОТВЕТА — строго JSON-массив, без текста вне него и без markdown: +[{"name":"Название","site":"домен"|null,"card_2gis":"https://2gis.ru/.../firm|branches/..."|null,"card_yandex":"https://yandex.ru/maps/org/..."|null,"source":"ссылка где взял","type":"региональная"|"федеральная","relevance":0-100}] +В каждом элементе хотя бы одно из site/card_2gis/card_yandex ОБЯЗАНО быть не null. Только реальные компании. +``` +**USER** (стоп-лист = накопленные карточки; на 1-м проходе = примеры клиента): +``` +Наша компания: <профиль деятельности> в <регион>. Наш сайт: <сайт клиента>. +Уже известные конкуренты (карточки) — НЕ выводить их повторно: + +Найди ТОЛЬКО НОВЫХ конкурентов в <регион>, которых нет среди этих карточек. Ответ — строго в формате из системного промта. +``` + +### 3.2. ПРОМТ НОРМАЛИЗАТОРА (§10.3, `deepseek-v4-flash`, temperature:0) +> НЕ строгий (фильтр — у кода; строгий промт над-режет и теряет реальных). Вход — склейка всех ответов исследователей этого прохода. + +**SYSTEM:** +``` +Ты — нормализатор-СКЛЕЙЩИК. Дан материал — карточки конкурентов из нескольких источников. Склей в ОДИН JSON-массив без дублей. Только JSON. +Включи ВСЕ компании, не суммаризируй. Склеивай в один элемент только явные дубли (тот же домен/карточка/название). Сохраняй ВСЕ якоря (site, card_2gis, card_yandex). Бери только из материала, не выдумывай, нет поля — null. +Схема: {"name","site","card_2gis","card_yandex","type","relevance"}. Верни ТОЛЬКО JSON-массив. +``` + +### 3.3. ЧИСТИЛЬЩИК (наш КОД — не LLM, §10.4) +- **site:** жив, если домен ответил любым HTTP-кодом (вкл. 401/403/503 — банки/защита). Таймаут/нет домена = фейк. Punycode для `.рф`. +- **card_2gis / card_yandex:** HTTP 200–399 = жива; 404 = фейк-id. +- Запись остаётся, если жив **хоть один** якорь (мульти-якорь спасает). +- **Клиент** исключается по СВОЕМУ якорю из формы (домен + firm-id), НЕ по имени. +- `found_by` и `relevance %` считает наш код, НЕ модель. +- 🥇 **Лучшее для прода:** не курлить домены от модели, а **резолвить имя через резолвер 2ГИС/Яндекс** — фейков физически не будет. (Совпадает с «единым резолвером».) + +### 3.4. ИИ-фильтр агрегаторов (§12.6) +Авито/Юла/Zoon/Банки.ру/Сравни/1000bankov и т.п. — НЕ конкуренты. Статический список мёртв (площадки плодятся). → **ИИ-классификатор** (`sonar-reasoning-pro`), рассуждением: +> «это поставщик услуги или площадка-агрегатор?» + +--- + +## 4. КАНАЛ 0 — портал поставщика (самый сильный сигнал) +Playwright-логин **lkomega** на crm.bp-gr.ru (Yii2: `#loginform-username`) → «Мои Проекты» (`/admin/visit/rt`) → сайты/телефоны проектов клиента «уже на прозвоне». Доступ: `SUPPLIER_LOGIN/SUPPLIER_PASSWORD` в `app/.env`. Playwright — `require` по абсолютному пути (`node_modules/playwright`; NODE_PATH не работает). +> ⚠️ Владелец 30.06: для Омеги канал 0 он закрывает собой («я сам Омега, знаю рынок»). Для УНИВЕРСАЛЬНОГО продукта канал 0 в дизайне остаётся (через интеграцию с поставщиком). + +--- + +## 5. ЕДИНЫЙ РЕЗОЛВЕР (§12.3) — имя → настоящая карточка +- Вход: имя (+ firmId 2ГИС, если есть из канала А). +- **2ГИС:** firmId (или поиск по имени → совпадение) → карточка → блок `contact_groups` (телефоны `type=phone/value`; сайт `type=website/url` — чистый, НЕ редирект `link.2gis`); рубрики+meta = описание; город из адреса = «в регионе». ⚠️ Регексп терпим к `?stat=...` после id. +- **Яндекс:** поиск по имени → ПЕРВЫЙ org, проверка **имя(нестрого)+категория(профиль)+город** (иначе чужая фирма с тем же именем); блок `phones[].value`, `urls[]`, `categories[]`; прямая ссылка `/maps/org//`. +- Ни 2ГИС-в-городе, ни Яндекс-совпал → `local=false` → UI «нет филиала в [регион]» (НЕ ссылка в пустой поиск). +- `is_federal` ПО ФАКТУ (см. §0). + +--- + +## 6. ТЕЛЕФОНЫ (§12.4, DaData) — закрыто +- Источник номеров — собственный блок контактов фирмы (2ГИС `contact_groups` + Яндекс `phones[]`), НЕ вся страница. +- DaData `cleaner.dadata.ru/api/v1/clean/phone` (ключ + X-Secret, **по одному номеру**): формат `+7…`, тип (Городской/Мобильный/Прямой мобильный/Колл-центр=8-800), регион, qc(0=валиден). Храним `7XXXXXXXXXX`, **оба вида** (городской И мобильный) — **каждый набираемый номер = отдельный источник = отдельный проект**, дедуп только точного совпадения. +- DaData-party (по ИНН) НЕ даёт ни сайт, ни телефоны — DaData теперь ТОЛЬКО телефонный стандартизатор. + +--- + +## 7. ПОХОЖЕСТЬ (§12.5) — эмбеддинги +AITUNNEL `text-embedding-3-small` — «числовой отпечаток» примеров клиента и кандидата (**имя+описание+профиль**) → косинус → %. Описание (рубрики 2ГИС/Яндекс) **обязательно** — иначе % меряет «красоту имени». Подтверждено владельцем по рынку: наверху МФО/банки/авто-специалисты, ниже общевещевые ломбарды. **NB:** общевещевые ломбарды владельцу нужны — не деприоритизировать в ноль. + +--- + +## 8. МОДЕЛИ / КЛЮЧИ / ЦЕНА +- **Шлюз AITUNNEL** (`https://api.aitunnel.ru/v1`, OpenAI-совместимый) — чат-модели (sonar*/gpt-mini/deepseek) + эмбеддинги. Исключены `grok-4.20`, `gemini-2.5-pro`, `gpt-4o-search-preview` (выдумывают / веб не активен); `gemini-2.5-pro` годен лишь как нормализатор. +- **Ключи (только в окружении/`app/.env`, НЕ в гит):** firecrawl `FIRECRAWL_API_KEY`, DaData ключ+X-Secret, AITUNNEL `AITUNNEL_API_KEY`, exa `EXA_API_KEY`, xfetch `XFETCH_API_KEY`, портал `SUPPLIER_LOGIN/PASSWORD`. +- **Цена:** 2ГИС через firecrawl ≈ 1 кредит/страница, полный подбор ≈ 50 кредитов; Яндекс 0 ₽; модели/DaData/exa — копейки. Себестоимость подбора = центы ≪ цена клиенту. + +--- + +## 9. ОТКРЫТЫЕ вопросы (не закрывать без владельца) +- **Кто считает `relevance %`** — §8.4 vs §5: модель даёт вразнобой (0/«102»/всем 100). Решено по сути: **% считает наш слой эмбеддингами** (§12.5) — но формально как открытый помечен. +- **Транспорт 2ГИС:** xfetch (сейчас жив, на нём шаг 2) vs firecrawl (после падения xfetch). Решать на канале А/H. +- **Выкидывать ли `gpt-4o-mini-search-preview`** (хронически фабрикует якоря) — открыто. + +--- + +## 10. 🔴 ЧЕСТНАЯ СВЕРКА — что в PHP реально собрано vs §12 + +> Это самое важное. Что я реально подключил в живой прогон (`LiveFindCompetitors`) против §12. + +| Блок §12 | По проекту | Что в PHP реально | Вердикт | +|---|---|---|---| +| **Канал А: шаг АНАЛИЗ → НЕСКОЛЬКО запросов-рубрик** | мульти-запрос из анализа профиля+примеров | **НЕТ.** Беру `about_self[0]` (длинную фразу клиента) как ОДНУ поисковую строку | ❌ **не сделано** | +| **Канал А: ПАГИНАЦИЯ всех страниц** | листать все страницы категории (44 vs 16) | **НЕТ.** Одна страница выдачи, потолок 15/6 | ❌ **не сделано** | +| **Канал В: 5 моделей → нормализатор → чистильщик → loop-until-dry** | добыча имён федералов/онлайн ИИ | **НЕ ПОДКЛЮЧЕНО ВООБЩЕ.** Ни 5 моделей, ни нормализатора, ни чистильщика, ни проходов. `include_federal` ведёт только в фильтр, а имена федералов никто не добывает | ❌ **не сделано (главный провал)** | +| **ИИ-фильтр агрегаторов** | sonar-reasoning-pro «поставщик/площадка» | ✅ `AggregatorFilter` + `AitunnelAggregatorClassifier` (на gpt-4o-mini) | 🟡 есть, но модель иная | +| **Канал 0: портал lkomega** | Playwright-логин → проекты | **НЕТ** (блок G не делал; для Омеги владелец закрыл собой) | ⏸ отложено осознанно | +| **Роль примеров клиента** | затравка стоп-листа + база похожести | Примеры идут ТОЛЬКО в похожесть; для ПОИСКА не используются | ❌ **не по проекту** | +| **Единый резолвер (2ГИС/Яндекс карточка)** | имя → карточка, is_federal по факту | ✅ `TwoGisResolver`/`YandexResolver`/`CompetitorResolver` | ✅ ок | +| **Телефоны DaData** | по одному номеру, оба вида | ✅ `CompetitorPhoneEnricher` (переиспользует `DaDataPhoneClient`) — но в живой проводке берутся из карточки резолвера; проверить, дергается ли DaData | 🟡 проверить проводку | +| **Слияние/дедуп union-find + вычет клиента** | по имени-транслит/домену/телефону | ✅ `AutopodborDedup.mergeCompetitors` | ✅ ок | +| **Похожесть-эмбеддинги** | text-embedding-3-small, имя+описание | ✅ `EmbeddingRelevance` + `AitunnelEmbedder` | ✅ ок | +| **Промпты §8.3/§10.2/§10.3** | дословные, протестированы | **НИ ОДИН не подключён** в PHP | ❌ **не сделано** | + +**Вывод одной строкой:** я собрал и проводил только «нижнюю половину» движка (резолвер → дедуп → телефоны → похожесть) и скормил ей ОДНУ поисковую строку из фразы клиента. **Вся «верхняя половина» — добыча имён (канал А мульти-запрос+пагинация и канал В с 5 моделями/нормализатором/чистильщиком/проходами) — не построена.** Поэтому 3 находки, низкие %, примеры проигнорированы. + +--- + +## 11. Что переделать в шаге 1 (план фикса — по §12) + +1. **Шаг АНАЛИЗ** — из профиля+примеров сгенерировать НЕСКОЛЬКО запросов-рубрик для канала А (ИИ или резолв примеров → их рубрики). +2. **Канал А с пагинацией** — мульти-запрос × все страницы 2ГИС (xfetch/firecrawl) + Яндекс (Playwright) → firm-id/имена. +3. **Канал В** — построить целиком: 5 моделей (промпт §10.2) ∥ → нормализатор (§10.3) → чистильщик/резолвер → loop-until-dry с растущим стоп-листом; на входе 1-го прохода стоп-лист = примеры клиента. +4. **Примеры клиента** — использовать как затравку (стоп-лист канала В + база похожести). +5. Прогнать сквозной живой тест на нише Омеги, сверить с §12.7 (ждём ≈40–50 находок, не 3). + +--- + +## 12. ДВИЖОК ШАГА 2 (studyCompetitor) — методика (готов, в бою; не трогать) + +- **Где лежит настоящий номер (по доверию):** `tel:`-href → schema.org `"telephone"` → microdata `itemprop=telephone` → e-mail с цифрами. Подмена — пул коллтрекинга (roistat/calltouch/comagic/uiscom/mango/callibri/ringostat/phonet). +- **Настоящий ✓** — номер в доверенном месте ИЛИ статичный сайт без коллтрекинга. **Подменный 🎭** — видимый отрендеренный номер при активном трекере, которого НЕТ в коде. **Пул ротации** (номер в теле ≥2 раз, не в коде, не видимый) — СКРЫВАЕМ (свалка; у МИРОКОН 69 номеров). +- **Тип** — городской/мобильный/8-800 по коду (`800`→tollfree, первая `9`→mobile, иначе city); DaData опционально уточняет. +- **Глубокий сбор = вся СЕТЬ конкурента:** найти родителя-сеть в 2ГИС (`branches/`), перечислить ВСЕ филиалы (КрасЛомбард — 22 офиса), снять номер/адрес с каждого + все сайты конкурента. Дедуп: один номер из разных мест → один источник со списком «где нашли», сортировка по числу подтверждений, подменные вниз. (КрасЛомбард: 27 настоящих рендером vs 14 из tel:.) +- **Fetch:** свой стек **curl сырого кода + наш Playwright** (рендер для видимых/трудных) — без чужих платных сервисов. xfetch отбросили в шаге 2, firecrawl — без кредитов. +- **Контракт:** `studyCompetitor(StudyCompetitorRequest{competitor{name,site_url?,directory_urls?},regionCode}) → StudyCompetitorResult{sources[]{signal_type,identifier,phone_kind?,phone_type?,provenance_url,provenance_label}}`. Сеть за интерфейсом `Fetcher` (тесты на `FakeFetcher`). +- **Эталоны-макеты:** `prototypes/2026-06-30-konkurentnoe-pole-istochniki-sbor1.html` (первый сбор) и `…-sbor2-povtor.html` (повтор-диф). +- **Честный предел:** голый лендинг с коллтрекингом без карточки и без настоящего в schema/tel → настоящий не вытащить; показываем подменный + «настоящий не найден», не выдумываем. +- **В этих файлах ИИ-промптов НЕТ** — шаг 2 целиком на regex/детекте, без модели. + +--- + +## 13. Незакрытые хвосты движка (из §12.11 + статусов) +- Канал 0 через резолвер (сейчас сырой), усилить дедуп (по корню домена приоритетнее имени), ужесточить domainMatchesName для общих имён. +- Шаг 1: проводка каналов в `RealCompetitorAgent` к живому fetch + флип провайдера за флагом (go-live — отдельное «да» владельца). +- Шаг 2: Яндекс-парсер под реальный DOM, поиск других площадок конкурента, SSRF-хвост Playwright-пути, приёмка/безопасность перед прод-выкатом. +- Риск потери: код движка раньше жил в scratchpad и **самоочистился** — всё новое класть в репозиторий. diff --git a/docs/superpowers/specs/2026-06-30-ZAFIKSIROVANO-chto-est-chto-peredelyvaem.md b/docs/superpowers/specs/2026-06-30-ZAFIKSIROVANO-chto-est-chto-peredelyvaem.md new file mode 100644 index 00000000..c83e97df --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-ZAFIKSIROVANO-chto-est-chto-peredelyvaem.md @@ -0,0 +1,94 @@ +# 🔥 ЗАФИКСИРОВАНО — Автоподбор: ЧТО У НАС / ЧТО ПЕРЕДЕЛЫВАЕМ + +> **Дата заморозки: 30.06.2026.** Это единая точка входа по движку «Автоподбор / Конкурентное поле». +> Цель файла — чтобы НЕ потерять (Node-движок уже раз самоочистился, §13.0) и НЕ уехать снова +> в старьё (v3 ИНН) или в фантазию. **Последний и единственный актуальный движок = §12 v4.** +> +> **НЕ прод. Коммит — спросив «эскейп». Пуш — gitea. Простым языком — владелец не программист.** + +--- + +## 0. ОДНОЙ СТРОКОЙ +Последний движок (§12 v4, каналы А+В) **спроектирован и доказан вживую — вчера дал 72 конкурента**. +Но в **PHP его перенесли только наполовину** (нижнюю), поэтому в портале вышло 3. **Переделываем — +переносим §12 в PHP один-в-один.** + +--- + +## 1. ✅ ЧТО У НАС ЕСТЬ (активы — не потерять, это фундамент) + +### 1.1. Методика — последний движок §12 v4 (ИСТОЧНИК ИСТИНЫ) +- **`docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-brainstorm-notes.md`** (ОСНОВНОЙ репо!), §8–§12: + - §12 — финальная архитектура v4: 3 канала → резолвер → DaData → дедуп → эмбеддинги → DTO. + - §9/§10 — конвейер канала В (5 моделей → нормализатор → чистильщик → проходы) + **дословные промпты**. +- **`docs/superpowers/specs/2026-06-30-DVIZHOK-voedino-metodika-i-prompty.md`** — я свёл ВСЁ воедино: + методика + **все промпты дословно** + методика шага 2 + сверка PHP vs §12. + +### 1.2. Дословные промпты (целы, протестированы) — в сводном доке выше +- Промт 5 исследователей (sonar×4 + gpt-4o-mini-search, параллельно, правило якоря, стоп-лист). +- Промт нормализатора-склейщика (deepseek-v4-flash). +- Формулировка ИИ-фильтра агрегаторов (sonar-reasoning-pro): «поставщик услуги или площадка?». + +### 1.3. ДОКАЗАТЕЛЬСТВО что движок работает — результат вчерашнего прогона (сохранён) +- **`AUTOPODBOR-client-report-top.png`** / **`-full.png`** (корень репо, 29.06 ~15:45 Крск): + **«Найдено 72 конкурента»**, 62 с сайтом, Красноярск, профиль «займ под залог авто/спецтехники». + Видно обе ветки: канал А (карточки 2ГИС/Яндекс) и **«канал В · поиск федералов»** (CarMoney, + CarCapital, CarZalog, Деньги на дом — «нет филиала в регионе»). Флаги «требует проверки». +- `autopodbor-final.png` (14:41 Крск), `autopodbor-AB-top.png`, `autopodbor-top.png` — прогоны А+В. +- **Страницы результата (HTML, durable):** `docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-LIVE-omega.html` + (34 конкурента, «5 поисковиков → склейка → дедуп») и `…-RESULT-page.html`. Это эталон вёрстки выдачи. + +### 1.4. PHP-«розетка» и нижняя половина движка — собрано ВЕРНО (переиспользуем, не трогаем) +Воркстри `worktree-avtopodbor`, на gitea (HEAD `062d46e1`): +- Контракт §7: `CompetitorAgent` (3 метода) + DTO + джобы + биллинг + 3 таблицы RLS + дедуп/нормализатор. +- **Резолвер** `TwoGisResolver`/`YandexResolver`/`CompetitorResolver` (имя→карточка, is_federal по факту). ✅ +- **Слияние/дедуп** `AutopodborDedup.mergeCompetitors` (union-find + вычет клиента). ✅ +- **Похожесть** `EmbeddingRelevance` + `AitunnelEmbedder` (text-embedding-3-small, косинус). ✅ +- **Телефоны** `CompetitorPhoneEnricher` (DaData). ✅ +- **Фильтр агрегаторов** `AggregatorFilter` + `AitunnelAggregatorClassifier`. ✅ (модель уточнить) +- Письмо «готово» + фон + портал :8001 (починен белый экран) — работает. + +### 1.5. Шаг 2 (изучение конкурента) — методика готова +`plans/2026-06-30-real-engine-step2-core.md` (Plan A) + `…-step2-fetch-agent.md` (Plan B) + +`findings/2026-06-30-real-engine-step2-research.md`. Настоящий/подменный номер, глубокий сбор сети, +эталоны-макеты `prototypes/…-istochniki-sbor1.html` / `…-sbor2-povtor.html`. (Отдельный поток, позже.) + +--- + +## 2. ❌ ЧТО ПЕРЕДЕЛЫВАЕМ (PHP-проводка `LiveFindCompetitors` — НЕ §12) + +То, что крутилось в портале (`app/Services/Autopodbor/Agent/LiveFindCompetitors.php`) — это НЕ §12. +В нём отсутствует ВСЯ «верхняя половина» (добыча имён). Переносим из §12: + +| # | Что переделать | Сейчас в PHP | Должно по §12 | +|---|---|---|---| +| 1 | **Шаг АНАЛИЗ** | нет | профиль+примеры → НЕСКОЛЬКО запросов-рубрик (в тесте — 6) | +| 2 | **Канал А: пагинация** | 1 страница, потолок 15/6 | мульти-запрос × **все страницы** 2ГИС/Яндекс (44 vs 16) | +| 3 | **Канал В целиком** | НЕТ (только фильтр) | 5 моделей (промпт §10.2) ∥ → нормализатор (§10.3) → чистильщик/резолвер → **проходы loop-until-dry** с растущим стоп-листом | +| 4 | **Примеры клиента как затравка** | идут только в похожесть | стоп-лист 1-го прохода канала В = примеры клиента | +| 5 | **Подключить промпты §8.3/§10.2/§10.3** | ни один не подключён | подключить дословно | +| 6 | **Вход: убрать «фразу как 1 запрос»** | `about_self[0]` → 1 поиск | профиль → АНАЛИЗ → мульти-запрос | + +**Остаётся как есть (не трогаем):** резолвер, дедуп, эмбеддинги-похожесть, телефоны DaData, +фильтр агрегаторов, DTO §7.2, биллинг, джобы, таблицы, UI, письмо, портал. + +--- + +## 3. ПЛАН ПЕРЕДЕЛКИ (по §12, по TDD, без интернета на фикстурах) +1. **Шаг АНАЛИЗ** — из профиля+примеров сгенерировать список запросов-рубрик для канала А. +2. **Канал А с пагинацией** — мульти-запрос × все страницы (2ГИС xfetch/firecrawl + Яндекс Playwright) → firm-id/имена → резолвер. +3. **Канал В** — построить целиком по §9/§10: 5 моделей (промпт §10.2, temperature:0, параллельно) → нормализатор (§10.3) → чистильщик/резолвер → loop-until-dry с растущим стоп-листом (стоп-лист прохода 1 = примеры клиента). +4. **Слияние** каналов А+В → дедуп → эмбеддинги-похожесть → DTO (нижняя половина уже есть). +5. **Сквозной живой прогон** на нише Омеги → сверить с эталоном (`AUTOPODBOR-client-report-top.png`): ждём ~70, не 3. +6. Флип провайдера/прод — отдельное «да» владельца. + +--- + +## 4. ⚠️ ЖЁСТКИЕ правила (не пересматривать без «меняем») +ИНН выкинут · система универсальна (всё клиентское — из конфига) · похожесть = эмбеддинги (не модель) · +агрегаторы = ИИ-классификатор · is_federal по факту · телефоны не синтезируем · якорю модели не верить +(только наш резолвер/проверка) · слово «N-й проход» в промте НЕЛЬЗЯ (усыпляет модели) · секреты только в +`app/.env` · НЕ прод · коммит — «эскейп» · пуш — gitea. + +> **Где код движка сейчас:** Node-движок v4 (resolver.js/channelA/B/merge/daphone) **самоочистился из +> scratchpad 29.06** — на диске его нет. Воспроизводим в PHP по §12 (методика+промпты целы). From f96c63568444630699c5a131260cdc21ecbb22d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 21:06:10 +0300 Subject: [PATCH 098/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B4=D0=B2=D0=B8=D0=B6?= =?UTF-8?q?=D0=BE=D0=BA=20=D1=88=D0=B0=D0=B3=D0=B0=201=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D0=BE=D0=B1=D1=80=D0=B0=D0=BD=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=20v4=20(=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D1=8B=20=D0=90+=D0=92,=20EXA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замена вырожденного «одна фраза → одна страница» на §12/§11.3 финал: - Шаг АНАЛИЗ (ChannelA\AitunnelQueryAnalyzer): описание → запросы-рубрики (мелкая модель). - Канал А (ChannelA\CategoryScraper): скрейп категории 2ГИС с пагинацией → резолв карточек. - Канал В (ChannelB\*): ОДНА модель sonar-reasoning-pro × 2 прохода → ТОЛЬКО имена федералов; стоп-лист = имена из А + примеры; сайт федерала через EXA (ExaSiteFinder), т.к. у федерала нет карточки в 2ГИС/Яндексе на регион. - Оркестратор LiveFindCompetitors переписан: АНАЛИЗ→А→В→слияние→отсев→дедуп→похожесть→DTO. - Провайдер перепрошит; config services.php +research_model/exa. Похожесть — эмбеддер-модель (математически), резолвер/дедуп — без изменений. Всё за тонкими границами, офлайн-тесты на фикстурах: модуль 130 unit + 74 feature зелёные. Провайдер за флагом autopodbor.real_find; на проде не меняется. Co-Authored-By: Claude Opus 4.8 --- .../Providers/AutopodborServiceProvider.php | 30 ++- .../Agent/ChannelA/AitunnelQueryAnalyzer.php | 98 ++++++++ .../Agent/ChannelA/CategoryScraper.php | 69 ++++++ .../Agent/ChannelA/QueryAnalyzer.php | 16 ++ .../Agent/ChannelB/AitunnelResearcher.php | 51 +++++ .../Agent/ChannelB/ChannelBSearch.php | 89 ++++++++ .../Agent/ChannelB/ExaSiteFinder.php | 98 ++++++++ .../Agent/ChannelB/ResearcherClient.php | 16 ++ .../Agent/ChannelB/ResearcherParser.php | 61 +++++ .../Agent/Extract/HtmlPhoneScanner.php | 6 +- .../Autopodbor/Agent/LiveFindCompetitors.php | 158 ++++++------- app/config/services.php | 12 + app/tests/Unit/Autopodbor/AgentDtoTest.php | 19 +- .../AutopodborNormalizerStripBadgeTest.php | 10 +- .../Autopodbor/AutopodborNormalizerTest.php | 8 +- .../Autopodbor/BalanceTransactionTypeTest.php | 4 +- .../ChannelA/AitunnelQueryAnalyzerTest.php | 65 ++++++ .../ChannelA/CategoryScraperTest.php | 79 +++++++ .../ChannelB/AitunnelResearcherTest.php | 60 +++++ .../ChannelB/ChannelBSearchTest.php | 84 +++++++ .../Autopodbor/ChannelB/ExaSiteFinderTest.php | 68 ++++++ .../ChannelB/ResearcherParserTest.php | 48 ++++ .../Autopodbor/Fetch/CompositeFetcherTest.php | 2 +- .../Autopodbor/LiveFindCompetitorsTest.php | 216 +++++++++--------- ...AFIKSIROVANO-chto-est-chto-peredelyvaem.md | 24 ++ 25 files changed, 1167 insertions(+), 224 deletions(-) create mode 100644 app/app/Services/Autopodbor/Agent/ChannelA/AitunnelQueryAnalyzer.php create mode 100644 app/app/Services/Autopodbor/Agent/ChannelA/CategoryScraper.php create mode 100644 app/app/Services/Autopodbor/Agent/ChannelA/QueryAnalyzer.php create mode 100644 app/app/Services/Autopodbor/Agent/ChannelB/AitunnelResearcher.php create mode 100644 app/app/Services/Autopodbor/Agent/ChannelB/ChannelBSearch.php create mode 100644 app/app/Services/Autopodbor/Agent/ChannelB/ExaSiteFinder.php create mode 100644 app/app/Services/Autopodbor/Agent/ChannelB/ResearcherClient.php create mode 100644 app/app/Services/Autopodbor/Agent/ChannelB/ResearcherParser.php create mode 100644 app/tests/Unit/Autopodbor/ChannelA/AitunnelQueryAnalyzerTest.php create mode 100644 app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php create mode 100644 app/tests/Unit/Autopodbor/ChannelB/AitunnelResearcherTest.php create mode 100644 app/tests/Unit/Autopodbor/ChannelB/ChannelBSearchTest.php create mode 100644 app/tests/Unit/Autopodbor/ChannelB/ExaSiteFinderTest.php create mode 100644 app/tests/Unit/Autopodbor/ChannelB/ResearcherParserTest.php diff --git a/app/app/Providers/AutopodborServiceProvider.php b/app/app/Providers/AutopodborServiceProvider.php index 2f292519..1f165046 100644 --- a/app/app/Providers/AutopodborServiceProvider.php +++ b/app/app/Providers/AutopodborServiceProvider.php @@ -4,6 +4,12 @@ namespace App\Providers; use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter; use App\Services\Autopodbor\Agent\Aggregator\AitunnelAggregatorClassifier; +use App\Services\Autopodbor\Agent\ChannelA\AitunnelQueryAnalyzer; +use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper; +use App\Services\Autopodbor\Agent\ChannelB\AitunnelResearcher; +use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch; +use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder; +use App\Services\Autopodbor\Agent\ChannelB\ResearcherParser; use App\Services\Autopodbor\Agent\CompetitorAgent; use App\Services\Autopodbor\Agent\FakeCompetitorAgent; use App\Services\Autopodbor\Agent\Fetch\CompositeFetcher; @@ -14,13 +20,13 @@ use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher; use App\Services\Autopodbor\Agent\FindCompetitorsAssembler; use App\Services\Autopodbor\Agent\LiveFindCompetitors; use App\Services\Autopodbor\Agent\RealCompetitorAgent; -use App\Services\Autopodbor\Agent\Resolve\TwoGisResolver; -use App\Services\Autopodbor\Agent\Resolve\YandexResolver; +use App\Services\Autopodbor\Agent\Resolve\CompetitorResolver; use App\Services\Autopodbor\Agent\Search\SearchResultsParser; use App\Services\Autopodbor\Agent\Similarity\AitunnelEmbedder; use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance; use App\Services\Autopodbor\AutopodborDedup; use App\Services\Autopodbor\AutopodborNormalizer; +use Illuminate\Http\Client\Factory as HttpFactory; use Illuminate\Support\ServiceProvider; class AutopodborServiceProvider extends ServiceProvider @@ -54,12 +60,17 @@ class AutopodborServiceProvider extends ServiceProvider } /** - * Живой движок поиска шага 1 (канал А) с ИИ через AITUNNEL: эмбеддинг-похожесть (%) и - * LLM-отсев агрегаторов. Клиенты деградируют сами при пустом ключе (эмбеддер → пустые векторы - * → 0%, классификатор → null → никого не выкидываем), поэтому подключаем их всегда. + * Живой движок поиска шага 1 — ФИНАЛ v4 (ZAFIKSIROVANO §0-БИС): шаг АНАЛИЗ (мелкая модель → + * запросы-рубрики) → КАНАЛ А (скрейп категории 2ГИС с пагинацией через xfetch → резолв карточек) → + * КАНАЛ В (одна модель sonar-reasoning-pro × 2 прохода → ИМЕНА федералов; их САЙТ через EXA) → + * сборка (отсев агрегаторов + дедуп + похожесть-эмбеддинги + DTO). Все ИИ/exa/xfetch клиенты + * деградируют при пустом ключе (без падения), поэтому подключаем их всегда. */ private function buildLiveFind(XfetchClient $xfetch): LiveFindCompetitors { + $pages = new LivePageFetcher($xfetch); + $http = $this->app->make(HttpFactory::class); + $assembler = new FindCompetitorsAssembler( new AggregatorFilter($this->app->make(AitunnelAggregatorClassifier::class)), new AutopodborDedup(new AutopodborNormalizer), @@ -67,10 +78,11 @@ class AutopodborServiceProvider extends ServiceProvider ); return new LiveFindCompetitors( - new LivePageFetcher($xfetch), - new SearchResultsParser, - new TwoGisResolver, - new YandexResolver, + new AitunnelQueryAnalyzer($http), + new CategoryScraper($pages, new SearchResultsParser), + new CompetitorResolver($pages), + new ChannelBSearch(new AitunnelResearcher($http), new ResearcherParser), + new ExaSiteFinder($http), $assembler, ); } diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/AitunnelQueryAnalyzer.php b/app/app/Services/Autopodbor/Agent/ChannelA/AitunnelQueryAnalyzer.php new file mode 100644 index 00000000..c31874a4 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/AitunnelQueryAnalyzer.php @@ -0,0 +1,98 @@ +http + ->withToken($key) + ->timeout((int) ($cfg['timeout_sec'] ?? 30)) + ->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [ + 'model' => $cfg['chat_model'] ?? 'gpt-4o-mini', + 'messages' => [['role' => 'user', 'content' => $prompt]], + 'temperature' => 0, + ]); + + if (! $resp->successful()) { + return $fallback; + } + + $queries = $this->parseQueries((string) $resp->json('choices.0.message.content')); + + return $queries !== [] ? $queries : $fallback; + } catch (\Throwable) { + return $fallback; + } + } + + /** + * JSON-массив строк из сырого ответа: фрагмент `[`…`]`, дедуп (без регистра), отсев пустых, лимит. + * + * @return list + */ + private function parseQueries(string $raw): array + { + $start = strpos($raw, '['); + $end = strrpos($raw, ']'); + if ($start === false || $end === false || $end < $start) { + return []; + } + $decoded = json_decode(substr($raw, $start, $end - $start + 1), true); + if (! is_array($decoded)) { + return []; + } + + $out = []; + $seen = []; + foreach ($decoded as $q) { + if (! is_string($q)) { + continue; + } + $q = trim($q); + $k = mb_strtolower($q, 'UTF-8'); + if ($q === '' || isset($seen[$k])) { + continue; + } + $seen[$k] = true; + $out[] = $q; + if (count($out) >= self::MAX_QUERIES) { + break; + } + } + + return $out; + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/CategoryScraper.php b/app/app/Services/Autopodbor/Agent/ChannelA/CategoryScraper.php new file mode 100644 index 00000000..c8c9ad70 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/CategoryScraper.php @@ -0,0 +1,69 @@ +` → `/search//page/2`…), собираем ссылки на карточки фирм. + * Дедуп между страницами и между запросами. Остановка: пустая страница / страница без новых фирм / + * лимит maxPages. Сами карточки (имя/сайт/телефоны) добывает дальше резолвер. + * + * Чистый над {@see PageFetcher} (живой — xfetch/firecrawl; в тестах — стаб), без прямой сети. + */ +final class CategoryScraper +{ + public function __construct( + private readonly PageFetcher $pages, + private readonly SearchResultsParser $parser, + private readonly int $maxPages = 5, + ) {} + + /** + * @param list $queries запросы-рубрики из шага АНАЛИЗ + * @return list полные ссылки на карточки фирм 2ГИС + */ + public function collectTwoGis(string $slug, array $queries): array + { + $seen = []; + $urls = []; + + foreach ($queries as $query) { + $query = trim((string) $query); + if ($query === '') { + continue; + } + + $base = "https://2gis.ru/{$slug}/search/".rawurlencode($query); + + for ($page = 1; $page <= max(1, $this->maxPages); $page++) { + $url = $page === 1 ? $base : $base."/page/{$page}"; + $firms = $this->parser->twoGis($this->pages->html($url)); + if ($firms === []) { + break; // выдача кончилась + } + + $added = 0; + foreach ($firms as $firm) { + $path = $firm['path']; + if (isset($seen[$path])) { + continue; + } + $seen[$path] = true; + $urls[] = 'https://2gis.ru'.$path; + $added++; + } + + if ($added === 0) { + break; // страница не дала ничего нового — дальше листать смысла нет + } + } + } + + return $urls; + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/QueryAnalyzer.php b/app/app/Services/Autopodbor/Agent/ChannelA/QueryAnalyzer.php new file mode 100644 index 00000000..24f2dcc0 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/QueryAnalyzer.php @@ -0,0 +1,16 @@ + короткие запросы-рубрики (напр. «автоломбард», «займ под залог авто»). */ + public function analyze(string $description, string $region): array; +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelB/AitunnelResearcher.php b/app/app/Services/Autopodbor/Agent/ChannelB/AitunnelResearcher.php new file mode 100644 index 00000000..58c05ada --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelB/AitunnelResearcher.php @@ -0,0 +1,51 @@ +http + ->withToken($key) + ->timeout((int) ($cfg['research_timeout_sec'] ?? 120)) + ->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [ + 'model' => $cfg['research_model'] ?? 'sonar-reasoning-pro', + 'messages' => [ + ['role' => 'system', 'content' => $system], + ['role' => 'user', 'content' => $user], + ], + 'temperature' => 0, + ]); + + if (! $resp->successful()) { + return '[]'; + } + + $content = (string) $resp->json('choices.0.message.content'); + + return $content !== '' ? $content : '[]'; + } catch (\Throwable) { + return '[]'; + } + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelB/ChannelBSearch.php b/app/app/Services/Autopodbor/Agent/ChannelB/ChannelBSearch.php new file mode 100644 index 00000000..3014c863 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelB/ChannelBSearch.php @@ -0,0 +1,89 @@ + $known имена, уже известные (список из канала А + примеры клиента) + * @return list только НОВЫЕ имена + */ + public function harvest(string $profile, string $region, string $clientSite, array $known, int $passes = 2): array + { + $accumulated = []; + $seen = []; + $stop = []; + + foreach ($known as $name) { + $name = trim((string) $name); + if ($name === '') { + continue; + } + $seen[$this->key($name)] = true; + $stop[] = $name; + } + + for ($pass = 1; $pass <= max(1, $passes); $pass++) { + $user = $this->userPrompt($profile, $region, $clientSite, $stop); + $raw = $this->client->research(self::SYSTEM_PROMPT, $user); + + foreach ($this->parser->parse($raw) as $cand) { + $k = $this->key($cand['name']); + if (isset($seen[$k])) { + continue; // уже известен (канал А, пример или прошлый проход) + } + $seen[$k] = true; + $accumulated[] = $cand; + $stop[] = $cand['name']; // стоп-лист растёт к следующему проходу + } + } + + return $accumulated; + } + + /** Пользовательский промт §11.3: профиль/регион/сайт клиента + список известных имён. */ + private function userPrompt(string $profile, string $region, string $clientSite, array $stop): string + { + $list = $stop === [] + ? '(пока пусто)' + : implode("\n", array_map(static fn (string $n): string => '- '.$n, $stop)); + + return "Наша компания: {$profile} в {$region}. Наш сайт: {$clientSite}.\n". + "Уже известные конкуренты — НЕ выводить их повторно:\n". + "{$list}\n". + "Дай ТОЛЬКО НОВЫЕ названия конкурентов в {$region}, которых нет в списке. Ответ — строго в формате из системного промта."; + } + + /** Ключ дедупа по имени: ё→е, нижний регистр, только буквы/цифры. */ + private function key(string $name): string + { + $name = str_replace('ё', 'е', mb_strtolower($name, 'UTF-8')); + + return (string) preg_replace('/[^\p{L}\p{N}]+/u', '', $name); + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelB/ExaSiteFinder.php b/app/app/Services/Autopodbor/Agent/ChannelB/ExaSiteFinder.php new file mode 100644 index 00000000..71182baa --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelB/ExaSiteFinder.php @@ -0,0 +1,98 @@ +http + ->withHeaders(['x-api-key' => $key]) + ->timeout((int) ($cfg['timeout_sec'] ?? 30)) + ->post(rtrim((string) ($cfg['base_url'] ?? 'https://api.exa.ai'), '/').'/search', [ + 'query' => $name.' официальный сайт', + 'numResults' => 10, + 'type' => 'auto', + ]); + + if (! $resp->successful()) { + return null; + } + + foreach ((array) $resp->json('results') as $r) { + $url = is_array($r) ? (string) ($r['url'] ?? '') : ''; + $domain = $this->domain($url); + if ($domain !== null && $this->isRealSite($domain)) { + return $domain; + } + } + + return null; + } catch (\Throwable) { + return null; + } + } + + /** Голый домен из URL: без схемы/www/пути, нижний регистр. */ + private function domain(string $url): ?string + { + $url = mb_strtolower(trim($url), 'UTF-8'); + if ($url === '') { + return null; + } + $url = preg_replace('#^https?://#', '', $url); + $url = preg_replace('#^www\.#', '', $url); + $url = explode('/', $url)[0]; + $url = explode('?', $url)[0]; + + return $url !== '' ? $url : null; + } + + /** Настоящий сайт фирмы — не из чёрного списка и не иностранный TLD. */ + private function isRealSite(string $domain): bool + { + if (in_array($domain, self::BLACKLIST, true)) { + return false; + } + foreach (self::FOREIGN_TLD as $tld) { + if (str_ends_with($domain, $tld)) { + return false; + } + } + + return true; + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelB/ResearcherClient.php b/app/app/Services/Autopodbor/Agent/ChannelB/ResearcherClient.php new file mode 100644 index 00000000..f8eb68ad --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelB/ResearcherClient.php @@ -0,0 +1,16 @@ + + */ + public function parse(string $raw): array + { + $out = []; + foreach ($this->decodeArray($raw) as $row) { + if (! is_array($row)) { + continue; + } + $name = trim((string) ($row['name'] ?? '')); + if ($name === '') { + continue; // без имени — мусор + } + $out[] = ['name' => $name, 'type' => $this->str($row['type'] ?? null)]; + } + + return $out; + } + + /** + * Достаёт JSON-массив: фрагмент от первой `[` до последней `]`. Не распарсилось — пустой массив. + * + * @return array + */ + private function decodeArray(string $raw): array + { + $start = strpos($raw, '['); + $end = strrpos($raw, ']'); + if ($start === false || $end === false || $end < $start) { + return []; + } + $decoded = json_decode(substr($raw, $start, $end - $start + 1), true); + + return is_array($decoded) ? $decoded : []; + } + + private function str(mixed $v): ?string + { + if (! is_string($v)) { + return null; + } + $v = trim($v); + + return $v !== '' ? $v : null; + } +} diff --git a/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php b/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php index dba78575..30c9b71c 100644 --- a/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php +++ b/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php @@ -8,7 +8,7 @@ final class HtmlPhoneScanner { /** * @param ?string $defaultAreaCode код города из запроса (напр. «391») — запасной для - * достройки коротких локальных номеров, если на странице нет полных + * достройки коротких локальных номеров, если на странице нет полных * @return array{code: array>, body: array, emails: list, uncertain: list} */ public function scan(string $html, ?string $defaultAreaCode = null): array @@ -111,8 +111,8 @@ final class HtmlPhoneScanner * Пример: «271-33-33» при «+7 (391) …» → ['built','73912713333']; * обрезок «+7 (391) 271» (7391271) → ['fragment', null]. * - * @param list $national 10-значные национальные части полных номеров страницы - * @param ?string $defaultAreaCode запасной код города из запроса (если полных нет) + * @param list $national 10-значные национальные части полных номеров страницы + * @param ?string $defaultAreaCode запасной код города из запроса (если полных нет) * @return array{0:string,1:?string} */ private function classifyShort(string $raw, array $national, ?string $defaultAreaCode): array diff --git a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php index 547efd94..b1b7ea76 100644 --- a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php +++ b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php @@ -4,128 +4,122 @@ declare(strict_types=1); namespace App\Services\Autopodbor\Agent; +use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper; +use App\Services\Autopodbor\Agent\ChannelA\QueryAnalyzer; +use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch; +use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder; use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest; use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult; -use App\Services\Autopodbor\Agent\Fetch\PageFetcher; +use App\Services\Autopodbor\Agent\Resolve\CompetitorResolver; use App\Services\Autopodbor\Agent\Resolve\ResolvedCompetitor; -use App\Services\Autopodbor\Agent\Resolve\TwoGisResolver; -use App\Services\Autopodbor\Agent\Resolve\YandexResolver; -use App\Services\Autopodbor\Agent\Search\SearchResultsParser; use App\Support\RegionCity; /** - * Живой канал А (§12.1 движка v4): ниша клиента → поиск 2ГИС+Яндекс → резолв каждой карточки → - * сборка ядра (фильтр/слияние/похожесть → DTO §7.2) через {@see FindCompetitorsAssembler}. + * Живой findCompetitors — ФИНАЛ движка v4 (ZAFIKSIROVANO §0-БИС, §11–§12). Порядок: + * 1. Шаг АНАЛИЗ ({@see QueryAnalyzer}): описание клиента → запросы-рубрики. + * 2. КАНАЛ А ({@see CategoryScraper}): Firecrawl/xfetch скрейп категории 2ГИС с пагинацией → ссылки + * фирм → {@see CompetitorResolver} → реальные карточки (имя/сайт/телефоны/2ГИС). + * 3. КАНАЛ В ({@see ChannelBSearch}): ОДНА модель × 2 прохода → ТОЛЬКО ИМЕНА федералов/онлайн + * (стоп-лист = имена из А + примеры). У федерала нет карточки на регион → его САЙТ ищем EXA + * ({@see ExaSiteFinder}) → резолвер помечает федералом (нет местной карточки + есть сайт). + * 4. Слияние А+В → {@see FindCompetitorsAssembler}: отсев агрегаторов → дедуп+вычет клиента → + * федерал-фильтр → похожесть-эмбеддинги → DTO §7.2. * - * Запрос для поиска — это сама «ниша» из формы (about_self[0]); ИИ-«анализ» профиля не обязателен. - * Добыча страниц — за {@see PageFetcher} (живая: 2ГИС через xfetch, Яндекс через локальный - * Playwright), поэтому весь обход тестируется офлайн на фикстурах. Резолв идёт ПРЯМО по ссылке - * из выдачи (карточка уже найдена), без повторного поиска. + * Все внешние вызовы (модели/скрейп/exa/резолв) — за тонкими границами, логика тестируема офлайн. */ final class LiveFindCompetitors { public function __construct( - private readonly PageFetcher $pages, - private readonly SearchResultsParser $parser, - private readonly TwoGisResolver $twoGis, - private readonly YandexResolver $yandex, + private readonly QueryAnalyzer $analyzer, + private readonly CategoryScraper $scraper, + private readonly CompetitorResolver $resolver, + private readonly ChannelBSearch $channelB, + private readonly ExaSiteFinder $exa, private readonly FindCompetitorsAssembler $assembler, - // Пределы резолвов на источник — ограничивают число живых запросов/трат за подбор. - // Яндекс (запасной рендер локальным Playwright ~медленный) держим ниже 2ГИС (xfetch). - private readonly int $twoGisCap = 15, - private readonly int $yandexCap = 6, - // Страница выдачи 2ГИС/Яндекс флакует (капча/пустой layout) — повторяем поиск, пока - // не появятся ссылки на фирмы (как ретрай списка филиалов в шаге 2). - private readonly int $searchRetries = 3, + // Потолок имён канала В на резолв (ограничивает живые exa/резолв-вызовы за подбор). + private readonly int $channelBCap = 40, + private readonly int $passes = 2, ) {} public function find(FindCompetitorsRequest $r): FindCompetitorsResult { - $niche = trim((string) ($r->aboutSelf[0] ?? '')); + $profile = $this->profile($r->aboutSelf); + $clientSite = $this->clientSite($r->aboutSelf); $city = RegionCity::name($r->regionCode) ?? ''; + $slug = RegionCity::slug($r->regionCode); - $candidates = []; - if ($niche !== '') { - $candidates = array_merge( - $this->fromTwoGis($niche, $r->regionCode), - $this->fromYandex($niche, $city), - ); + // 1+2. АНАЛИЗ → канал А (справочники, пагинация) → резолв карточек. + $queries = $this->analyzer->analyze($profile, $city); + $aCards = []; + if ($slug !== null && $queries !== []) { + foreach ($this->scraper->collectTwoGis($slug, $queries) as $firmUrl) { + $card = $this->resolver->resolve('', $firmUrl, $city); + if ($card->name !== '') { + $aCards[] = $card; + } + } } - // Свои сайт/имя (about_self) — для вычета себя из конкурентов. - $clientKeys = array_values(array_filter(array_map( - static fn ($v): string => (string) $v, - $r->aboutSelf, - ), static fn (string $v): bool => $v !== '')); + // 3. Канал В: стоп-лист = имена из А + примеры клиента → имена → EXA-сайт → резолв. + $bCards = []; + if ($r->includeFederal) { + $known = array_merge( + array_map(static fn (ResolvedCompetitor $c): string => $c->name, $aCards), + $this->stringList($r->examples), + ); + $names = $this->channelB->harvest($profile, $city, $clientSite, $known, $this->passes); + foreach (array_slice($names, 0, max(0, $this->channelBCap)) as $cand) { + $site = $this->exa->findSite($cand['name'], $city); + $bCards[] = $this->resolver->resolve($cand['name'], null, $city, $site); + } + } - // examples — для похожести (если подключён живой эмбеддер); без него ранжирование = 0. - $examples = array_values(array_filter(array_map( - static fn ($v): string => (string) $v, - $r->examples, - ), static fn (string $v): bool => $v !== '')); + $candidates = array_map( + fn (ResolvedCompetitor $c): array => $this->toArray($c), + array_merge($aCards, $bCards), + ); + + $clientKeys = $this->stringList($r->aboutSelf); + $examples = $this->stringList($r->examples); return $this->assembler->assemble($candidates, $examples, $clientKeys, $r->includeFederal, $r->maxCompetitors); } - /** 2ГИС: /<город>/search/<ниша> → карточки firm (только если знаем слаг города). */ - private function fromTwoGis(string $niche, int $regionCode): array + /** Профиль (ниша) = первая непустая строка «о себе». */ + private function profile(array $aboutSelf): string { - $slug = RegionCity::slug($regionCode); - if ($slug === null) { - return []; // нет уверенного слага — пропускаем 2ГИС, остаётся Яндекс - } - $searchUrl = "https://2gis.ru/{$slug}/search/".rawurlencode($niche); - $items = array_slice($this->searchWithRetry($searchUrl, fn (string $h): array => $this->parser->twoGis($h)), 0, $this->twoGisCap); - - $out = []; - foreach ($items as $it) { - $url = 'https://2gis.ru'.$it['path']; - $card = $this->twoGis->parse($this->pages->html($url), $url); - if ($card !== null) { - $out[] = $this->toArray($card); + foreach ($aboutSelf as $v) { + $v = trim((string) $v); + if ($v !== '') { + return $v; } } - return $out; + return ''; } - /** Яндекс: поиск «ниша город» → карточки org (имя/город проверяет резолвер). */ - private function fromYandex(string $niche, string $city): array + /** Сайт клиента = первая «о себе»-строка, похожая на домен (есть точка, нет пробела). */ + private function clientSite(array $aboutSelf): string { - $searchUrl = 'https://yandex.ru/maps/?text='.rawurlencode(trim($niche.' '.$city)); - $items = array_slice($this->searchWithRetry($searchUrl, fn (string $h): array => $this->parser->yandex($h)), 0, $this->yandexCap); - - $out = []; - foreach ($items as $it) { - $url = 'https://yandex.ru'.$it['url']; - $card = $this->yandex->parse($this->pages->html($url), $url, $it['name'], $city); - if ($card !== null) { - $out[] = $this->toArray($card); + foreach ($aboutSelf as $v) { + $v = trim((string) $v); + if ($v !== '' && ! str_contains($v, ' ') && str_contains($v, '.')) { + return $v; } } - return $out; + return ''; } - /** - * Загрузка страницы выдачи с ретраем: повторяем, пока парсер не вернёт ссылки (флак капчи/пустого - * layout). Первый непустой результат — наш. - * - * @param callable(string):array $parse - */ - private function searchWithRetry(string $url, callable $parse): array + /** @return list */ + private function stringList(array $items): array { - for ($attempt = 1; $attempt <= max(1, $this->searchRetries); $attempt++) { - $items = $parse($this->pages->html($url)); - if ($items !== []) { - return $items; - } - } - - return []; + return array_values(array_filter( + array_map(static fn ($v): string => trim((string) $v), $items), + static fn (string $v): bool => $v !== '', + )); } - /** Карточка резолвера → строка-кандидат для сборщика. */ private function toArray(ResolvedCompetitor $c): array { return [ diff --git a/app/config/services.php b/app/config/services.php index f810b4fa..2d3c416f 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -90,6 +90,18 @@ return [ 'embed_model' => env('AITUNNEL_EMBED_MODEL', 'text-embedding-3-small'), 'chat_model' => env('AITUNNEL_CHAT_MODEL', 'gpt-4o-mini'), 'timeout_sec' => (int) env('AITUNNEL_TIMEOUT_SEC', 30), + // Канал В — генератор имён (ZAFIKSIROVANO §0-БИС): одна рассуждающая модель, ~68 с/вызов, + // потому отдельный долгий таймаут. + 'research_model' => env('AITUNNEL_RESEARCH_MODEL', 'sonar-reasoning-pro'), + 'research_timeout_sec' => (int) env('AITUNNEL_RESEARCH_TIMEOUT_SEC', 120), + ], + + // EXA — поиск САЙТА по имени для канала В (федералы): у федерала нет карточки в 2ГИС/Яндексе + // на регион, а нам нужен сайт (ZAFIKSIROVANO / §11.5). Ключ только в .env. Пусто → канал В без сайтов. + 'exa' => [ + 'key' => env('EXA_API_KEY'), + 'base_url' => env('EXA_BASE_URL', 'https://api.exa.ai'), + 'timeout_sec' => (int) env('EXA_TIMEOUT_SEC', 30), ], // Плитка балансов (28.06): Yandex Cloud биллинг (серверы + Managed PG ~18к/мес). diff --git a/app/tests/Unit/Autopodbor/AgentDtoTest.php b/app/tests/Unit/Autopodbor/AgentDtoTest.php index 828d87e7..e1d7641b 100644 --- a/app/tests/Unit/Autopodbor/AgentDtoTest.php +++ b/app/tests/Unit/Autopodbor/AgentDtoTest.php @@ -1,23 +1,28 @@ 'окна'], includeFederal:true, maxCompetitors:15); + $fReq = new FindCompetitorsRequest(regionCode: 16, examples: ['okna.ru'], aboutSelf: ['description' => 'окна'], includeFederal: true, maxCompetitors: 15); expect($fReq->regionCode)->toBe(16)->and($fReq->examples)->toContain('okna.ru')->and($fReq->includeFederal)->toBeTrue(); - $fRes = new FindCompetitorsResult(competitors:[['name'=>'Окна Комфорт','relevance_pct'=>100]]); + $fRes = new FindCompetitorsResult(competitors: [['name' => 'Окна Комфорт', 'relevance_pct' => 100]]); expect($fRes->competitors[0]['name'])->toBe('Окна Комфорт'); - $sReq = new StudyCompetitorRequest(competitor:['name'=>'Окна Комфорт','site_url'=>'okna.ru'], regionCode:16); + $sReq = new StudyCompetitorRequest(competitor: ['name' => 'Окна Комфорт', 'site_url' => 'okna.ru'], regionCode: 16); expect($sReq->competitor['site_url'])->toBe('okna.ru')->and($sReq->regionCode)->toBe(16); - $sRes = new StudyCompetitorResult(sources:[['signal_type'=>'site','identifier'=>'okna.ru']]); + $sRes = new StudyCompetitorResult(sources: [['signal_type' => 'site', 'identifier' => 'okna.ru']]); expect($sRes->sources[0]['signal_type'])->toBe('site'); - $rReq = new ResolveByNameRequest(name:'Окна Комфорт', regionCode:16); + $rReq = new ResolveByNameRequest(name: 'Окна Комфорт', regionCode: 16); expect($rReq->name)->toBe('Окна Комфорт'); - $rRes = new ResolveByNameResult(candidates:[['name'=>'Окна Комфорт']]); + $rRes = new ResolveByNameResult(candidates: [['name' => 'Окна Комфорт']]); expect($rRes->candidates)->toHaveCount(1); }); diff --git a/app/tests/Unit/Autopodbor/AutopodborNormalizerStripBadgeTest.php b/app/tests/Unit/Autopodbor/AutopodborNormalizerStripBadgeTest.php index ae818c01..fa8d4881 100644 --- a/app/tests/Unit/Autopodbor/AutopodborNormalizerStripBadgeTest.php +++ b/app/tests/Unit/Autopodbor/AutopodborNormalizerStripBadgeTest.php @@ -5,26 +5,26 @@ declare(strict_types=1); use App\Services\Autopodbor\AutopodborNormalizer; it('срезает значок реального номера', function () { - $n = new AutopodborNormalizer(); + $n = new AutopodborNormalizer; expect($n->stripBadge('Окна Комфорт ✓'))->toBe('Окна Комфорт'); }); it('срезает значок подменного номера', function () { - $n = new AutopodborNormalizer(); + $n = new AutopodborNormalizer; expect($n->stripBadge('Окна Комфорт 🎭'))->toBe('Окна Комфорт'); }); it('имя без значка не меняется', function () { - $n = new AutopodborNormalizer(); + $n = new AutopodborNormalizer; expect($n->stripBadge('Окна Комфорт'))->toBe('Окна Комфорт'); }); it('имя сайта (без значка) с внутренними пробелами сохраняется', function () { - $n = new AutopodborNormalizer(); + $n = new AutopodborNormalizer; expect($n->stripBadge('Балкон-Сервис 16'))->toBe('Балкон-Сервис 16'); }); it('числовой суффикс не трогается (значка нет)', function () { - $n = new AutopodborNormalizer(); + $n = new AutopodborNormalizer; expect($n->stripBadge('Окна Комфорт 2'))->toBe('Окна Комфорт 2'); }); diff --git a/app/tests/Unit/Autopodbor/AutopodborNormalizerTest.php b/app/tests/Unit/Autopodbor/AutopodborNormalizerTest.php index c206fcae..b363c9da 100644 --- a/app/tests/Unit/Autopodbor/AutopodborNormalizerTest.php +++ b/app/tests/Unit/Autopodbor/AutopodborNormalizerTest.php @@ -3,26 +3,26 @@ use App\Services\Autopodbor\AutopodborNormalizer; it('нормализует домен в голову', function () { - $n = new AutopodborNormalizer(); + $n = new AutopodborNormalizer; expect($n->domainHead('https://www.Okna-Komfort.RU/contacts'))->toBe('okna-komfort.ru') ->and($n->domainHead('okna.pro'))->toBe('okna.pro') ->and($n->domainHead('http://site.ru:8080/path?x=1'))->toBe('site.ru'); }); it('нормализует телефон в 7xxxxxxxxxx', function () { - $n = new AutopodborNormalizer(); + $n = new AutopodborNormalizer; expect($n->phone('+7 (843) 200-11-22'))->toBe('78432001122') ->and($n->phone('8 843 2001122'))->toBe('78432001122'); }); it('строит dedup_key источника', function () { - $n = new AutopodborNormalizer(); + $n = new AutopodborNormalizer; expect($n->sourceKey('site', 'https://OKNA.ru/'))->toBe('site:okna.ru') ->and($n->sourceKey('call', '8-843-200-11-22'))->toBe('call:78432001122'); }); it('строит dedup_key конкурента', function () { - $n = new AutopodborNormalizer(); + $n = new AutopodborNormalizer; expect($n->competitorKey('Окна Комфорт', 'https://www.okna-komfort.ru/'))->toBe('site:okna-komfort.ru') ->and($n->competitorKey(' Окна Комфорт ', null))->toBe('name:окна комфорт'); }); diff --git a/app/tests/Unit/Autopodbor/BalanceTransactionTypeTest.php b/app/tests/Unit/Autopodbor/BalanceTransactionTypeTest.php index 16b1cddb..32b16688 100644 --- a/app/tests/Unit/Autopodbor/BalanceTransactionTypeTest.php +++ b/app/tests/Unit/Autopodbor/BalanceTransactionTypeTest.php @@ -1,5 +1,7 @@ toBe('autopodbor_charge'); + expect(BalanceTransaction::TYPE_AUTOPODBOR_CHARGE)->toBe('autopodbor_charge'); }); diff --git a/app/tests/Unit/Autopodbor/ChannelA/AitunnelQueryAnalyzerTest.php b/app/tests/Unit/Autopodbor/ChannelA/AitunnelQueryAnalyzerTest.php new file mode 100644 index 00000000..81591c5a --- /dev/null +++ b/app/tests/Unit/Autopodbor/ChannelA/AitunnelQueryAnalyzerTest.php @@ -0,0 +1,65 @@ +analyze('займы под залог авто и спецтехники', 'Красноярский край')) + ->toBe(['займы под залог авто и спецтехники']); +}); + +it('разбирает JSON-массив запросов из ответа модели', function () { + Config::set('services.aitunnel', [ + 'key' => 'k', 'base_url' => 'https://api.aitunnel.ru/v1', 'chat_model' => 'gpt-4o-mini', 'timeout_sec' => 30, + ]); + + $http = app(HttpFactory::class); + $http->fake([ + '*/chat/completions' => $http->response([ + 'choices' => [['message' => ['content' => '["автоломбард","займ под залог авто","МФО","ломбард"]']]], + ], 200), + ]); + + $a = new AitunnelQueryAnalyzer($http); + $out = $a->analyze('займы под залог авто', 'Красноярский край'); + + expect($out)->toBe(['автоломбард', 'займ под залог авто', 'МФО', 'ломбард']); +}); + +it('режет markdown-обёртку и пустые/дубли, ограничивает число', function () { + Config::set('services.aitunnel.key', 'k'); + + $http = app(HttpFactory::class); + $http->fake([ + '*/chat/completions' => $http->response([ + 'choices' => [['message' => ['content' => "```json\n[\"автоломбард\",\"\",\"автоломбард\",\"займ под залог\"]\n```"]]], + ], 200), + ]); + + $a = new AitunnelQueryAnalyzer($http); + $out = $a->analyze('займы', 'Красноярский край'); + + expect($out)->toBe(['автоломбард', 'займ под залог']); +}); + +it('при ошибке сети — fallback из описания', function () { + Config::set('services.aitunnel.key', 'k'); + + $http = app(HttpFactory::class); + $http->fake(['*/chat/completions' => $http->response('boom', 500)]); + + $a = new AitunnelQueryAnalyzer($http); + + expect($a->analyze('ломбард', 'Красноярский край'))->toBe(['ломбард']); +}); diff --git a/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php b/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php new file mode 100644 index 00000000..05b6ebb5 --- /dev/null +++ b/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php @@ -0,0 +1,79 @@ + $map подстрока URL => HTML */ + public function __construct(private array $map) {} + + public function html(string $url): string + { + foreach ($this->map as $needle => $html) { + if (str_contains($url, $needle)) { + return $html; + } + } + + return ''; + } +} + +function firmHtml(array $ids): string +{ + $html = ''; + foreach ($ids as $id) { + $html .= 'Фирма '.$id.''; + } + + return $html; +} + +it('мульти-запрос × пагинация: собирает фирмы со всех страниц, дедуп между запросами', function () { + $fetcher = new MapFetcher([ + // запрос «автоломбард»: стр.1 → 1,2 ; стр.2 → 3 ; стр.3 → пусто (стоп) + '/search/%D0%B0%D0%B2%D1%82%D0%BE%D0%BB%D0%BE%D0%BC%D0%B1%D0%B0%D1%80%D0%B4/page/2' => firmHtml([3]), + '/search/%D0%B0%D0%B2%D1%82%D0%BE%D0%BB%D0%BE%D0%BC%D0%B1%D0%B0%D1%80%D0%B4' => firmHtml([1, 2]), + // запрос «ломбард»: стр.1 → 2(дубль),4 ; стр.2 → пусто + '/search/%D0%BB%D0%BE%D0%BC%D0%B1%D0%B0%D1%80%D0%B4' => firmHtml([2, 4]), + ]); + + $scraper = new CategoryScraper($fetcher, new SearchResultsParser, maxPages: 5); + + $urls = $scraper->collectTwoGis('krasnoyarsk', ['автоломбард', 'ломбард']); + + // фирмы 1,2,3,4 — по одному разу (2 не задвоилась, 3 пришла с 2-й страницы → пагинация работает) + expect($urls)->toHaveCount(4); + $joined = implode(' ', $urls); + foreach ([1, 2, 3, 4] as $id) { + expect($joined)->toContain('/firm/'.$id); + } + // это полные ссылки на 2ГИС + expect($urls[0])->toStartWith('https://2gis.ru/'); +}); + +it('пустая выдача по запросу — ничего не добавляет, не падает', function () { + $scraper = new CategoryScraper(new MapFetcher([]), new SearchResultsParser, maxPages: 3); + + expect($scraper->collectTwoGis('krasnoyarsk', ['ничего']))->toBe([]); +}); + +it('останавливает пагинацию на maxPages', function () { + // каждая страница непустая → должно ограничиться maxPages вызовами на запрос + $fetcher = new MapFetcher(['/search/' => firmHtml([7])]); + $scraper = new CategoryScraper($fetcher, new SearchResultsParser, maxPages: 2); + + // фирма 7 одна и та же на всех страницах → дедуп оставит 1, но без падения/бесконечного цикла + $urls = $scraper->collectTwoGis('krasnoyarsk', ['займ']); + + expect($urls)->toHaveCount(1); +}); diff --git a/app/tests/Unit/Autopodbor/ChannelB/AitunnelResearcherTest.php b/app/tests/Unit/Autopodbor/ChannelB/AitunnelResearcherTest.php new file mode 100644 index 00000000..eaf80fee --- /dev/null +++ b/app/tests/Unit/Autopodbor/ChannelB/AitunnelResearcherTest.php @@ -0,0 +1,60 @@ +research('sys', 'user'))->toBe('[]'); +}); + +it('шлёт system+user в chat/completions и возвращает content', function () { + Config::set('services.aitunnel', [ + 'key' => 'test-key', + 'base_url' => 'https://api.aitunnel.ru/v1', + 'research_model' => 'sonar-reasoning-pro', + 'research_timeout_sec' => 120, + ]); + + $http = app(HttpFactory::class); + $http->fake([ + '*/chat/completions' => $http->response([ + 'choices' => [['message' => ['content' => '[{"name":"CarMoney","type":"федеральная"}]']]], + ], 200), + ]); + + $r = new AitunnelResearcher($http); + $out = $r->research('SYS-PROMPT', 'USER-PROMPT'); + + expect($out)->toContain('CarMoney'); + + $http->assertSent(function ($request) { + $body = $request->data(); + + return str_contains($request->url(), '/chat/completions') + && $body['model'] === 'sonar-reasoning-pro' + && $body['messages'][0]['role'] === 'system' + && $body['messages'][0]['content'] === 'SYS-PROMPT' + && $body['messages'][1]['role'] === 'user' + && $body['messages'][1]['content'] === 'USER-PROMPT'; + }); +}); + +it('при ошибке сети возвращает пустой массив', function () { + Config::set('services.aitunnel.key', 'test-key'); + + $http = app(HttpFactory::class); + $http->fake(['*/chat/completions' => $http->response('boom', 500)]); + + $r = new AitunnelResearcher($http); + + expect($r->research('sys', 'user'))->toBe('[]'); +}); diff --git a/app/tests/Unit/Autopodbor/ChannelB/ChannelBSearchTest.php b/app/tests/Unit/Autopodbor/ChannelB/ChannelBSearchTest.php new file mode 100644 index 00000000..676e1647 --- /dev/null +++ b/app/tests/Unit/Autopodbor/ChannelB/ChannelBSearchTest.php @@ -0,0 +1,84 @@ + */ + public array $userPrompts = []; + + /** @param list $answers */ + public function __construct(private array $answers) {} + + public function research(string $system, string $user): string + { + $this->userPrompts[] = $user; + + return $this->answers[count($this->userPrompts) - 1] ?? '[]'; + } +} + +it('2 прохода: копит новые имена без дублей, наращивает стоп-лист, исключает список из А', function () { + $pass1 = '[{"name":"CarMoney","type":"федеральная"},{"name":"Финансовый дом","type":"федеральная"}]'; + $pass2 = '[{"name":"Webbankir","type":"федеральная"},{"name":"CarMoney","type":"федеральная"}]'; // дубль + + $fake = new FakeResearcher([$pass1, $pass2]); + $search = new ChannelBSearch($fake, new ResearcherParser); + + // knownFromA = имена, уже найденные каналом А (справочники) + $out = $search->harvest( + profile: 'займы под залог авто', + region: 'Красноярский край', + clientSite: 'lkomega.ru', + known: ['КрасЛомбард', 'Голд Авто Инвест', 'Ваш инвестор'], + passes: 2, + ); + + // 3 новых уникальных (CarMoney из прохода 2 — дубль, не добавлен) + expect($out)->toHaveCount(3) + ->and(array_column($out, 'name'))->toContain('CarMoney', 'Финансовый дом', 'Webbankir'); + + expect($fake->userPrompts)->toHaveCount(2); + + // Проход 1: стоп-лист несёт список из А + expect($fake->userPrompts[0])->toContain('КрасЛомбард') + ->and($fake->userPrompts[0])->toContain('Голд Авто Инвест'); + + // Проход 2: стоп-лист вырос — несёт найденное в проходе 1 + expect($fake->userPrompts[1])->toContain('CarMoney') + ->and($fake->userPrompts[1])->toContain('Финансовый дом'); +}); + +it('ИИ не должен повторять имена из канала А (дедуп с known)', function () { + // ИИ вернул фирму, уже найденную каналом А → не добавляем + $fake = new FakeResearcher(['[{"name":"КрасЛомбард","type":"региональная"},{"name":"Cashmotor","type":"федеральная"}]']); + $search = new ChannelBSearch($fake, new ResearcherParser); + + $out = $search->harvest('займы', 'Красноярский край', 'lkomega.ru', ['КрасЛомбард'], 1); + + expect($out)->toHaveCount(1)->and($out[0]['name'])->toBe('Cashmotor'); +}); + +it('пустой ответ прохода не падает и не добавляет', function () { + $fake = new FakeResearcher(['[{"name":"Cashmotor","type":"федеральная"}]', '[]']); + $search = new ChannelBSearch($fake, new ResearcherParser); + + $out = $search->harvest('займы', 'Красноярский край', 'lkomega.ru', [], 2); + + expect($out)->toHaveCount(1)->and($out[0]['name'])->toBe('Cashmotor'); +}); + +it('системный промт просит ТОЛЬКО названия (§11.3)', function () { + expect(ChannelBSearch::SYSTEM_PROMPT)->toContain('только НАЗВАНИЯ') + ->and(ChannelBSearch::SYSTEM_PROMPT)->toContain('строго JSON'); +}); diff --git a/app/tests/Unit/Autopodbor/ChannelB/ExaSiteFinderTest.php b/app/tests/Unit/Autopodbor/ChannelB/ExaSiteFinderTest.php new file mode 100644 index 00000000..7dbfd2ed --- /dev/null +++ b/app/tests/Unit/Autopodbor/ChannelB/ExaSiteFinderTest.php @@ -0,0 +1,68 @@ +findSite('CarMoney', 'Красноярский край'))->toBeNull(); +}); + +it('берёт домен первого результата (голый, без www/пути)', function () { + Config::set('services.exa', ['key' => 'k', 'base_url' => 'https://api.exa.ai', 'timeout_sec' => 30]); + + $http = app(HttpFactory::class); + $http->fake([ + 'api.exa.ai/*' => $http->response([ + 'results' => [['url' => 'https://www.carmoney.ru/about?utm=1'], ['url' => 'https://carmoney.ru']], + ], 200), + ]); + + $f = new ExaSiteFinder($http); + + expect($f->findSite('CarMoney', 'Красноярский край'))->toBe('carmoney.ru'); +}); + +it('пропускает агрегаторы/каталоги и иностранные TLD, берёт первый «настоящий»', function () { + Config::set('services.exa.key', 'k'); + + $http = app(HttpFactory::class); + $http->fake([ + 'api.exa.ai/*' => $http->response([ + 'results' => [ + ['url' => 'https://www.avito.ru/krasnoyarsk/zaim'], + ['url' => 'https://banki.ru/products/cashmotor'], + ['url' => 'https://cashmotor.kg/'], // иностранный TLD + ['url' => 'https://cashmotor.ru/'], // настоящий + ], + ], 200), + ]); + + $f = new ExaSiteFinder($http); + + expect($f->findSite('Cashmotor', 'Красноярский край'))->toBe('cashmotor.ru'); +}); + +it('нет подходящих результатов или ошибка сети → null', function () { + Config::set('services.exa.key', 'k'); + + $http = app(HttpFactory::class); + $http->fake([ + 'api.exa.ai/*' => $http->response(['results' => [['url' => 'https://zoon.ru/x']]], 200), + ]); + expect((new ExaSiteFinder($http))->findSite('X', 'Красноярский край'))->toBeNull(); + + $http2 = app(HttpFactory::class); + $http2->fake(['api.exa.ai/*' => $http2->response('boom', 500)]); + expect((new ExaSiteFinder($http2))->findSite('Y', 'Красноярский край'))->toBeNull(); +}); diff --git a/app/tests/Unit/Autopodbor/ChannelB/ResearcherParserTest.php b/app/tests/Unit/Autopodbor/ChannelB/ResearcherParserTest.php new file mode 100644 index 00000000..77e9b09d --- /dev/null +++ b/app/tests/Unit/Autopodbor/ChannelB/ResearcherParserTest.php @@ -0,0 +1,48 @@ +parser = new ResearcherParser; +}); + +it('разбирает чистый JSON в имена + тип', function () { + $raw = '[{"name":"CarMoney","type":"федеральная"},{"name":"Ваш инвестор","type":"региональная"}]'; + + $out = $this->parser->parse($raw); + + expect($out)->toHaveCount(2) + ->and($out[0]['name'])->toBe('CarMoney') + ->and($out[0]['type'])->toBe('федеральная') + ->and($out[1]['name'])->toBe('Ваш инвестор'); +}); + +it('вытаскивает JSON из markdown-обёртки и текста вокруг', function () { + $raw = "Вот названия:\n```json\n".'[{"name":"Финео","type":"федеральная"}]'."\n```\nГотово."; + + $out = $this->parser->parse($raw); + + expect($out)->toHaveCount(1)->and($out[0]['name'])->toBe('Финео'); +}); + +it('пропускает элемент без имени; тип может отсутствовать', function () { + $raw = '[{"name":"","type":"региональная"},{"name":"Голд Авто Инвест"}]'; + + $out = $this->parser->parse($raw); + + expect($out)->toHaveCount(1) + ->and($out[0]['name'])->toBe('Голд Авто Инвест') + ->and($out[0]['type'])->toBeNull(); +}); + +it('пустой массив и мусор дают []', function () { + expect($this->parser->parse('[]'))->toBe([]) + ->and($this->parser->parse('извините, ничего'))->toBe([]) + ->and($this->parser->parse(''))->toBe([]); +}); diff --git a/app/tests/Unit/Autopodbor/Fetch/CompositeFetcherTest.php b/app/tests/Unit/Autopodbor/Fetch/CompositeFetcherTest.php index 5e5557dd..20391865 100644 --- a/app/tests/Unit/Autopodbor/Fetch/CompositeFetcherTest.php +++ b/app/tests/Unit/Autopodbor/Fetch/CompositeFetcherTest.php @@ -2,8 +2,8 @@ use App\Services\Autopodbor\Agent\Fetch\CompositeFetcher; use App\Services\Autopodbor\Agent\Fetch\DirectoryCard; -use App\Services\Autopodbor\Agent\Fetch\Fetcher; use App\Services\Autopodbor\Agent\Fetch\FetchedSite; +use App\Services\Autopodbor\Agent\Fetch\Fetcher; function onlySiteFetcher(): Fetcher { diff --git a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php index 28aef754..1154a8ae 100644 --- a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php +++ b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php @@ -4,21 +4,58 @@ declare(strict_types=1); use App\Services\Autopodbor\Agent\Aggregator\AggregatorClassifier; use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter; +use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper; +use App\Services\Autopodbor\Agent\ChannelA\QueryAnalyzer; +use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch; +use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder; +use App\Services\Autopodbor\Agent\ChannelB\ResearcherClient; +use App\Services\Autopodbor\Agent\ChannelB\ResearcherParser; use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest; -use App\Services\Autopodbor\Agent\Fetch\PageFetcher; use App\Services\Autopodbor\Agent\FindCompetitorsAssembler; use App\Services\Autopodbor\Agent\LiveFindCompetitors; -use App\Services\Autopodbor\Agent\Resolve\TwoGisResolver; -use App\Services\Autopodbor\Agent\Resolve\YandexResolver; +use App\Services\Autopodbor\Agent\Resolve\CompetitorResolver; use App\Services\Autopodbor\Agent\Search\SearchResultsParser; use App\Services\Autopodbor\Agent\Similarity\Embedder; use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance; use App\Services\Autopodbor\AutopodborDedup; use App\Services\Autopodbor\AutopodborNormalizer; +use Illuminate\Http\Client\Factory as HttpFactory; +use Illuminate\Support\Facades\Config; +// Сквозной офлайн-тест ФИНАЛЬНОГО оркестратора (ZAFIKSIROVANO §0-БИС): АНАЛИЗ → канал А (скрейп +// категории 2ГИС + резолв карточек) → канал В (имена федералов → САЙТ через EXA → резолв) → сборка. +// ИИ-границы фейкаем, страницы — stubPages на реальных фикстурах, exa — Http fake. // stubPages()/autopodborFixture() — глобальные хелперы из tests/Pest.php. -function liveEngine(): LiveFindCompetitors +function fakeAnalyzer(array $queries): QueryAnalyzer +{ + return new class($queries) implements QueryAnalyzer + { + public function __construct(private array $queries) {} + + public function analyze(string $description, string $region): array + { + return $this->queries; + } + }; +} + +function fakeResearcher(array $answers): ResearcherClient +{ + return new class($answers) implements ResearcherClient + { + public function __construct(private array $answers) {} + + public function research(string $system, string $user): string + { + static $i = 0; + + return $this->answers[$i++] ?? '[]'; + } + }; +} + +function noAiAssembler(): FindCompetitorsAssembler { $nullClassifier = new class implements AggregatorClassifier { @@ -34,135 +71,90 @@ function liveEngine(): LiveFindCompetitors return array_map(fn () => [0.0], $texts); } }; - $assembler = new FindCompetitorsAssembler( + + return new FindCompetitorsAssembler( new AggregatorFilter($nullClassifier), new AutopodborDedup(new AutopodborNormalizer), new EmbeddingRelevance($zeroEmbedder), ); - - return new LiveFindCompetitors( - stubPages([ - // выдача - '2gis.ru/krasnoyarsk/search/' => autopodborFixture('2gis-search-kraslombard.html'), - 'maps/?text=' => autopodborFixture('yandex-search-kraslombard.html'), - // карточки - '/firm/' => autopodborFixture('2gis-firm-kraslombard.html'), - '/maps/org/' => autopodborFixture('yandex-org-kraslombard.html'), - ]), - new SearchResultsParser, - new TwoGisResolver, - new YandexResolver, - $assembler, - ); } -it('живой поиск по нише → настоящий конкурент, склеенный из 2ГИС и Яндекса', function () { - $res = liveEngine()->find(new FindCompetitorsRequest( - regionCode: 29, // Красноярский край → город Красноярск, слаг krasnoyarsk - examples: [], - aboutSelf: ['ломбард', 'moy-lombard.ru'], // ниша + ВАШ сайт (другой — себя НЕ вычитаем) +it('сквозной: канал А (2ГИС-карточка) + канал В (федерал с сайтом из EXA) попадают в результат', function () { + Config::set('services.exa', ['key' => 'k', 'base_url' => 'https://api.exa.ai', 'timeout_sec' => 30]); + $http = app(HttpFactory::class); + $http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://carmoney.ru/']]], 200)]); + + $pages = stubPages([ + '2gis.ru/krasnoyarsk/search/' => autopodborFixture('2gis-search-kraslombard.html'), + '/firm/' => autopodborFixture('2gis-firm-kraslombard.html'), + // Яндекс по «CarMoney …» вернёт КрасЛомбарда — резолвер отвергнет по несовпадению имени → федерал. + 'maps/?text=' => autopodborFixture('yandex-search-kraslombard.html'), + '/maps/org/' => autopodborFixture('yandex-org-kraslombard.html'), + ]); + + $find = new LiveFindCompetitors( + fakeAnalyzer(['ломбард']), + new CategoryScraper($pages, new SearchResultsParser, maxPages: 2), + new CompetitorResolver($pages), + new ChannelBSearch(fakeResearcher(['[{"name":"CarMoney","type":"федеральная"}]', '[]']), new ResearcherParser), + new ExaSiteFinder($http), + noAiAssembler(), + ); + + $res = $find->find(new FindCompetitorsRequest( + regionCode: 29, + examples: ['automoney-krsk.ru'], + aboutSelf: ['займы под залог авто', 'lkomega.ru'], includeFederal: true, maxCompetitors: 20, )); $names = array_column($res->competitors, 'name'); - expect($names)->toContain('КрасЛомбард'); // найден живым поиском по нише + expect($names)->toContain('КрасЛомбард'); // канал А - $kl = collect($res->competitors)->firstWhere('name', 'КрасЛомбард'); - expect($kl['site_url'])->toBe('http://kraslombard24.ru'); - // склейка одной фирмы из 2ГИС + Яндекс: в «где нашли» обе ссылки справочников - expect(collect($kl['directory_urls'])->contains(fn (string $u): bool => str_contains($u, '/firm/')))->toBeTrue(); - expect(collect($kl['directory_urls'])->contains(fn (string $u): bool => str_contains($u, '/maps/org/')))->toBeTrue(); + $carmoney = collect($res->competitors)->firstWhere('name', 'CarMoney'); + expect($carmoney)->not->toBeNull() + ->and($carmoney['is_federal'])->toBeTrue() + ->and($carmoney['site_url'])->toBe('carmoney.ru'); // сайт из EXA }); -it('свой сайт вычитается из конкурентов', function () { - $res = liveEngine()->find(new FindCompetitorsRequest( - regionCode: 29, - examples: [], - aboutSelf: ['ломбард', 'kraslombard24.ru'], // теперь КрасЛомбард = это «мы» - includeFederal: true, - maxCompetitors: 20, - )); +it('includeFederal=false → канал В выключен, остаётся только местная А', function () { + Config::set('services.exa.key', 'k'); + $http = app(HttpFactory::class); + $http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://carmoney.ru/']]], 200)]); - expect(array_column($res->competitors, 'name'))->not->toContain('КрасЛомбард'); -}); + $pages = stubPages([ + '2gis.ru/krasnoyarsk/search/' => autopodborFixture('2gis-search-kraslombard.html'), + '/firm/' => autopodborFixture('2gis-firm-kraslombard.html'), + ]); -it('повторяет флакующую выдачу 2ГИС, пока не появятся фирмы', function () { - // первый запрос страницы 2ГИС-поиска — пусто (капча/флак), второй — реальная выдача - $pages = new class implements PageFetcher - { - private int $searchHits = 0; - - public function html(string $url): string - { - if (str_contains($url, '/search/')) { - $this->searchHits++; - - return $this->searchHits >= 2 ? autopodborFixture('2gis-search-kraslombard.html') : ''; - } - if (str_contains($url, '/firm/')) { - return autopodborFixture('2gis-firm-kraslombard.html'); - } - - return ''; // Яндекс пропускаем - } - }; - - $nullClassifier = new class implements AggregatorClassifier - { - public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool - { - return null; - } - }; - $zeroEmbedder = new class implements Embedder - { - public function embed(array $texts): array - { - return array_map(fn () => [0.0], $texts); - } - }; - $engine = new LiveFindCompetitors( - $pages, - new SearchResultsParser, - new TwoGisResolver, - new YandexResolver, - new FindCompetitorsAssembler( - new AggregatorFilter($nullClassifier), - new AutopodborDedup(new AutopodborNormalizer), - new EmbeddingRelevance($zeroEmbedder), - ), + $find = new LiveFindCompetitors( + fakeAnalyzer(['ломбард']), + new CategoryScraper($pages, new SearchResultsParser, maxPages: 2), + new CompetitorResolver($pages), + new ChannelBSearch(fakeResearcher(['[{"name":"CarMoney","type":"федеральная"}]']), new ResearcherParser), + new ExaSiteFinder($http), + noAiAssembler(), ); - $res = $engine->find(new FindCompetitorsRequest(29, [], ['ломбард', 'moy.ru'], true, 20)); - expect(array_column($res->competitors, 'name'))->toContain('КрасЛомбард'); + $res = $find->find(new FindCompetitorsRequest(29, [], ['ломбард', 'lkomega.ru'], false, 20)); + $names = array_column($res->competitors, 'name'); + + expect($names)->toContain('КрасЛомбард') + ->and($names)->not->toContain('CarMoney'); }); -it('пустая выдача → пустой список, без падения', function () { - $engine = new LiveFindCompetitors( - stubPages([]), // на всё '' - new SearchResultsParser, - new TwoGisResolver, - new YandexResolver, - new FindCompetitorsAssembler( - new AggregatorFilter(new class implements AggregatorClassifier - { - public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool - { - return null; - } - }), - new AutopodborDedup(new AutopodborNormalizer), - new EmbeddingRelevance(new class implements Embedder - { - public function embed(array $texts): array - { - return array_map(fn () => [0.0], $texts); - } - }), - ), +it('пусто везде → пустой список, без падения', function () { + Config::set('services.exa.key', ''); + $find = new LiveFindCompetitors( + fakeAnalyzer(['ничего']), + new CategoryScraper(stubPages([]), new SearchResultsParser, maxPages: 2), + new CompetitorResolver(stubPages([])), + new ChannelBSearch(fakeResearcher(['[]', '[]']), new ResearcherParser), + new ExaSiteFinder(app(HttpFactory::class)), + noAiAssembler(), ); - $res = $engine->find(new FindCompetitorsRequest(29, [], ['ничего', 'self.ru'], false, 10)); + $res = $find->find(new FindCompetitorsRequest(29, [], ['ничего', 'self.ru'], true, 10)); expect($res->competitors)->toBe([]); }); diff --git a/docs/superpowers/specs/2026-06-30-ZAFIKSIROVANO-chto-est-chto-peredelyvaem.md b/docs/superpowers/specs/2026-06-30-ZAFIKSIROVANO-chto-est-chto-peredelyvaem.md index c83e97df..ffb806a4 100644 --- a/docs/superpowers/specs/2026-06-30-ZAFIKSIROVANO-chto-est-chto-peredelyvaem.md +++ b/docs/superpowers/specs/2026-06-30-ZAFIKSIROVANO-chto-est-chto-peredelyvaem.md @@ -13,6 +13,30 @@ Но в **PHP его перенесли только наполовину** (нижнюю), поэтому в портале вышло 3. **Переделываем — переносим §12 в PHP один-в-один.** +## 0-БИС. ФИНАЛ КАНАЛА В — подтверждено владельцем 30.06 (важнее §9/§10) +> Владелец 30.06: к концу прогона **5 моделей УБРАЛИ** — гоняли **ОДНУ модель ДВА прохода** с тем же +> доведённым промптом, и на этом признали успех. Это финал, его не успели вынести в §-доки (жил в чате). +> **Источник истины по числу моделей теперь — это слово владельца, а не «5 моделей» из §9/§10.** +- **Модель:** `sonar-reasoning-pro` (владелец «не помню точную — возьми лучшую»; беру рассуждающую/чистую, + ту же, что для фильтра агрегаторов). Параметризовать — добавить модели = строчка конфига. +- **Проходы:** 2. Проход 1 — стоп-лист = примеры клиента. Проход 2 — стоп-лист = примеры + найденное в проходе 1. +- **Промт — тот же** (§10.2, нейтральный, правило якоря) — от числа моделей НЕ меняется. +- Сверка: на живом прогоне сравнить с эталоном 72; мало — добавить модель/проход (это настройка, не переделка). + +### 0-ТРИ. КАНАЛ В = ТОЛЬКО ИМЕНА, сайт через EXA (уточнение владельца 30.06) +> ИИ канала В даёт **ТОЛЬКО НАЗВАНИЯ** (промт §11.3), без сайтов/карточек. У федерала **НЕТ карточки +> в 2ГИС/Яндексе на регион**, а нам нужен **сайт** — поэтому имя из В нормализуем через **EXA** +> (поиск сайта по имени, §11.4–11.5), не Firecrawl. Канал А (региональные фирмы) — Firecrawl/xfetch +> скрейп категории 2ГИС. Похожесть — эмбеддер-модель (математически), как и было. + +### 0-ЧЕТЫРЕ. ✅ СДЕЛАНО 30.06 (PHP, по TDD, на фикстурах — 130 unit + 74 feature зелёные) +- Шаг АНАЛИЗ `ChannelA\AitunnelQueryAnalyzer` (мелкая модель → запросы-рубрики). +- Канал А `ChannelA\CategoryScraper` (скрейп категории 2ГИС + пагинация) → резолв `CompetitorResolver`. +- Канал В `ChannelB\{ResearcherParser, ResearcherClient, ChannelBSearch, AitunnelResearcher}` (имена, + 1 модель × 2 прохода) + `ChannelB\ExaSiteFinder` (сайт федерала через EXA). +- Оркестратор `LiveFindCompetitors` переписан под АНАЛИЗ→А→В→слияние→сборка; провайдер перепрошит. +- Осталось: ЖИВОЙ прогон со сверкой на эталон 72 + (при нужде) точечная калибровка. + --- ## 1. ✅ ЧТО У НАС ЕСТЬ (активы — не потерять, это фундамент) From be08e04f12d37db30c5d014e849c9a5775d0b081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 1 Jul 2026 03:31:16 +0300 Subject: [PATCH 099/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=BB=D1=91=D0=B3=D0=BA?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=20=D0=90=20?= =?UTF-8?q?=E2=80=94=20=D0=B8=D0=BC=D1=8F+=D1=81=D0=B0=D0=B9=D1=82+=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=202=D0=93=D0=98=D0=A1=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=B7=D0=B0=D1=85=D0=BE=D0=B4=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шаг 1 = только имя+сайт+ссылка на справочник (телефоны/описание = шаг 2). Убраны медленные per-card открытия: со страницы категории 2ГИС берём по фирме имя + ссылку карточки + САЙТ сразу (CategoryListingParser: сайт из редиректа «Перейти на сайт», firmId в пути = id фирмы; мусор avito/max.ru/трекеры/иностр.TLD отсеиваем). CategoryScraper отдаёт строки {name,card_url,site} с пагинацией. LiveFindCompetitors упрощён: канал А из списка (без резолва), канал В — имена → сайт EXA, без сайта (нет якоря) выкидываем. Живой канал А на новом ключе xfetch: 48 реальных фирм Красноярска, 17 с сайтом, ~4.5 мин, без per-card. Филиалы схлопывает merge (union-find). Модуль 135 unit + 74 feature зелёные. За флагом autopodbor.real_find; на проде не меняется. Co-Authored-By: Claude Opus 4.8 --- .../Providers/AutopodborServiceProvider.php | 6 +- .../Agent/ChannelA/CategoryListingParser.php | 107 ++++++++++++++++ .../Agent/ChannelA/CategoryScraper.php | 36 +++--- .../Autopodbor/Agent/LiveFindCompetitors.php | 93 +++++++------- .../ChannelA/CategoryListingParserTest.php | 56 +++++++++ .../ChannelA/CategoryScraperTest.php | 77 ++++++------ .../Autopodbor/LiveFindCompetitorsTest.php | 118 +++++++++--------- 7 files changed, 328 insertions(+), 165 deletions(-) create mode 100644 app/app/Services/Autopodbor/Agent/ChannelA/CategoryListingParser.php create mode 100644 app/tests/Unit/Autopodbor/ChannelA/CategoryListingParserTest.php diff --git a/app/app/Providers/AutopodborServiceProvider.php b/app/app/Providers/AutopodborServiceProvider.php index 1f165046..05e380ac 100644 --- a/app/app/Providers/AutopodborServiceProvider.php +++ b/app/app/Providers/AutopodborServiceProvider.php @@ -5,6 +5,7 @@ namespace App\Providers; use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter; use App\Services\Autopodbor\Agent\Aggregator\AitunnelAggregatorClassifier; use App\Services\Autopodbor\Agent\ChannelA\AitunnelQueryAnalyzer; +use App\Services\Autopodbor\Agent\ChannelA\CategoryListingParser; use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper; use App\Services\Autopodbor\Agent\ChannelB\AitunnelResearcher; use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch; @@ -20,8 +21,6 @@ use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher; use App\Services\Autopodbor\Agent\FindCompetitorsAssembler; use App\Services\Autopodbor\Agent\LiveFindCompetitors; use App\Services\Autopodbor\Agent\RealCompetitorAgent; -use App\Services\Autopodbor\Agent\Resolve\CompetitorResolver; -use App\Services\Autopodbor\Agent\Search\SearchResultsParser; use App\Services\Autopodbor\Agent\Similarity\AitunnelEmbedder; use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance; use App\Services\Autopodbor\AutopodborDedup; @@ -79,8 +78,7 @@ class AutopodborServiceProvider extends ServiceProvider return new LiveFindCompetitors( new AitunnelQueryAnalyzer($http), - new CategoryScraper($pages, new SearchResultsParser), - new CompetitorResolver($pages), + new CategoryScraper($pages, new CategoryListingParser), new ChannelBSearch(new AitunnelResearcher($http), new ResearcherParser), new ExaSiteFinder($http), $assembler, diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/CategoryListingParser.php b/app/app/Services/Autopodbor/Agent/ChannelA/CategoryListingParser.php new file mode 100644 index 00000000..d9a3c2ce --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/CategoryListingParser.php @@ -0,0 +1,107 @@ +) + САЙТ. + * + * Сайт лежит в редиректе «Перейти на сайт»: `…//null/?http://site.ru…` — id в пути = id + * фирмы, настоящий адрес после `?`. Мусор (агрегаторы/мессенджеры/трекеры/иностранные TLD) отсеиваем — + * это не сайт фирмы. Телефоны/описание тут НЕ трогаем (это шаг 2). Чистый: на вход HTML, наружу не ходит. + */ +final class CategoryListingParser +{ + /** Не сайт фирмы: агрегаторы/каталоги/соцсети/мессенджеры/трекеры. */ + private const BLACKLIST = [ + 'avito.ru', 'youla.ru', 'zoon.ru', 'banki.ru', 'sravni.ru', 'flamp.ru', 'orgpage.ru', + 'max.ru', 'vk.com', 'ok.ru', 't.me', 'telegram.me', 'wa.me', 'facebook.com', 'instagram.com', + 'youtube.com', 'dzen.ru', 'rambler.ru', 'mail.ru', 'tns-counter.ru', 'top100.ru', + 'serving-sys.ru', 'otello.ru', 'russpass.ru', 'gosuslugi.ru', + ]; + + private const FOREIGN_TLD = ['.kg', '.kz', '.by', '.ua', '.ge', '.am', '.uz', '.md', '.com.tr']; + + /** + * @return list + */ + public function parse(string $html): array + { + $sites = $this->siteMap($html); + + $out = []; + $seen = []; + $re = '#([^<]+)#u'; + if (preg_match_all($re, $html, $m, PREG_SET_ORDER)) { + foreach ($m as $hit) { + $id = $hit[2]; + if (isset($seen[$id])) { + continue; + } + $seen[$id] = true; + $name = trim(html_entity_decode($hit[3], ENT_QUOTES | ENT_HTML5, 'UTF-8')); + if ($name === '') { + continue; + } + $out[] = [ + 'name' => $name, + 'card_url' => 'https://2gis.ru'.$hit[1], + 'site' => $sites[$id] ?? null, + ]; + } + } + + return $out; + } + + /** + * firmId → домен сайта из редиректов «Перейти на сайт». Первый «настоящий» на фирму. + * + * @return array + */ + private function siteMap(string $html): array + { + $sites = []; + if (preg_match_all('#/(\d{10,})/null/[^"?]*\?(https?://[^"\\\\?]+)#', $html, $m, PREG_SET_ORDER)) { + foreach ($m as $hit) { + $id = $hit[1]; + if (isset($sites[$id])) { + continue; + } + $domain = $this->domain($hit[2]); + if ($domain !== null && $this->isRealSite($domain)) { + $sites[$id] = $domain; + } + } + } + + return $sites; + } + + private function domain(string $url): ?string + { + $url = mb_strtolower(trim($url), 'UTF-8'); + $url = preg_replace('#^https?://#', '', $url); + $url = preg_replace('#^www\.#', '', $url); + $url = explode('/', $url)[0]; + $url = explode('?', $url)[0]; + + return $url !== '' ? $url : null; + } + + private function isRealSite(string $domain): bool + { + if (in_array($domain, self::BLACKLIST, true)) { + return false; + } + foreach (self::FOREIGN_TLD as $tld) { + if (str_ends_with($domain, $tld)) { + return false; + } + } + + return true; + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/CategoryScraper.php b/app/app/Services/Autopodbor/Agent/ChannelA/CategoryScraper.php index c8c9ad70..03de280f 100644 --- a/app/app/Services/Autopodbor/Agent/ChannelA/CategoryScraper.php +++ b/app/app/Services/Autopodbor/Agent/ChannelA/CategoryScraper.php @@ -5,32 +5,32 @@ declare(strict_types=1); namespace App\Services\Autopodbor\Agent\ChannelA; use App\Services\Autopodbor\Agent\Fetch\PageFetcher; -use App\Services\Autopodbor\Agent\Search\SearchResultsParser; /** - * Канал А (§12.7): по каждому запросу-рубрике из шага АНАЛИЗ скрейпим категорию 2ГИС со СКВОЗНОЙ - * ПАГИНАЦИЕЙ всех страниц (`/search/` → `/search//page/2`…), собираем ссылки на карточки фирм. - * Дедуп между страницами и между запросами. Остановка: пустая страница / страница без новых фирм / - * лимит maxPages. Сами карточки (имя/сайт/телефоны) добывает дальше резолвер. + * Канал А (ZAFIKSIROVANO: шаг 1 = имя+сайт+карточка БЕЗ захода в карточку): по каждому запросу-рубрике + * из шага АНАЛИЗ скрейпим категорию 2ГИС со СКВОЗНОЙ ПАГИНАЦИЕЙ всех страниц и берём из СПИСКА по + * фирме имя + ссылку на карточку + сайт (через {@see CategoryListingParser}). Карточки не открываем — + * это резко быстрее. Дедуп между страницами и запросами по ссылке карточки. Остановка: пустая + * страница / страница без новых фирм / лимит maxPages. * - * Чистый над {@see PageFetcher} (живой — xfetch/firecrawl; в тестах — стаб), без прямой сети. + * Чистый над {@see PageFetcher} (живой — xfetch/firecrawl; в тестах — стаб). */ final class CategoryScraper { public function __construct( private readonly PageFetcher $pages, - private readonly SearchResultsParser $parser, + private readonly CategoryListingParser $parser, private readonly int $maxPages = 5, ) {} /** * @param list $queries запросы-рубрики из шага АНАЛИЗ - * @return list полные ссылки на карточки фирм 2ГИС + * @return list */ public function collectTwoGis(string $slug, array $queries): array { $seen = []; - $urls = []; + $out = []; foreach ($queries as $query) { $query = trim((string) $query); @@ -42,28 +42,28 @@ final class CategoryScraper for ($page = 1; $page <= max(1, $this->maxPages); $page++) { $url = $page === 1 ? $base : $base."/page/{$page}"; - $firms = $this->parser->twoGis($this->pages->html($url)); - if ($firms === []) { + $rows = $this->parser->parse($this->pages->html($url)); + if ($rows === []) { break; // выдача кончилась } $added = 0; - foreach ($firms as $firm) { - $path = $firm['path']; - if (isset($seen[$path])) { + foreach ($rows as $row) { + $key = $row['card_url']; + if (isset($seen[$key])) { continue; } - $seen[$path] = true; - $urls[] = 'https://2gis.ru'.$path; + $seen[$key] = true; + $out[] = $row; $added++; } if ($added === 0) { - break; // страница не дала ничего нового — дальше листать смысла нет + break; // страница без новых фирм — дальше листать незачем } } } - return $urls; + return $out; } } diff --git a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php index b1b7ea76..1ff05d1c 100644 --- a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php +++ b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php @@ -10,33 +10,28 @@ use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch; use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder; use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest; use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult; -use App\Services\Autopodbor\Agent\Resolve\CompetitorResolver; -use App\Services\Autopodbor\Agent\Resolve\ResolvedCompetitor; use App\Support\RegionCity; /** - * Живой findCompetitors — ФИНАЛ движка v4 (ZAFIKSIROVANO §0-БИС, §11–§12). Порядок: - * 1. Шаг АНАЛИЗ ({@see QueryAnalyzer}): описание клиента → запросы-рубрики. - * 2. КАНАЛ А ({@see CategoryScraper}): Firecrawl/xfetch скрейп категории 2ГИС с пагинацией → ссылки - * фирм → {@see CompetitorResolver} → реальные карточки (имя/сайт/телефоны/2ГИС). - * 3. КАНАЛ В ({@see ChannelBSearch}): ОДНА модель × 2 прохода → ТОЛЬКО ИМЕНА федералов/онлайн - * (стоп-лист = имена из А + примеры). У федерала нет карточки на регион → его САЙТ ищем EXA - * ({@see ExaSiteFinder}) → резолвер помечает федералом (нет местной карточки + есть сайт). + * Живой findCompetitors — ФИНАЛ v4, ЛЁГКАЯ версия шага 1 (ZAFIKSIROVANO: нужны ТОЛЬКО имя+сайт+карточка; + * телефоны/описание/глубокий сбор = шаг 2). БЕЗ захода в карточки и без per-name резолва — резко быстрее. + * 1. Шаг АНАЛИЗ ({@see QueryAnalyzer}): описание → запросы-рубрики. + * 2. КАНАЛ А ({@see CategoryScraper}): скрейп категории 2ГИС с пагинацией → из СПИСКА имя+карточка+сайт + * (местные фирмы, is_federal=false). + * 3. КАНАЛ В ({@see ChannelBSearch}): одна модель × 2 прохода → ИМЕНА федералов (стоп-лист = имена из А + + * примеры); сайт каждого — через EXA ({@see ExaSiteFinder}). Без сайта (нет якоря) — выкидываем. * 4. Слияние А+В → {@see FindCompetitorsAssembler}: отсев агрегаторов → дедуп+вычет клиента → - * федерал-фильтр → похожесть-эмбеддинги → DTO §7.2. - * - * Все внешние вызовы (модели/скрейп/exa/резолв) — за тонкими границами, логика тестируема офлайн. + * федерал-фильтр → похожесть-эмбеддинги (математически) → DTO §7.2. */ final class LiveFindCompetitors { public function __construct( private readonly QueryAnalyzer $analyzer, private readonly CategoryScraper $scraper, - private readonly CompetitorResolver $resolver, private readonly ChannelBSearch $channelB, private readonly ExaSiteFinder $exa, private readonly FindCompetitorsAssembler $assembler, - // Потолок имён канала В на резолв (ограничивает живые exa/резолв-вызовы за подбор). + // Потолок имён канала В на проверку сайта EXA (ограничивает живые exa-вызовы за подбор). private readonly int $channelBCap = 40, private readonly int $passes = 2, ) {} @@ -48,43 +43,64 @@ final class LiveFindCompetitors $city = RegionCity::name($r->regionCode) ?? ''; $slug = RegionCity::slug($r->regionCode); - // 1+2. АНАЛИЗ → канал А (справочники, пагинация) → резолв карточек. + // 1+2. АНАЛИЗ → канал А: из списка категории имя+карточка+сайт (без захода в карточку). $queries = $this->analyzer->analyze($profile, $city); - $aCards = []; - if ($slug !== null && $queries !== []) { - foreach ($this->scraper->collectTwoGis($slug, $queries) as $firmUrl) { - $card = $this->resolver->resolve('', $firmUrl, $city); - if ($card->name !== '') { - $aCards[] = $card; - } - } - } + $aRows = ($slug !== null && $queries !== []) + ? $this->scraper->collectTwoGis($slug, $queries) + : []; + $aCards = array_map(fn (array $row): array => $this->localCard($row), $aRows); - // 3. Канал В: стоп-лист = имена из А + примеры клиента → имена → EXA-сайт → резолв. + // 3. Канал В: имена федералов → САЙТ через EXA. Нет сайта = нет якоря → выкидываем. $bCards = []; if ($r->includeFederal) { $known = array_merge( - array_map(static fn (ResolvedCompetitor $c): string => $c->name, $aCards), + array_map(static fn (array $c): string => $c['name'], $aCards), $this->stringList($r->examples), ); $names = $this->channelB->harvest($profile, $city, $clientSite, $known, $this->passes); foreach (array_slice($names, 0, max(0, $this->channelBCap)) as $cand) { $site = $this->exa->findSite($cand['name'], $city); - $bCards[] = $this->resolver->resolve($cand['name'], null, $city, $site); + if ($site !== null) { + $bCards[] = $this->federalCard($cand['name'], $site); + } } } - $candidates = array_map( - fn (ResolvedCompetitor $c): array => $this->toArray($c), - array_merge($aCards, $bCards), - ); - + $candidates = array_merge($aCards, $bCards); $clientKeys = $this->stringList($r->aboutSelf); $examples = $this->stringList($r->examples); return $this->assembler->assemble($candidates, $examples, $clientKeys, $r->includeFederal, $r->maxCompetitors); } + /** Местная карточка из строки списка 2ГИС (канал А). */ + private function localCard(array $row): array + { + return [ + 'name' => (string) $row['name'], + 'site_url' => $row['site'] ?? null, + 'description' => null, + 'is_federal' => false, + 'directory_urls' => [$row['card_url']], + 'phones' => [], + 'provenance' => ['via' => 'engine', 'source' => '2gis-list'], + ]; + } + + /** Федеральная карточка (канал В): имя + сайт из EXA, без карточки на регион. */ + private function federalCard(string $name, string $site): array + { + return [ + 'name' => $name, + 'site_url' => $site, + 'description' => null, + 'is_federal' => true, + 'directory_urls' => [], + 'phones' => [], + 'provenance' => ['via' => 'engine', 'source' => 'channelB-exa'], + ]; + } + /** Профиль (ниша) = первая непустая строка «о себе». */ private function profile(array $aboutSelf): string { @@ -119,17 +135,4 @@ final class LiveFindCompetitors static fn (string $v): bool => $v !== '', )); } - - private function toArray(ResolvedCompetitor $c): array - { - return [ - 'name' => $c->name, - 'site_url' => $c->siteUrl, - 'description' => $c->description, - 'is_federal' => $c->isFederal, - 'directory_urls' => $c->directoryUrl !== null ? [$c->directoryUrl] : [], - 'phones' => $c->phones, - 'provenance' => ['via' => 'engine', 'source' => $c->source], - ]; - } } diff --git a/app/tests/Unit/Autopodbor/ChannelA/CategoryListingParserTest.php b/app/tests/Unit/Autopodbor/ChannelA/CategoryListingParserTest.php new file mode 100644 index 00000000..03d1be86 --- /dev/null +++ b/app/tests/Unit/Autopodbor/ChannelA/CategoryListingParserTest.php @@ -0,0 +1,56 @@ +/null/?http://site.ru — id в пути = id фирмы. Мусор (avito/max.ru/трекеры) отсеиваем. +// Разметка — реальная (xfetch-рендер 2ГИС, проверено вживую 30.06). + +beforeEach(function () { + $this->parser = new CategoryListingParser; + + // Реальная разметка: ссылка фирмы с ?stat= + имя в NAME, + // и редиректы сайтов с firmId в пути. КрасЛомбард и Голд — с сайтом, Дружба — без. + $this->html = + 'КрасЛомбард '. + 'Голд Авто Инвест '. + 'Ломбард Дружба'. + '{"caption":"Перейти на сайт","url":"https://link.2gis.ru/e/project7/985690700540003/null/HASH1?http://kraslombard24.ru/terms.html"}'. + '{"caption":"Перейти на сайт","url":"https://link.2gis.ru/e/project7/70000001062446169/null/HASH2?http://goldautoinvest.ru/"}'. + '{"caption":"Перейти на сайт","url":"https://link.2gis.ru/e/project7/985690699685728/null/HASH3?https://www.avito.ru/krasnoyarsk"}'; +}); + +it('тащит имя + ссылку на карточку + сайт прямо из списка', function () { + $out = $this->parser->parse($this->html); + + expect($out)->toHaveCount(3); + + $kl = collect($out)->firstWhere('name', 'КрасЛомбард'); + expect($kl['card_url'])->toBe('https://2gis.ru/krasnoyarsk/firm/985690700540003') + ->and($kl['site'])->toBe('kraslombard24.ru'); + + $gold = collect($out)->firstWhere('name', 'Голд Авто Инвест'); + expect($gold['site'])->toBe('goldautoinvest.ru'); +}); + +it('фирма без сайта в списке → site=null (не выдумываем)', function () { + $out = $this->parser->parse($this->html); + + $druzhba = collect($out)->firstWhere('name', 'Ломбард Дружба'); + expect($druzhba)->not->toBeNull()->and($druzhba['site'])->toBeNull(); +}); + +it('агрегаторы/мессенджеры в сайтах не цепляются (avito пропущена, своей фирмы нет)', function () { + $out = $this->parser->parse($this->html); + + // у avito-редиректа нет ссылки на фирму в списке → её и нет в выдаче; и домен avito в любом случае в ЧС + expect(collect($out)->pluck('site')->filter()->all())->not->toContain('avito.ru'); +}); + +it('пустой/мусорный HTML → []', function () { + expect($this->parser->parse(''))->toBe([]) + ->and($this->parser->parse('пусто'))->toBe([]); +}); diff --git a/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php b/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php index 05b6ebb5..a7ea8cbc 100644 --- a/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php +++ b/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php @@ -2,15 +2,14 @@ declare(strict_types=1); +use App\Services\Autopodbor\Agent\ChannelA\CategoryListingParser; use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper; use App\Services\Autopodbor\Agent\Fetch\PageFetcher; -use App\Services\Autopodbor\Agent\Search\SearchResultsParser; -// Канал А (§12.7): по каждому запросу-рубрике скрейпим категорию 2ГИС с ПАГИНАЦИЕЙ всех страниц -// + Яндекс. Дедуп фирм между страницами и между запросами. Остановка на пустой странице. -// Чистая логика над PageFetcher (стаб в тесте), без сети. +// Канал А: по запросу-рубрике скрейпим категорию 2ГИС со СКВОЗНОЙ ПАГИНАЦИЕЙ, берём из списка +// имя+карточку+сайт (CategoryListingParser). Дедуп между страницами и запросами. Стоп на пустой. +// Чистая логика над PageFetcher (стаб), без сети. -// Фейк-фетчер: отдаёт заранее заданный HTML по подстроке URL; что не сматчилось — пусто. final class MapFetcher implements PageFetcher { /** @param array $map подстрока URL => HTML */ @@ -28,52 +27,56 @@ final class MapFetcher implements PageFetcher } } -function firmHtml(array $ids): string +/** Разметка списка категории 2ГИС: фирмы [[id,name,site?],...]. */ +function listingHtml(array $firms): string { - $html = ''; - foreach ($ids as $id) { - $html .= 'Фирма '.$id.''; + $h = ''; + foreach ($firms as $f) { + $id = $f[0]; + $name = $f[1]; + $site = $f[2] ?? null; + $h .= ''.$name.''; + if ($site !== null) { + $h .= '{"caption":"Перейти на сайт","url":"https://link.2gis.ru/e/project7/'.$id.'/null/H?http://'.$site.'/"}'; + } } - return $html; + return $h; } -it('мульти-запрос × пагинация: собирает фирмы со всех страниц, дедуп между запросами', function () { +it('мульти-запрос × пагинация: имя+карточка+сайт со всех страниц, дедуп между запросами', function () { + // id фирм — реалистично длинные (как у 2ГИС: 985690700540003) $fetcher = new MapFetcher([ - // запрос «автоломбард»: стр.1 → 1,2 ; стр.2 → 3 ; стр.3 → пусто (стоп) - '/search/%D0%B0%D0%B2%D1%82%D0%BE%D0%BB%D0%BE%D0%BC%D0%B1%D0%B0%D1%80%D0%B4/page/2' => firmHtml([3]), - '/search/%D0%B0%D0%B2%D1%82%D0%BE%D0%BB%D0%BE%D0%BC%D0%B1%D0%B0%D1%80%D0%B4' => firmHtml([1, 2]), - // запрос «ломбард»: стр.1 → 2(дубль),4 ; стр.2 → пусто - '/search/%D0%BB%D0%BE%D0%BC%D0%B1%D0%B0%D1%80%D0%B4' => firmHtml([2, 4]), + // «автоломбард»: стр.1 → 1(site),2 ; стр.2 → 3 ; стр.3 пусто + '/search/%D0%B0%D0%B2%D1%82%D0%BE%D0%BB%D0%BE%D0%BC%D0%B1%D0%B0%D1%80%D0%B4/page/2' => listingHtml([[70000000000003, 'Фирма3', null]]), + '/search/%D0%B0%D0%B2%D1%82%D0%BE%D0%BB%D0%BE%D0%BC%D0%B1%D0%B0%D1%80%D0%B4' => listingHtml([[70000000000001, 'Фирма1', 'firma1.ru'], [70000000000002, 'Фирма2', null]]), + // «ломбард»: стр.1 → 2(дубль),4 ; стр.2 пусто + '/search/%D0%BB%D0%BE%D0%BC%D0%B1%D0%B0%D1%80%D0%B4' => listingHtml([[70000000000002, 'Фирма2', null], [70000000000004, 'Фирма4', 'firma4.ru']]), ]); - $scraper = new CategoryScraper($fetcher, new SearchResultsParser, maxPages: 5); + $rows = (new CategoryScraper($fetcher, new CategoryListingParser, maxPages: 5)) + ->collectTwoGis('krasnoyarsk', ['автоломбард', 'ломбард']); - $urls = $scraper->collectTwoGis('krasnoyarsk', ['автоломбард', 'ломбард']); + expect($rows)->toHaveCount(4); // 1,2,3,4 по разу (2 не задвоилась, 3 с 2-й страницы) + $names = array_column($rows, 'name'); + expect($names)->toContain('Фирма1', 'Фирма2', 'Фирма3', 'Фирма4'); - // фирмы 1,2,3,4 — по одному разу (2 не задвоилась, 3 пришла с 2-й страницы → пагинация работает) - expect($urls)->toHaveCount(4); - $joined = implode(' ', $urls); - foreach ([1, 2, 3, 4] as $id) { - expect($joined)->toContain('/firm/'.$id); - } - // это полные ссылки на 2ГИС - expect($urls[0])->toStartWith('https://2gis.ru/'); + $f1 = collect($rows)->firstWhere('name', 'Фирма1'); + expect($f1['site'])->toBe('firma1.ru') + ->and($f1['card_url'])->toBe('https://2gis.ru/krasnoyarsk/firm/70000000000001'); }); -it('пустая выдача по запросу — ничего не добавляет, не падает', function () { - $scraper = new CategoryScraper(new MapFetcher([]), new SearchResultsParser, maxPages: 3); +it('пустая выдача → пусто, не падает', function () { + $rows = (new CategoryScraper(new MapFetcher([]), new CategoryListingParser, maxPages: 3)) + ->collectTwoGis('krasnoyarsk', ['ничего']); - expect($scraper->collectTwoGis('krasnoyarsk', ['ничего']))->toBe([]); + expect($rows)->toBe([]); }); -it('останавливает пагинацию на maxPages', function () { - // каждая страница непустая → должно ограничиться maxPages вызовами на запрос - $fetcher = new MapFetcher(['/search/' => firmHtml([7])]); - $scraper = new CategoryScraper($fetcher, new SearchResultsParser, maxPages: 2); +it('останавливает пагинацию на повторах/maxPages (без бесконечного цикла)', function () { + $fetcher = new MapFetcher(['/search/' => listingHtml([[70000000000007, 'Фирма7', null]])]); + $rows = (new CategoryScraper($fetcher, new CategoryListingParser, maxPages: 3)) + ->collectTwoGis('krasnoyarsk', ['займ']); - // фирма 7 одна и та же на всех страницах → дедуп оставит 1, но без падения/бесконечного цикла - $urls = $scraper->collectTwoGis('krasnoyarsk', ['займ']); - - expect($urls)->toHaveCount(1); + expect($rows)->toHaveCount(1); }); diff --git a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php index 1154a8ae..41c95602 100644 --- a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php +++ b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); use App\Services\Autopodbor\Agent\Aggregator\AggregatorClassifier; use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter; +use App\Services\Autopodbor\Agent\ChannelA\CategoryListingParser; use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper; use App\Services\Autopodbor\Agent\ChannelA\QueryAnalyzer; use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch; @@ -13,8 +14,6 @@ use App\Services\Autopodbor\Agent\ChannelB\ResearcherParser; use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest; use App\Services\Autopodbor\Agent\FindCompetitorsAssembler; use App\Services\Autopodbor\Agent\LiveFindCompetitors; -use App\Services\Autopodbor\Agent\Resolve\CompetitorResolver; -use App\Services\Autopodbor\Agent\Search\SearchResultsParser; use App\Services\Autopodbor\Agent\Similarity\Embedder; use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance; use App\Services\Autopodbor\AutopodborDedup; @@ -22,10 +21,9 @@ use App\Services\Autopodbor\AutopodborNormalizer; use Illuminate\Http\Client\Factory as HttpFactory; use Illuminate\Support\Facades\Config; -// Сквозной офлайн-тест ФИНАЛЬНОГО оркестратора (ZAFIKSIROVANO §0-БИС): АНАЛИЗ → канал А (скрейп -// категории 2ГИС + резолв карточек) → канал В (имена федералов → САЙТ через EXA → резолв) → сборка. -// ИИ-границы фейкаем, страницы — stubPages на реальных фикстурах, exa — Http fake. -// stubPages()/autopodborFixture() — глобальные хелперы из tests/Pest.php. +// Сквозной офлайн-тест ЛЁГКОГО оркестратора: АНАЛИЗ → канал А (из списка 2ГИС имя+карточка+сайт, +// без захода в карточку) → канал В (имена → сайт EXA, без сайта выкидываем) → сборка. +// ИИ-границы фейкаем; страницы — stubPages; exa — Http fake. listingHtml() — из CategoryScraperTest. function fakeAnalyzer(array $queries): QueryAnalyzer { @@ -61,7 +59,7 @@ function noAiAssembler(): FindCompetitorsAssembler { public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool { - return null; // без ИИ — никого не выкидываем + return null; } }; $zeroEmbedder = new class implements Embedder @@ -79,82 +77,80 @@ function noAiAssembler(): FindCompetitorsAssembler ); } -it('сквозной: канал А (2ГИС-карточка) + канал В (федерал с сайтом из EXA) попадают в результат', function () { +function leanEngine($pages, ResearcherClient $researcher, HttpFactory $http, array $queries): LiveFindCompetitors +{ + return new LiveFindCompetitors( + fakeAnalyzer($queries), + new CategoryScraper($pages, new CategoryListingParser, maxPages: 2), + new ChannelBSearch($researcher, new ResearcherParser), + new ExaSiteFinder($http), + noAiAssembler(), + ); +} + +it('сквозной: канал А (2ГИС-список: имя+карточка+сайт) + канал В (федерал с сайтом EXA)', function () { Config::set('services.exa', ['key' => 'k', 'base_url' => 'https://api.exa.ai', 'timeout_sec' => 30]); $http = app(HttpFactory::class); $http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://carmoney.ru/']]], 200)]); $pages = stubPages([ - '2gis.ru/krasnoyarsk/search/' => autopodborFixture('2gis-search-kraslombard.html'), - '/firm/' => autopodborFixture('2gis-firm-kraslombard.html'), - // Яндекс по «CarMoney …» вернёт КрасЛомбарда — резолвер отвергнет по несовпадению имени → федерал. - 'maps/?text=' => autopodborFixture('yandex-search-kraslombard.html'), - '/maps/org/' => autopodborFixture('yandex-org-kraslombard.html'), + '2gis.ru/krasnoyarsk/search/' => listingHtml([[985690700540003, 'КрасЛомбард', 'kraslombard24.ru']]), ]); - $find = new LiveFindCompetitors( - fakeAnalyzer(['ломбард']), - new CategoryScraper($pages, new SearchResultsParser, maxPages: 2), - new CompetitorResolver($pages), - new ChannelBSearch(fakeResearcher(['[{"name":"CarMoney","type":"федеральная"}]', '[]']), new ResearcherParser), - new ExaSiteFinder($http), - noAiAssembler(), - ); + $res = leanEngine($pages, fakeResearcher(['[{"name":"CarMoney","type":"федеральная"}]', '[]']), $http, ['ломбард']) + ->find(new FindCompetitorsRequest( + regionCode: 29, + examples: ['automoney-krsk.ru'], + aboutSelf: ['займы под залог авто', 'lkomega.ru'], + includeFederal: true, + maxCompetitors: 20, + )); - $res = $find->find(new FindCompetitorsRequest( - regionCode: 29, - examples: ['automoney-krsk.ru'], - aboutSelf: ['займы под залог авто', 'lkomega.ru'], - includeFederal: true, - maxCompetitors: 20, - )); + $kl = collect($res->competitors)->firstWhere('name', 'КрасЛомбард'); + expect($kl)->not->toBeNull() + ->and($kl['site_url'])->toBe('kraslombard24.ru') + ->and($kl['is_federal'])->toBeFalse() + ->and(collect($kl['directory_urls'])->contains(fn ($u) => str_contains($u, '/firm/985690700540003')))->toBeTrue(); - $names = array_column($res->competitors, 'name'); - expect($names)->toContain('КрасЛомбард'); // канал А - - $carmoney = collect($res->competitors)->firstWhere('name', 'CarMoney'); - expect($carmoney)->not->toBeNull() - ->and($carmoney['is_federal'])->toBeTrue() - ->and($carmoney['site_url'])->toBe('carmoney.ru'); // сайт из EXA + $cm = collect($res->competitors)->firstWhere('name', 'CarMoney'); + expect($cm)->not->toBeNull() + ->and($cm['is_federal'])->toBeTrue() + ->and($cm['site_url'])->toBe('carmoney.ru'); }); -it('includeFederal=false → канал В выключен, остаётся только местная А', function () { +it('канал В: имя без сайта в EXA выкидывается (нет якоря)', function () { + Config::set('services.exa.key', 'k'); + $http = app(HttpFactory::class); + // EXA не нашла подходящего сайта (только агрегатор) → site=null → имя выкинуто + $http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://zoon.ru/x']]], 200)]); + + $pages = stubPages(['2gis.ru/krasnoyarsk/search/' => listingHtml([[985690700540003, 'КрасЛомбард', 'kraslombard24.ru']])]); + + $res = leanEngine($pages, fakeResearcher(['[{"name":"Призрак","type":"федеральная"}]', '[]']), $http, ['ломбард']) + ->find(new FindCompetitorsRequest(29, [], ['ломбард', 'lkomega.ru'], true, 20)); + + $names = array_column($res->competitors, 'name'); + expect($names)->toContain('КрасЛомбард')->and($names)->not->toContain('Призрак'); +}); + +it('includeFederal=false → канал В выключен, остаётся местная А', function () { Config::set('services.exa.key', 'k'); $http = app(HttpFactory::class); $http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://carmoney.ru/']]], 200)]); - $pages = stubPages([ - '2gis.ru/krasnoyarsk/search/' => autopodborFixture('2gis-search-kraslombard.html'), - '/firm/' => autopodborFixture('2gis-firm-kraslombard.html'), - ]); + $pages = stubPages(['2gis.ru/krasnoyarsk/search/' => listingHtml([[985690700540003, 'КрасЛомбард', 'kraslombard24.ru']])]); - $find = new LiveFindCompetitors( - fakeAnalyzer(['ломбард']), - new CategoryScraper($pages, new SearchResultsParser, maxPages: 2), - new CompetitorResolver($pages), - new ChannelBSearch(fakeResearcher(['[{"name":"CarMoney","type":"федеральная"}]']), new ResearcherParser), - new ExaSiteFinder($http), - noAiAssembler(), - ); + $res = leanEngine($pages, fakeResearcher(['[{"name":"CarMoney","type":"федеральная"}]']), $http, ['ломбард']) + ->find(new FindCompetitorsRequest(29, [], ['ломбард', 'lkomega.ru'], false, 20)); - $res = $find->find(new FindCompetitorsRequest(29, [], ['ломбард', 'lkomega.ru'], false, 20)); $names = array_column($res->competitors, 'name'); - - expect($names)->toContain('КрасЛомбард') - ->and($names)->not->toContain('CarMoney'); + expect($names)->toContain('КрасЛомбард')->and($names)->not->toContain('CarMoney'); }); it('пусто везде → пустой список, без падения', function () { Config::set('services.exa.key', ''); - $find = new LiveFindCompetitors( - fakeAnalyzer(['ничего']), - new CategoryScraper(stubPages([]), new SearchResultsParser, maxPages: 2), - new CompetitorResolver(stubPages([])), - new ChannelBSearch(fakeResearcher(['[]', '[]']), new ResearcherParser), - new ExaSiteFinder(app(HttpFactory::class)), - noAiAssembler(), - ); + $res = leanEngine(stubPages([]), fakeResearcher(['[]', '[]']), app(HttpFactory::class), ['ничего']) + ->find(new FindCompetitorsRequest(29, [], ['ничего', 'self.ru'], true, 10)); - $res = $find->find(new FindCompetitorsRequest(29, [], ['ничего', 'self.ru'], true, 10)); expect($res->competitors)->toBe([]); }); From c3f0f272e385f35db73e740e341cb0879d5e5600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 1 Jul 2026 04:53:58 +0300 Subject: [PATCH 100/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B2=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=B0=20=D0=90=20?= =?UTF-8?q?=E2=80=94=20=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA=D1=81.=D0=9A=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D1=8B=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20Playwright?= =?UTF-8?q?=20+=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firecrawl Яндекс.Карты не рендерит (0-2 орг) — по §12.2 Яндекс берём локальным Playwright. render-yandex-list.cjs скроллит ленту результатов → 113 орг за ~18с (быстрее xfetch-2ГИС). YandexDirectory (граница) + PlaywrightYandexDirectory (живой, Process→node). Яндекс = имя+карточка (сайта в списке нет — только на карточке, не открываем). Оркестратор: канал А = 2ГИС(сайт)+Яндекс, слияние (mergeCompetitors union-find) схлопывает одного конкурента из обоих справочников в одну карточку с двумя directory_urls; сайт из 2ГИС. Провайдер подключает живой Яндекс. listingHtml → общий хелпер tests/Pest.php. Модуль 136 unit + 74 feature зелёные. За флагом; на проде не меняется. Co-Authored-By: Claude Opus 4.8 --- .../Providers/AutopodborServiceProvider.php | 2 + .../ChannelA/PlaywrightYandexDirectory.php | 78 +++++++++++++++++++ .../Agent/ChannelA/YandexDirectory.php | 19 +++++ .../Autopodbor/Agent/LiveFindCompetitors.php | 29 ++++--- app/scripts/render-yandex-list.cjs | 59 ++++++++++++++ app/tests/Pest.php | 22 ++++++ .../ChannelA/CategoryScraperTest.php | 17 +--- .../Autopodbor/LiveFindCompetitorsTest.php | 45 ++++++++++- 8 files changed, 245 insertions(+), 26 deletions(-) create mode 100644 app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php create mode 100644 app/app/Services/Autopodbor/Agent/ChannelA/YandexDirectory.php create mode 100644 app/scripts/render-yandex-list.cjs diff --git a/app/app/Providers/AutopodborServiceProvider.php b/app/app/Providers/AutopodborServiceProvider.php index 05e380ac..3927634e 100644 --- a/app/app/Providers/AutopodborServiceProvider.php +++ b/app/app/Providers/AutopodborServiceProvider.php @@ -7,6 +7,7 @@ use App\Services\Autopodbor\Agent\Aggregator\AitunnelAggregatorClassifier; use App\Services\Autopodbor\Agent\ChannelA\AitunnelQueryAnalyzer; use App\Services\Autopodbor\Agent\ChannelA\CategoryListingParser; use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper; +use App\Services\Autopodbor\Agent\ChannelA\PlaywrightYandexDirectory; use App\Services\Autopodbor\Agent\ChannelB\AitunnelResearcher; use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch; use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder; @@ -79,6 +80,7 @@ class AutopodborServiceProvider extends ServiceProvider return new LiveFindCompetitors( new AitunnelQueryAnalyzer($http), new CategoryScraper($pages, new CategoryListingParser), + new PlaywrightYandexDirectory, new ChannelBSearch(new AitunnelResearcher($http), new ResearcherParser), new ExaSiteFinder($http), $assembler, diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php b/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php new file mode 100644 index 00000000..613bc67c --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php @@ -0,0 +1,78 @@ + <город>`, скроллит ленту и берёт + * организации: имя + ссылка на карточку. Скрипт — `scripts/render-yandex-list.cjs` (печатает JSON {orgs}). + * Дедуп по id между запросами; потолок на число рендеров (лимит времени/нагрузки при параллельных клиентах). + * + * Сеть/браузер за Process — офлайн не юнит-тестим; оркестратор тестируется через фейк YandexDirectory. + */ +final class PlaywrightYandexDirectory implements YandexDirectory +{ + private string $script; + + public function __construct( + private readonly string $nodeBin = 'node', + string $script = '', + private readonly int $timeout = 120, + // Не рендерим больше N запросов за подбор (локальный браузер тяжёл при параллельных клиентах). + private readonly int $maxQueries = 4, + ) { + $this->script = $script !== '' ? $script : base_path('scripts/render-yandex-list.cjs'); + } + + public function collect(string $city, array $queries): array + { + $seen = []; + $out = []; + + foreach (array_slice($queries, 0, max(0, $this->maxQueries)) as $query) { + $query = trim((string) $query); + if ($query === '') { + continue; + } + + $url = 'https://yandex.ru/maps/?text='.rawurlencode(trim($query.' '.$city)); + foreach ($this->render($url) as $org) { + $href = (string) ($org['href'] ?? ''); + $name = trim((string) ($org['name'] ?? '')); + if ($name === '' || ! preg_match('#^/maps/org/[a-z0-9_-]+/(\d+)#i', $href, $m)) { + continue; + } + $id = $m[1]; + if (isset($seen[$id])) { + continue; + } + $seen[$id] = true; + $out[] = ['name' => $name, 'card_url' => 'https://yandex.ru'.$href]; + } + } + + return $out; + } + + /** @return list> orgs из render-скрипта; ошибка → []. */ + private function render(string $url): array + { + try { + $p = new Process([$this->nodeBin, $this->script, $url]); + $p->setTimeout($this->timeout); + $p->run(); + if (! $p->isSuccessful()) { + return []; + } + $json = json_decode($p->getOutput(), true); + + return is_array($json['orgs'] ?? null) ? $json['orgs'] : []; + } catch (\Throwable) { + return []; + } + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/YandexDirectory.php b/app/app/Services/Autopodbor/Agent/ChannelA/YandexDirectory.php new file mode 100644 index 00000000..d38f9262 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/YandexDirectory.php @@ -0,0 +1,19 @@ + $queries запросы-рубрики из шага АНАЛИЗ + * @return list + */ + public function collect(string $city, array $queries): array; +} diff --git a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php index 1ff05d1c..ef7344c1 100644 --- a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php +++ b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php @@ -6,6 +6,7 @@ namespace App\Services\Autopodbor\Agent; use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper; use App\Services\Autopodbor\Agent\ChannelA\QueryAnalyzer; +use App\Services\Autopodbor\Agent\ChannelA\YandexDirectory; use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch; use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder; use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest; @@ -28,6 +29,7 @@ final class LiveFindCompetitors public function __construct( private readonly QueryAnalyzer $analyzer, private readonly CategoryScraper $scraper, + private readonly YandexDirectory $yandex, private readonly ChannelBSearch $channelB, private readonly ExaSiteFinder $exa, private readonly FindCompetitorsAssembler $assembler, @@ -43,12 +45,19 @@ final class LiveFindCompetitors $city = RegionCity::name($r->regionCode) ?? ''; $slug = RegionCity::slug($r->regionCode); - // 1+2. АНАЛИЗ → канал А: из списка категории имя+карточка+сайт (без захода в карточку). + // 1+2. АНАЛИЗ → канал А, ДВА справочника: 2ГИС (xfetch, имя+карточка+сайт из списка) + + // Яндекс (локальный Playwright, имя+карточка, без сайта). Слияние ниже объединит дубли по имени. $queries = $this->analyzer->analyze($profile, $city); - $aRows = ($slug !== null && $queries !== []) + $twoGisRows = ($slug !== null && $queries !== []) ? $this->scraper->collectTwoGis($slug, $queries) : []; - $aCards = array_map(fn (array $row): array => $this->localCard($row), $aRows); + $yandexRows = ($queries !== []) + ? $this->yandex->collect($city, $queries) + : []; + $aCards = array_merge( + array_map(fn (array $row): array => $this->localCard($row['name'], $row['card_url'], $row['site'] ?? null), $twoGisRows), + array_map(fn (array $row): array => $this->localCard($row['name'], $row['card_url'], null), $yandexRows), + ); // 3. Канал В: имена федералов → САЙТ через EXA. Нет сайта = нет якоря → выкидываем. $bCards = []; @@ -73,17 +82,19 @@ final class LiveFindCompetitors return $this->assembler->assemble($candidates, $examples, $clientKeys, $r->includeFederal, $r->maxCompetitors); } - /** Местная карточка из строки списка 2ГИС (канал А). */ - private function localCard(array $row): array + /** Местная карточка из списка справочника (канал А): 2ГИС (с сайтом) или Яндекс (site=null). */ + private function localCard(string $name, string $cardUrl, ?string $site): array { + $source = str_contains($cardUrl, 'yandex.ru') ? 'yandex-list' : '2gis-list'; + return [ - 'name' => (string) $row['name'], - 'site_url' => $row['site'] ?? null, + 'name' => $name, + 'site_url' => $site, 'description' => null, 'is_federal' => false, - 'directory_urls' => [$row['card_url']], + 'directory_urls' => [$cardUrl], 'phones' => [], - 'provenance' => ['via' => 'engine', 'source' => '2gis-list'], + 'provenance' => ['via' => 'engine', 'source' => $source], ]; } diff --git a/app/scripts/render-yandex-list.cjs b/app/scripts/render-yandex-list.cjs new file mode 100644 index 00000000..1ad64500 --- /dev/null +++ b/app/scripts/render-yandex-list.cjs @@ -0,0 +1,59 @@ +// Usage: node render-yandex-list.cjs +// Рендер страницы категории Яндекс.Карт локальным Playwright (Firecrawl её не берёт, §12.2). +// Скроллит КОНТЕЙНЕР СПИСКА результатов (подгрузка ленивая) и печатает JSON: +// { orgs: [{ name, id, href }] } — имя + id + ссылка на карточку организации. +// Сайт в списке Яндекса НЕ отдаётся (только на карточке) — здесь не собираем (шаг 1 = имя+карточка). +// require('playwright') резолвится из node_modules корня репо (скрипт лежит под app/scripts). +const { chromium } = require('playwright'); + +(async () => { + const url = process.argv[2]; + let parsed; + try { + parsed = new URL(url); + } catch (e) { + console.error('bad url'); + process.exit(2); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + console.error('bad scheme'); + process.exit(2); + } + + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ locale: 'ru-RU' }); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 }); + await page.waitForTimeout(4000); + + // Ленивая лента результатов — скроллим её контейнер, пока подгружается. + for (let i = 0; i < 14; i++) { + await page.evaluate(() => { + const el = document.querySelector('.scroll__container, .search-list-view__list, [class*="search-list"]'); + if (el) el.scrollBy(0, 2000); + }); + await page.waitForTimeout(800); + } + + const orgs = await page.$$eval('a[href*="/maps/org/"]', (els) => { + const seen = new Set(); + const out = []; + for (const e of els) { + const href = e.getAttribute('href') || ''; + const m = href.match(/\/maps\/org\/[a-z0-9_-]+\/(\d+)/i); + if (!m || seen.has(m[1])) continue; + seen.add(m[1]); + const name = (e.getAttribute('aria-label') || e.textContent || '').trim(); + if (name) out.push({ name, id: m[1], href: href.split('?')[0] }); + } + return out; + }); + + process.stdout.write(JSON.stringify({ orgs })); + } finally { + await browser.close(); + } +})().catch((e) => { + console.error(String(e)); + process.exit(1); +}); diff --git a/app/tests/Pest.php b/app/tests/Pest.php index 62859391..0b2ff9d5 100644 --- a/app/tests/Pest.php +++ b/app/tests/Pest.php @@ -100,6 +100,28 @@ function stubPages(array $byNeedle): PageFetcher }; } +/** + * Разметка списка категории 2ГИС для тестов канала А: фирмы [[id,name,site?],...]. + * Общий хелпер (используют CategoryScraperTest и LiveFindCompetitorsTest). + * + * @param array $firms + */ +function listingHtml(array $firms): string +{ + $h = ''; + foreach ($firms as $f) { + $id = $f[0]; + $name = $f[1]; + $site = $f[2] ?? null; + $h .= ''.$name.''; + if ($site !== null) { + $h .= '{"caption":"Перейти на сайт","url":"https://link.2gis.ru/e/project7/'.$id.'/null/H?http://'.$site.'/"}'; + } + } + + return $h; +} + /** * Link a Лидерра-project to a supplier_project via the M:N pivot * (Plan 1 model). Post-Plan-2 LeadRouter eligibility queries the pivot diff --git a/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php b/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php index a7ea8cbc..faf4e0a6 100644 --- a/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php +++ b/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php @@ -27,22 +27,7 @@ final class MapFetcher implements PageFetcher } } -/** Разметка списка категории 2ГИС: фирмы [[id,name,site?],...]. */ -function listingHtml(array $firms): string -{ - $h = ''; - foreach ($firms as $f) { - $id = $f[0]; - $name = $f[1]; - $site = $f[2] ?? null; - $h .= ''.$name.''; - if ($site !== null) { - $h .= '{"caption":"Перейти на сайт","url":"https://link.2gis.ru/e/project7/'.$id.'/null/H?http://'.$site.'/"}'; - } - } - - return $h; -} +// listingHtml() — общий хелпер в tests/Pest.php. it('мульти-запрос × пагинация: имя+карточка+сайт со всех страниц, дедуп между запросами', function () { // id фирм — реалистично длинные (как у 2ГИС: 985690700540003) diff --git a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php index 41c95602..86678d38 100644 --- a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php +++ b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php @@ -7,6 +7,7 @@ use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter; use App\Services\Autopodbor\Agent\ChannelA\CategoryListingParser; use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper; use App\Services\Autopodbor\Agent\ChannelA\QueryAnalyzer; +use App\Services\Autopodbor\Agent\ChannelA\YandexDirectory; use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch; use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder; use App\Services\Autopodbor\Agent\ChannelB\ResearcherClient; @@ -77,11 +78,25 @@ function noAiAssembler(): FindCompetitorsAssembler ); } -function leanEngine($pages, ResearcherClient $researcher, HttpFactory $http, array $queries): LiveFindCompetitors +function fakeYandex(array $rows): YandexDirectory +{ + return new class($rows) implements YandexDirectory + { + public function __construct(private array $rows) {} + + public function collect(string $city, array $queries): array + { + return $this->rows; + } + }; +} + +function leanEngine($pages, ResearcherClient $researcher, HttpFactory $http, array $queries, array $yandexRows = []): LiveFindCompetitors { return new LiveFindCompetitors( fakeAnalyzer($queries), new CategoryScraper($pages, new CategoryListingParser, maxPages: 2), + fakeYandex($yandexRows), new ChannelBSearch($researcher, new ResearcherParser), new ExaSiteFinder($http), noAiAssembler(), @@ -118,6 +133,34 @@ it('сквозной: канал А (2ГИС-список: имя+карточк ->and($cm['site_url'])->toBe('carmoney.ru'); }); +it('слияние 2ГИС+Яндекс: один конкурент в обоих справочниках → одна карточка с двумя ссылками', function () { + Config::set('services.exa.key', ''); + $http = app(HttpFactory::class); + + $pages = stubPages([ + '2gis.ru/krasnoyarsk/search/' => listingHtml([[985690700540003, 'КрасЛомбард', 'kraslombard24.ru']]), + ]); + // Яндекс дал того же КрасЛомбарда (без сайта) + новую фирму, которой нет в 2ГИС + $yandex = [ + ['name' => 'КрасЛомбард', 'card_url' => 'https://yandex.ru/maps/org/kraslombard/175852236692'], + ['name' => 'ЯрКомиссионка', 'card_url' => 'https://yandex.ru/maps/org/yarkomissionka/226908207223'], + ]; + + $res = leanEngine($pages, fakeResearcher(['[]']), $http, ['ломбард'], $yandex) + ->find(new FindCompetitorsRequest(29, [], ['ломбард', 'lkomega.ru'], false, 20)); + + $names = array_column($res->competitors, 'name'); + // КрасЛомбард — один раз (2ГИС+Яндекс слиты), плюс новая из Яндекса + expect(array_count_values($names)['КрасЛомбард'])->toBe(1) + ->and($names)->toContain('ЯрКомиссионка'); + + // у слитого КрасЛомбарда — обе ссылки-справочники и сайт из 2ГИС + $kl = collect($res->competitors)->firstWhere('name', 'КрасЛомбард'); + expect($kl['site_url'])->toBe('kraslombard24.ru') + ->and(collect($kl['directory_urls'])->contains(fn ($u) => str_contains($u, '2gis.ru')))->toBeTrue() + ->and(collect($kl['directory_urls'])->contains(fn ($u) => str_contains($u, 'yandex.ru')))->toBeTrue(); +}); + it('канал В: имя без сайта в EXA выкидывается (нет якоря)', function () { Config::set('services.exa.key', 'k'); $http = app(HttpFactory::class); From fb3027848a30c3d6df3eca2b8477f16b3d592b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 1 Jul 2026 04:55:51 +0300 Subject: [PATCH 101/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=84=D0=B0=D0=B9=D0=BB=20?= =?UTF-8?q?=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F=20=D0=B4?= =?UTF-8?q?=D0=B2=D0=B8=D0=B6=D0=BA=D0=B0=20+=20=D0=BF=D1=80=D0=BE=D0=BC?= =?UTF-8?q?=D1=82=20=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=D0=B0=20(01.07)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Единая точка: что сделано (движок шага 1 пересобран — АНАЛИЗ→канал А 2ГИС(xfetch)+Яндекс (Playwright)→слияние→канал В федералы(EXA)→сборка; 136 unit+74 feature зелёные, HEAD c3f0f272), что осталось (полный живой прогон+сверка на 72, калибровка, параллельность), ключи/окружение, карта кода, промт для восстановления после компакта. Co-Authored-By: Claude Opus 4.8 --- ...026-07-01-DVIZHOK-STATUS-i-prodolzhenie.md | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-01-DVIZHOK-STATUS-i-prodolzhenie.md diff --git a/docs/superpowers/plans/2026-07-01-DVIZHOK-STATUS-i-prodolzhenie.md b/docs/superpowers/plans/2026-07-01-DVIZHOK-STATUS-i-prodolzhenie.md new file mode 100644 index 00000000..5a93d995 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-DVIZHOK-STATUS-i-prodolzhenie.md @@ -0,0 +1,131 @@ +# 🔧 ДВИЖОК «Автоподбор» — СОСТОЯНИЕ + ПРОМТ ПРОДОЛЖЕНИЯ (01.07.2026) + +> Единый файл-состояние: что сделано, что осталось, что прочитать для восстановления контекста. +> Воркстри `worktree-avtopodbor`. **НЕ прод. Коммит — спросив «эскейп». Пуш — gitea. Простым языком.** + +--- + +## 0. ОДНОЙ СТРОКОЙ +Движок шага 1 (поиск конкурентов) **пересобран под финал v4 целиком** и офлайн-зелёный (136 unit + +74 feature). Осталось — **полный живой прогон 2ГИС+Яндекс+В со сверкой на эталон 72** и калибровка. + +--- + +## 1. ✅ ЧТО СДЕЛАНО (всё на gitea, ветка `worktree-avtopodbor`, HEAD **dee2ebbc**) + +Финальный поток шага 1 (ZAFIKSIROVANO §0-БИС/§0-ТРИ): **ТОЛЬКО имя+сайт+карточки** (телефоны/описание = шаг 2). + +- **Шаг АНАЛИЗ** — `ChannelA\AitunnelQueryAnalyzer`: описание клиента → запросы-рубрики (мелкая модель). +- **Канал А, справочник 1 — 2ГИС (xfetch):** `ChannelA\CategoryScraper` + `CategoryListingParser` — + скрейп категории с пагинацией, из СПИСКА берём имя+карточку+**сайт** (сайт в редиректе «Перейти на + сайт», firmId в пути; мусор/агрегаторы отсеяны). БЕЗ захода в карточку. + Живой замер: **48 фирм, 17 с сайтом, ~275с** (xfetch render-bound). +- **Канал А, справочник 2 — Яндекс (локальный Playwright):** `ChannelA\YandexDirectory` (граница) + + `PlaywrightYandexDirectory` + `scripts/render-yandex-list.cjs` (скроллит ленту). Имя+карточка, БЕЗ + сайта (в списке Яндекса сайта нет — только на карточке, не открываем). Firecrawl Яндекс НЕ берёт (§12.2). + Живой замер: **113 организаций за ~18с** (быстро). +- **Слияние 2ГИС+Яндекс:** `AutopodborDedup::mergeCompetitors` (union-find по имени/домену) — один + конкурент из обоих справочников → одна карточка с ДВУМЯ `directory_urls` (2ГИС+Яндекс), сайт из 2ГИС. +- **Канал В (федералы) — одна модель × 2 прохода:** `ChannelB\{ChannelBSearch, AitunnelResearcher, + ResearcherParser}` — sonar-reasoning-pro даёт ТОЛЬКО ИМЕНА (промт §11.3), стоп-лист = имена из А + + примеры. Сайт федерала — `ChannelB\ExaSiteFinder` (EXA по имени; у федерала нет карточки на регион). + Нет сайта = нет якоря → выкидываем. +- **Сборка:** `FindCompetitorsAssembler` — отсев агрегаторов (LLM) → дедуп+вычет клиента → федерал-фильтр + → похожесть-эмбеддинги (математически, text-embedding-3-small) → DTO §7.2. +- **Оркестратор** `LiveFindCompetitors` собирает всё; **провайдер** `AutopodborServiceProvider` + подключает живьём за флагом `autopodbor.real_find`. На проде НЕ флипнут. + +**Коммиты сессии:** `5a651651` (каналы А/В §12) → `5d40de66` (лёгкий канал А из списка) → +`dee2ebbc` (Яндекс-источник + слияние). Доки: `d4684717` (методика+финал), контекст-файлы. + +--- + +## 2. 🔴 ЧТО ОСТАЛОСЬ + +1. **ПОЛНЫЙ ЖИВОЙ ПРОГОН** движка на вводе Омеги (2ГИС+Яндекс+В вместе) → сверка с эталоном + `AUTOPODBOR-client-report-top.png` (72 конкурента). Ждём после слияния/дедупа ~60-75 уникальных. + Скрипт-образец: `scratchpad/live_engine_run.php` (обновить под новый конструктор — +Yandex; или + гонять через `app(CompetitorAgent::class)->findCompetitors(...)` с `config real_find=true`). +2. **Калибровка** по фидбэку владельца (он знает рынок): проценты, лишний шум, полнота. +3. **Скорость/параллельность** (владелец: 3-4 клиента разом, шаг 1 и шаг 2): движок stateless per-job; + узкое место — xfetch-2ГИС (~275с) + локальный Playwright на параллельные Яндекс-рендеры. Держать + потолки (`maxPages`, `channelBCap`, `maxQueries`); поднять N воркеров очереди. Возможно распараллелить + каналы А (2ГИС) и Яндекс внутри прогона. +4. **(Отложено, шаг 2)** сайт Яндекса из карточки организации («другой сайт» — владелец согласен отложить). +5. **Провайдер-флип на прод** — отдельное «да» владельца (сейчас за флагом, прод не тронут). + +--- + +## 3. КЛЮЧИ / ОКРУЖЕНИЕ (секреты только в `app/.env`, gitignored — в гит НИКОГДА) +- `XFETCH_API_KEY` — **обновлён 01.07** (старый кончился) — 2ГИС-скрейп, `POST https://xf4.ru/fetch`. +- `AITUNNEL_API_KEY` — sonar-reasoning-pro (канал В), эмбеддинги (похожесть), gpt-4o-mini (АНАЛИЗ, отсев). +- `EXA_API_KEY` — сайт федерала по имени (`POST https://api.exa.ai/search`). +- `FIRECRAWL_API_KEY` — есть, но Яндекс им НЕ берётся (не рендерит); резерв. +- `AUTOPODBOR_REAL_FIND=1` — включает живой движок. +- Playwright: `node` v24 + `node_modules/playwright` **в корне репо** (`Документация/node_modules`), + chromium установлен; `require('playwright')` резолвится из-под `app/scripts/`. +- Тесты: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor tests/Feature/Autopodbor`; PHP `c:/tools/php83/php`. + В тестах real_find принудительно ВЫКЛ (phpunit.xml). lefthook не в PATH (коммиты проходят). + +--- + +## 4. ИСТОЧНИКИ ИСТИНЫ (durable — читать, не по памяти) +- **Методика движка воедино + все промпты:** `docs/superpowers/specs/2026-06-30-DVIZHOK-voedino-metodika-i-prompty.md` +- **Что есть / что переделываем + ФИНАЛ (1 модель×2 прохода, EXA):** `docs/superpowers/specs/2026-06-30-ZAFIKSIROVANO-chto-est-chto-peredelyvaem.md` +- **§12 «ФИНАЛЬНЫЙ ДВИЖОК v4» + §8–§11 (промпты/проходы):** `docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-brainstorm-notes.md` **(ОСНОВНОЙ репо!)** +- **Эталон результата:** `AUTOPODBOR-client-report-top.png` (корень репо, 72 конкурента). + +--- + +## 5. КАРТА КОДА ДВИЖКА (`app/app/Services/Autopodbor/Agent/`) +``` +ChannelA/ QueryAnalyzer(iface)+AitunnelQueryAnalyzer — шаг АНАЛИЗ (рубрики) + CategoryListingParser — 2ГИС список → имя+карточка+сайт + CategoryScraper — 2ГИС пагинация + YandexDirectory(iface)+PlaywrightYandexDirectory — Яндекс (Playwright) +ChannelB/ ResearcherClient(iface)+AitunnelResearcher — sonar-reasoning-pro (имена) + ResearcherParser, ChannelBSearch — 2 прохода, стоп-лист + ExaSiteFinder — сайт федерала (EXA) +Resolve/ CompetitorResolver, TwoGis/YandexResolver — (для resolveByName; в лёгком find НЕ зовётся) +Similarity/ Embedder(iface)+AitunnelEmbedder+EmbeddingRelevance — похожесть % +Aggregator/ AggregatorClassifier(iface)+Aitunnel...+AggregatorFilter — отсев площадок +FindCompetitorsAssembler — сборка ядра → DTO +LiveFindCompetitors — ОРКЕСТРАТОР (АНАЛИЗ→А(2ГИС+Яндекс)→В→сборка) +``` +Провайдер: `app/app/Providers/AutopodborServiceProvider.php` (`buildLiveFind`). Render: `app/scripts/render-yandex-list.cjs`. + +--- + +## 6. ПРОМТ ДЛЯ ВОССТАНОВЛЕНИЯ КОНТЕКСТА (скопировать в новый чат после компакта) + +> Продолжаем движок **«Автоподбор конкурентов»** Лидерры, ШАГ 1. Воркстри `worktree-avtopodbor` (код +> под `app/`). **НЕ прод. Коммит — спросив «эскейп». Пуш — только gitea. Простым языком — владелец не +> программист. Руками 2ГИС/Яндекс не парсить — только сервисы.** +> +> 🔴 **ПРОЧИТАЙ ПЕРВЫМ ДЕЛОМ (durable, не по памяти):** +> `docs/superpowers/plans/2026-07-01-DVIZHOK-STATUS-i-prodolzhenie.md` — состояние, что сделано/осталось, +> ключи, карта кода. Он ссылается на методику (`…-DVIZHOK-voedino…`) и финал (`…-ZAFIKSIROVANO…`). +> +> **ГДЕ МЫ:** движок шага 1 пересобран под финал v4 — шаг АНАЛИЗ → канал А из ДВУХ справочников +> (2ГИС через xfetch: имя+карточка+**сайт** из списка, без захода в карточку; Яндекс через локальный +> Playwright: имя+карточка, 113 орг/18с) → слияние 2ГИС+Яндекс (одна карточка, два справочника) → +> канал В (одна модель sonar-reasoning-pro × 2 прохода → имена федералов → сайт через EXA) → сборка +> (отсев агрегаторов + дедуп + похожесть-эмбеддинги + DTO). Всё за флагом `autopodbor.real_find`, на +> проде не тронуто. Офлайн-тесты: **136 unit + 74 feature зелёные**, HEAD gitea `dee2ebbc`. +> +> **СЛЕДУЮЩЕЕ:** полный ЖИВОЙ прогон на вводе Омеги (регион 29 Красноярский край, профиль «займы под +> залог авто/спецтехники», примеры vashinvestor/caranga, федералы вкл) → сверка с эталоном +> `AUTOPODBOR-client-report-top.png` (72 конкурента). Тратит кредиты (~30₽, xfetch медленный ~5 мин). +> Спросить владельца перед запуском. Ключи в `app/.env` (xfetch ОБНОВЛЁН 01.07). Playwright — в корне +> репо (`Документация/node_modules`). Дальше — калибровка по фидбэку владельца (он знает рынок). +> +> **НЕ воскрешать старое:** ИНН выкинут; похожесть = эмбеддинги (не модель); канал В = ТОЛЬКО имена +> (не якоря); федерал-сайт через EXA; Яндекс — локальный Playwright (не Firecrawl). Перед коммитом — «эскейп». + +--- + +## 7. ЖЁСТКИЕ ПРАВИЛА (не пересматривать без «меняем») +ИНН выкинут · система универсальна (клиентское из конфига) · похожесть = эмбеддинги · агрегаторы = +LLM-классификатор · is_federal по факту · телефоны не синтезируем · якорю модели не верить · шаг 1 = +имя+сайт+карточки (телефоны/описание = шаг 2) · Яндекс = Playwright, не Firecrawl · секреты только в +`.env` · НЕ прод · коммит — «эскейп» · пуш — gitea. From 48acb85df7e7022c27ae64a22f3e2ef47a475ea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 1 Jul 2026 05:27:45 +0300 Subject: [PATCH 102/417] =?UTF-8?q?fix(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=BA=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=92=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D1=87=D0=B8=D1=81=D1=82=D1=8B=D0=B9=20=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=BF-=D0=BB=D0=B8=D1=81=D1=82=20=E2=80=94=20=D0=B4?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D0=B8-=D1=84=D0=B8=D0=BB=D0=B8=D0=B0=D0=BB?= =?UTF-8?q?=D1=8B=20=D1=81=D1=85=D0=BB=D0=BE=D0=BF=D0=BD=D1=83=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Стоп-лист известных для ИИ канала В дедуплицировался только в проверке видели/не видели, а в промпт уходил с повторами (Корунд ×27, КрасЛомбард ×19). Теперь чистим и сам список по ключу имени — ИИ видит каждое имя один раз: короче, дешевле по токенам, точнее. TDD, 137/137 unit зелёные. Co-Authored-By: Claude Opus 4.8 --- .../Agent/ChannelB/ChannelBSearch.php | 6 +++++- .../ChannelB/ChannelBSearchTest.php | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/app/Services/Autopodbor/Agent/ChannelB/ChannelBSearch.php b/app/app/Services/Autopodbor/Agent/ChannelB/ChannelBSearch.php index 3014c863..32c5ce8d 100644 --- a/app/app/Services/Autopodbor/Agent/ChannelB/ChannelBSearch.php +++ b/app/app/Services/Autopodbor/Agent/ChannelB/ChannelBSearch.php @@ -44,7 +44,11 @@ TXT; if ($name === '') { continue; } - $seen[$this->key($name)] = true; + $k = $this->key($name); + if (isset($seen[$k])) { + continue; // чистим стоп-лист от дублей-филиалов: «Корунд» ×27 → один + } + $seen[$k] = true; $stop[] = $name; } diff --git a/app/tests/Unit/Autopodbor/ChannelB/ChannelBSearchTest.php b/app/tests/Unit/Autopodbor/ChannelB/ChannelBSearchTest.php index 676e1647..f52e5929 100644 --- a/app/tests/Unit/Autopodbor/ChannelB/ChannelBSearchTest.php +++ b/app/tests/Unit/Autopodbor/ChannelB/ChannelBSearchTest.php @@ -78,6 +78,25 @@ it('пустой ответ прохода не падает и не добав expect($out)->toHaveCount(1)->and($out[0]['name'])->toBe('Cashmotor'); }); +it('чистый стоп-лист для ИИ: дубли-филиалы схлопнуты — каждое имя один раз', function () { + // Канал А отдал филиалы: «Корунд» ×3 (в т.ч. другой регистр), «КрасЛомбард» ×2. + // В промпт ИИ должно уйти по одному — иначе «- Корунд» повторится 27 раз (реальный случай). + $fake = new FakeResearcher(['[]']); + $search = new ChannelBSearch($fake, new ResearcherParser); + + $search->harvest( + profile: 'займы под залог авто', + region: 'Красноярский край', + clientSite: 'lkomega.ru', + known: ['Корунд', 'Корунд', 'корунд', 'КрасЛомбард', 'КрасЛомбард'], + passes: 1, + ); + + $prompt = $fake->userPrompts[0]; + expect(substr_count($prompt, '- Корунд'))->toBe(1) + ->and(substr_count($prompt, '- КрасЛомбард'))->toBe(1); +}); + it('системный промт просит ТОЛЬКО названия (§11.3)', function () { expect(ChannelBSearch::SYSTEM_PROMPT)->toContain('только НАЗВАНИЯ') ->and(ChannelBSearch::SYSTEM_PROMPT)->toContain('строго JSON'); From dbc71e898bdb1443f411fbac92f56b6dfb53fbda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 1 Jul 2026 06:24:00 +0300 Subject: [PATCH 103/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=AF=D0=BD=D0=B4=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF=D0=B8=D1=82=20?= =?UTF-8?q?=D0=BF=D0=BE=20slug-=D0=BA=D0=BE=D0=B4=D1=83=20=D1=84=D0=B8?= =?UTF-8?q?=D1=80=D0=BC=D1=8B=20+=20=D0=BA=D0=BE=D0=B4=20=D0=B2=20=D1=81?= =?UTF-8?q?=D0=BA=D0=BB=D0=B5=D0=B9=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Яндекс отдавал по строке на КАЖДЫЙ филиал (дедуп по id точки) — 214 строк, из них хлам-повторы одной сети (Корунд ×15). Новый YandexListParser дедупит по SLUG (код фирмы от источника /maps/org//id): одна фирма = одна строка, разные написания одной фирмы склеены, разные фирмы с разным кодом раздельны. Код справочника (ya:slug) уходит в union-find склейку как опознавательный признак. Живьём 214 -> 94. TDD, 215/215 (unit+feature) зелёные. НЕ прод. Co-Authored-By: Claude Opus 4.8 --- .../ChannelA/PlaywrightYandexDirectory.php | 21 +++----- .../Agent/ChannelA/YandexListParser.php | 48 +++++++++++++++++++ .../Autopodbor/Agent/LiveFindCompetitors.php | 15 ++++-- .../Services/Autopodbor/AutopodborDedup.php | 8 ++++ .../Unit/Autopodbor/AutopodborMergeTest.php | 12 +++++ .../ChannelA/YandexListParserTest.php | 48 +++++++++++++++++++ 6 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 app/app/Services/Autopodbor/Agent/ChannelA/YandexListParser.php create mode 100644 app/tests/Unit/Autopodbor/ChannelA/YandexListParserTest.php diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php b/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php index 613bc67c..75e9d78b 100644 --- a/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php +++ b/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php @@ -24,15 +24,16 @@ final class PlaywrightYandexDirectory implements YandexDirectory private readonly int $timeout = 120, // Не рендерим больше N запросов за подбор (локальный браузер тяжёл при параллельных клиентах). private readonly int $maxQueries = 4, + private readonly YandexListParser $parser = new YandexListParser, ) { $this->script = $script !== '' ? $script : base_path('scripts/render-yandex-list.cjs'); } public function collect(string $city, array $queries): array { - $seen = []; - $out = []; - + // Собираем сырьё по всем рубрикам, дедуп/опознание фирмы — в парсере ПО SLUG (код фирмы от + // Яндекса), а не по id филиала: «Корунд ×15 → один», разные написания одной фирмы склеены. + $orgs = []; foreach (array_slice($queries, 0, max(0, $this->maxQueries)) as $query) { $query = trim((string) $query); if ($query === '') { @@ -41,21 +42,11 @@ final class PlaywrightYandexDirectory implements YandexDirectory $url = 'https://yandex.ru/maps/?text='.rawurlencode(trim($query.' '.$city)); foreach ($this->render($url) as $org) { - $href = (string) ($org['href'] ?? ''); - $name = trim((string) ($org['name'] ?? '')); - if ($name === '' || ! preg_match('#^/maps/org/[a-z0-9_-]+/(\d+)#i', $href, $m)) { - continue; - } - $id = $m[1]; - if (isset($seen[$id])) { - continue; - } - $seen[$id] = true; - $out[] = ['name' => $name, 'card_url' => 'https://yandex.ru'.$href]; + $orgs[] = $org; } } - return $out; + return $this->parser->parse($orgs); } /** @return list> orgs из render-скрипта; ошибка → []. */ diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/YandexListParser.php b/app/app/Services/Autopodbor/Agent/ChannelA/YandexListParser.php new file mode 100644 index 00000000..f755c281 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/YandexListParser.php @@ -0,0 +1,48 @@ +/`, а НЕ по id филиала и НЕ по тексту имени. + * + * Почему slug: у всех точек одной сети slug общий (напр. `korund`), а id у каждой точки свой — + * поэтому дедуп по slug честно схлопывает филиалы «Корунд ×15 → один» и склеивает разные написания + * одной фирмы («КрасЛомбард»/«Красломбард» → slug `kraslombard`). При этом две РАЗНЫЕ фирмы с похожим + * именем, которым Яндекс дал разные slug, остаются раздельными. Это опознавательный признак источника, + * а не догадка по строке. Чистый разбор: на вход сырой массив организаций из render, наружу не ходит. + */ +final class YandexListParser +{ + /** + * @param list> $orgs сырые организации из render-скрипта (name, href, ...) + * @return list + */ + public function parse(array $orgs): array + { + $seen = []; + $out = []; + + foreach ($orgs as $org) { + $href = trim((string) ($org['href'] ?? '')); + $name = trim((string) ($org['name'] ?? '')); + if ($name === '' || ! preg_match('#^/maps/org/([a-z0-9_-]+)/(\d+)#i', $href, $m)) { + continue; + } + $slug = mb_strtolower($m[1], 'UTF-8'); + if (isset($seen[$slug])) { + continue; // одна фирма (slug) = одна строка; филиалы схлопнуты + } + $seen[$slug] = true; + $out[] = [ + 'name' => $name, + 'card_url' => 'https://yandex.ru'.$href, + 'slug' => $slug, + ]; + } + + return $out; + } +} diff --git a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php index ef7344c1..037388b3 100644 --- a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php +++ b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php @@ -56,7 +56,12 @@ final class LiveFindCompetitors : []; $aCards = array_merge( array_map(fn (array $row): array => $this->localCard($row['name'], $row['card_url'], $row['site'] ?? null), $twoGisRows), - array_map(fn (array $row): array => $this->localCard($row['name'], $row['card_url'], null), $yandexRows), + array_map(fn (array $row): array => $this->localCard( + $row['name'], + $row['card_url'], + null, + isset($row['slug']) && $row['slug'] !== '' ? 'ya:'.$row['slug'] : null, + ), $yandexRows), ); // 3. Канал В: имена федералов → САЙТ через EXA. Нет сайта = нет якоря → выкидываем. @@ -82,8 +87,11 @@ final class LiveFindCompetitors return $this->assembler->assemble($candidates, $examples, $clientKeys, $r->includeFederal, $r->maxCompetitors); } - /** Местная карточка из списка справочника (канал А): 2ГИС (с сайтом) или Яндекс (site=null). */ - private function localCard(string $name, string $cardUrl, ?string $site): array + /** + * Местная карточка из списка справочника (канал А): 2ГИС (с сайтом) или Яндекс (site=null). + * $dirKey — опознавательный код фирмы от справочника (напр. «ya:korund») для склейки по источнику. + */ + private function localCard(string $name, string $cardUrl, ?string $site, ?string $dirKey = null): array { $source = str_contains($cardUrl, 'yandex.ru') ? 'yandex-list' : '2gis-list'; @@ -93,6 +101,7 @@ final class LiveFindCompetitors 'description' => null, 'is_federal' => false, 'directory_urls' => [$cardUrl], + 'directory_keys' => $dirKey !== null ? [$dirKey] : [], 'phones' => [], 'provenance' => ['via' => 'engine', 'source' => $source], ]; diff --git a/app/app/Services/Autopodbor/AutopodborDedup.php b/app/app/Services/Autopodbor/AutopodborDedup.php index 0a71518a..e95c079f 100644 --- a/app/app/Services/Autopodbor/AutopodborDedup.php +++ b/app/app/Services/Autopodbor/AutopodborDedup.php @@ -191,6 +191,14 @@ final class AutopodborDedup $keys[] = 'p:'.$pp; } } + // Код фирмы от справочника (напр. slug Яндекса «ya:korund») — опознавательный признак + // источника: склеивает разные написания одной фирмы, которые ключ имени не поймал. + foreach ($c['directory_keys'] ?? [] as $dk) { + $dk = trim((string) $dk); + if ($dk !== '') { + $keys[] = 'd:'.$dk; + } + } return array_values(array_unique($keys)); } diff --git a/app/tests/Unit/Autopodbor/AutopodborMergeTest.php b/app/tests/Unit/Autopodbor/AutopodborMergeTest.php index d821d85d..5a4b16a5 100644 --- a/app/tests/Unit/Autopodbor/AutopodborMergeTest.php +++ b/app/tests/Unit/Autopodbor/AutopodborMergeTest.php @@ -51,6 +51,18 @@ it('объединяет ссылки справочников и телефон expect($merged[0]['phones'])->toContain('71112223344')->toContain('75556667788'); }); +it('склеивает по общему коду справочника (slug Яндекса), даже при разном написании имени', function () { + // Один и тот же «КрасЛомбард», но имя записано по-разному → по имени НЕ сойдётся. + // Спасает опознавательный код фирмы от Яндекса (slug) — это признак источника, не догадка. + $merged = mergeService()->mergeCompetitors([ + ['name' => 'КрасЛомбард', 'directory_keys' => ['ya:kraslombard'], 'directory_urls' => ['https://yandex.ru/maps/org/kraslombard/333']], + ['name' => 'Красломбард 24 Взлётка', 'directory_keys' => ['ya:kraslombard'], 'directory_urls' => ['https://yandex.ru/maps/org/kraslombard/444']], + ]); + + expect($merged)->toHaveCount(1) + ->and($merged[0]['directory_urls'])->toHaveCount(2); +}); + it('не склеивает разных конкурентов', function () { $merged = mergeService()->mergeCompetitors([ ['name' => 'Окна Комфорт', 'site_url' => 'okna-komfort.ru'], diff --git a/app/tests/Unit/Autopodbor/ChannelA/YandexListParserTest.php b/app/tests/Unit/Autopodbor/ChannelA/YandexListParserTest.php new file mode 100644 index 00000000..2c3703f4 --- /dev/null +++ b/app/tests/Unit/Autopodbor/ChannelA/YandexListParserTest.php @@ -0,0 +1,48 @@ +/), +// а НЕ по id филиала и НЕ по тексту имени. Одна фирма (slug) = одна строка. Чистая логика над сырым +// массивом организаций из render-скрипта — без сети/браузера. + +it('дедуп по slug: филиалы одной фирмы → одна строка (даже при разном написании имени)', function () { + $orgs = [ + ['name' => 'Корунд', 'href' => '/maps/org/korund/111'], + ['name' => 'Корунд', 'href' => '/maps/org/korund/222'], + ['name' => 'Красломбард', 'href' => '/maps/org/kraslombard/333'], + ['name' => 'КрасЛомбард', 'href' => '/maps/org/kraslombard/444'], // другое написание — тот же slug + ]; + + $rows = (new YandexListParser)->parse($orgs); + + expect($rows)->toHaveCount(2) + ->and(array_column($rows, 'slug'))->toContain('korund', 'kraslombard'); + + $kl = collect($rows)->firstWhere('slug', 'kraslombard'); + expect($kl['name'])->toBe('Красломбард') // первое написание сохраняется + ->and($kl['card_url'])->toBe('https://yandex.ru/maps/org/kraslombard/333'); +}); + +it('разные фирмы с похожим именем, но разным slug — остаются раздельными', function () { + // Яндекс дал им РАЗНЫЕ коды → это две разные фирмы, склеивать нельзя. + $orgs = [ + ['name' => 'Автозайм 124', 'href' => '/maps/org/avtozaym_124/1'], + ['name' => 'Автозайм124', 'href' => '/maps/org/avtozaym124/2'], + ]; + + expect((new YandexListParser)->parse($orgs))->toHaveCount(2); +}); + +it('без имени или без валидного href организации — пропускаем', function () { + $orgs = [ + ['name' => '', 'href' => '/maps/org/x/1'], + ['name' => 'Нет ссылки', 'href' => '/foo/bar'], + ['name' => 'ОК', 'href' => '/maps/org/ok/9'], + ]; + + $rows = (new YandexListParser)->parse($orgs); + expect($rows)->toHaveCount(1)->and($rows[0]['name'])->toBe('ОК'); +}); From f420924257369a8d37dd4b3be65560d126e3d3c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 1 Jul 2026 07:00:41 +0300 Subject: [PATCH 104/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=80=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20=D0=B8=D0=B7=20=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=20+?= =?UTF-8?q?=20=D0=BC=D0=B5=D0=B6=D0=BE=D1=82=D1=80=D0=B0=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BF=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playwright тянет рубрику фирмы прямо из СПИСКА Яндекса («Ломбард, автоломбард», «Микрофинансовая организация», «Банк») без захода в карточку — быстро и бесплатно. Рубрика идёт в описание конкурента → в похожесть-эмбеддинги (раньше меряли по голому имени, хлам и целевое сбивались). Промпт анализатора обезличен: примеры из РАЗНЫХ отраслей (стоматология/автосервис/доставка) + «подбери под описание клиента» — движок универсален, не подточен под нишу займов Омеги. TDD 216/216, НЕ прод. Co-Authored-By: Claude Opus 4.8 --- .../Agent/ChannelA/AitunnelQueryAnalyzer.php | 10 +++++++--- .../Agent/ChannelA/YandexListParser.php | 7 +++++-- .../Autopodbor/Agent/LiveFindCompetitors.php | 6 ++++-- app/scripts/render-yandex-list.cjs | 11 ++++++++++- .../ChannelA/YandexListParserTest.php | 19 ++++++++++++++----- 5 files changed, 40 insertions(+), 13 deletions(-) diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/AitunnelQueryAnalyzer.php b/app/app/Services/Autopodbor/Agent/ChannelA/AitunnelQueryAnalyzer.php index c31874a4..4b9040e6 100644 --- a/app/app/Services/Autopodbor/Agent/ChannelA/AitunnelQueryAnalyzer.php +++ b/app/app/Services/Autopodbor/Agent/ChannelA/AitunnelQueryAnalyzer.php @@ -30,11 +30,15 @@ final class AitunnelQueryAnalyzer implements QueryAnalyzer return $fallback; } + // Примеры НАМЕРЕННО из РАЗНЫХ отраслей — движок универсален (клиент Лидерры может быть любой + // сферы и региона). Рубрики ИИ подбирает ПОД ОПИСАНИЕ клиента, а не под какую-то одну нишу. $prompt = "Описание деятельности клиента: «{$description}». Регион: {$region}.\n". 'Дай от 3 до 6 КОРОТКИХ поисковых запросов (рубрик), по которым в справочниках 2ГИС и '. - 'Яндекс.Карты ищут его КОНКУРЕНТОВ — так, как ищут категорию (напр. «автоломбард», '. - '«займ под залог авто», «МФО», «ломбард»). Только сами запросы, без пояснений, '. - 'без названий конкретных фирм и без города. Ответ — строго JSON-массив строк: ["...","..."]'; + 'Яндекс.Карты ищут его КОНКУРЕНТОВ — так, как ищут категорию. Примеры для разных сфер: '. + 'стоматология → «стоматология», «имплантация зубов»; автосервис → «автосервис», «шиномонтаж»; '. + 'доставка еды → «доставка суши», «пиццерия». Подбери рубрики строго ПОД ОПИСАНИЕ клиента выше. '. + 'Только сами запросы, без пояснений, без названий конкретных фирм и без города. '. + 'Ответ — строго JSON-массив строк: ["...","..."]'; try { $resp = $this->http diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/YandexListParser.php b/app/app/Services/Autopodbor/Agent/ChannelA/YandexListParser.php index f755c281..af3e80d4 100644 --- a/app/app/Services/Autopodbor/Agent/ChannelA/YandexListParser.php +++ b/app/app/Services/Autopodbor/Agent/ChannelA/YandexListParser.php @@ -17,8 +17,8 @@ namespace App\Services\Autopodbor\Agent\ChannelA; final class YandexListParser { /** - * @param list> $orgs сырые организации из render-скрипта (name, href, ...) - * @return list + * @param list> $orgs сырые организации из render-скрипта (name, href, category, ...) + * @return list */ public function parse(array $orgs): array { @@ -36,10 +36,13 @@ final class YandexListParser continue; // одна фирма (slug) = одна строка; филиалы схлопнуты } $seen[$slug] = true; + // Рубрика из списка Яндекса («Ломбард, автоломбард», «Микрофинансовая организация») = описание. + $category = trim((string) ($org['category'] ?? '')); $out[] = [ 'name' => $name, 'card_url' => 'https://yandex.ru'.$href, 'slug' => $slug, + 'description' => $category !== '' ? $category : null, ]; } diff --git a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php index 037388b3..e2b2362e 100644 --- a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php +++ b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php @@ -61,6 +61,7 @@ final class LiveFindCompetitors $row['card_url'], null, isset($row['slug']) && $row['slug'] !== '' ? 'ya:'.$row['slug'] : null, + $row['description'] ?? null, ), $yandexRows), ); @@ -90,15 +91,16 @@ final class LiveFindCompetitors /** * Местная карточка из списка справочника (канал А): 2ГИС (с сайтом) или Яндекс (site=null). * $dirKey — опознавательный код фирмы от справочника (напр. «ya:korund») для склейки по источнику. + * $description — рубрика из списка («Ломбард, автоломбард») для похожести-эмбеддингов. */ - private function localCard(string $name, string $cardUrl, ?string $site, ?string $dirKey = null): array + private function localCard(string $name, string $cardUrl, ?string $site, ?string $dirKey = null, ?string $description = null): array { $source = str_contains($cardUrl, 'yandex.ru') ? 'yandex-list' : '2gis-list'; return [ 'name' => $name, 'site_url' => $site, - 'description' => null, + 'description' => $description, 'is_federal' => false, 'directory_urls' => [$cardUrl], 'directory_keys' => $dirKey !== null ? [$dirKey] : [], diff --git a/app/scripts/render-yandex-list.cjs b/app/scripts/render-yandex-list.cjs index 1ad64500..d281ffa4 100644 --- a/app/scripts/render-yandex-list.cjs +++ b/app/scripts/render-yandex-list.cjs @@ -44,7 +44,16 @@ const { chromium } = require('playwright'); if (!m || seen.has(m[1])) continue; seen.add(m[1]); const name = (e.getAttribute('aria-label') || e.textContent || '').trim(); - if (name) out.push({ name, id: m[1], href: href.split('?')[0] }); + if (!name) continue; + // Рубрика из списка (без захода в карточку): поднимаемся к контейнеру-сниппету, берём категорию. + let box = e; + for (let up = 0; up < 6; up++) { + if (box.parentElement) box = box.parentElement; + if (box.className && /snippet|search-business|card/i.test(box.className)) break; + } + const catEl = box.querySelector('[class*="category"], [class*="rubric"]'); + const category = catEl ? catEl.textContent.trim().slice(0, 80) : ''; + out.push({ name, id: m[1], href: href.split('?')[0], category }); } return out; }); diff --git a/app/tests/Unit/Autopodbor/ChannelA/YandexListParserTest.php b/app/tests/Unit/Autopodbor/ChannelA/YandexListParserTest.php index 2c3703f4..952596e8 100644 --- a/app/tests/Unit/Autopodbor/ChannelA/YandexListParserTest.php +++ b/app/tests/Unit/Autopodbor/ChannelA/YandexListParserTest.php @@ -8,11 +8,11 @@ use App\Services\Autopodbor\Agent\ChannelA\YandexListParser; // а НЕ по id филиала и НЕ по тексту имени. Одна фирма (slug) = одна строка. Чистая логика над сырым // массивом организаций из render-скрипта — без сети/браузера. -it('дедуп по slug: филиалы одной фирмы → одна строка (даже при разном написании имени)', function () { +it('дедуп по slug: филиалы одной фирмы → одна строка (даже при разном написании имени); рубрика → описание', function () { $orgs = [ - ['name' => 'Корунд', 'href' => '/maps/org/korund/111'], - ['name' => 'Корунд', 'href' => '/maps/org/korund/222'], - ['name' => 'Красломбард', 'href' => '/maps/org/kraslombard/333'], + ['name' => 'Корунд', 'href' => '/maps/org/korund/111', 'category' => 'Ломбард'], + ['name' => 'Корунд', 'href' => '/maps/org/korund/222', 'category' => 'Ломбард'], + ['name' => 'Красломбард', 'href' => '/maps/org/kraslombard/333', 'category' => 'Ломбард, автоломбард'], ['name' => 'КрасЛомбард', 'href' => '/maps/org/kraslombard/444'], // другое написание — тот же slug ]; @@ -23,7 +23,16 @@ it('дедуп по slug: филиалы одной фирмы → одна ст $kl = collect($rows)->firstWhere('slug', 'kraslombard'); expect($kl['name'])->toBe('Красломбард') // первое написание сохраняется - ->and($kl['card_url'])->toBe('https://yandex.ru/maps/org/kraslombard/333'); + ->and($kl['card_url'])->toBe('https://yandex.ru/maps/org/kraslombard/333') + ->and($kl['description'])->toBe('Ломбард, автоломбард'); // рубрика из списка = описание +}); + +it('нет рубрики у организации → описание null, не падает', function () { + $rows = (new YandexListParser)->parse([ + ['name' => 'Безрубрики', 'href' => '/maps/org/bez/1'], + ]); + + expect($rows)->toHaveCount(1)->and($rows[0]['description'])->toBeNull(); }); it('разные фирмы с похожим именем, но разным slug — остаются раздельными', function () { From 2975bf7967d1e5604f7631f3c750cf054940741a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 1 Jul 2026 09:18:09 +0300 Subject: [PATCH 105/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=BC=D0=B0=D1=81=D1=88?= =?UTF-8?q?=D1=82=D0=B0=D0=B1=20=D0=B8=20=D1=87=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=20=D1=88=D0=B0=D0=B3=D0=B0=201=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D1=83=D0=BB=20EXA,=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D1=8C-throttle,=20=D0=B1=D0=B0=D1=82=D1=87=20=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D0=B5=D0=B3=D0=B0=D1=82=D0=BE=D1=80=D0=B0,=20?= =?UTF-8?q?=D1=83=D0=B7=D0=BA=D0=B8=D0=B5=20=D1=80=D1=83=D0=B1=D1=80=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8,=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Масштаб (лимиты внешних сервисов — на КЛЮЧ, общий на всех клиентов): - EXA: параллельный пул findSites (concurrency=5) вместо 40 запросов по одному. - Агрегатор: батч вместо ~90 запросов по одному + чанкинг по 40 и длинный таймаут 90с (большой список gpt-4o-mini не успевал в 30с). - Очередь autopodbor + WithoutOverlapping на всех 3 джобах — глобальный потолок = число воркеров; assertNoInFlight держит один подбор на клиента. Чистота выдачи (универсально, без зашитых ниш): - Анализатор: узкие рубрики, запрет зонтичных слов («финансовые услуги» тащила юрфирмы/банки). - Дедуп: ключ имени минус слова из СЛОВАРЯ рубрик прогона («Яричъ Ломбард»→«Яричъ»); слово-категория из данных, не из списка. Гард: склейка только если остаётся ровно один слово-токен (генерики вроде «займы под залог» не трогаем). TDD, 233/233 (unit+feature) зелёные. НЕ прод. Co-Authored-By: Claude Opus 4.8 --- .../Autopodbor/RunAutopodborResolveJob.php | 42 +++++-- .../Autopodbor/RunAutopodborSearchJob.php | 19 ++- .../Jobs/Autopodbor/RunAutopodborStudyJob.php | 17 ++- .../Agent/Aggregator/AggregatorFilter.php | 25 +++- .../AitunnelAggregatorClassifier.php | 118 +++++++++++++++++- .../Aggregator/BatchAggregatorClassifier.php | 19 +++ .../Agent/ChannelA/AitunnelQueryAnalyzer.php | 17 ++- .../Agent/ChannelB/ExaSiteFinder.php | 99 ++++++++++++--- .../Autopodbor/Agent/LiveFindCompetitors.php | 7 +- .../Services/Autopodbor/AutopodborDedup.php | 42 ++++++- .../Autopodbor/AutopodborNormalizer.php | 30 +++++ app/config/services.php | 7 ++ .../AutopodborQueueThrottleTest.php | 40 ++++++ .../Aggregator/AggregatorFilterTest.php | 36 ++++++ .../AitunnelAggregatorClassifierTest.php | 50 ++++++++ .../Unit/Autopodbor/AutopodborMergeTest.php | 58 +++++++++ .../Autopodbor/ChannelB/ExaSiteFinderTest.php | 51 ++++++++ .../2026-07-01-autopodbor-scale-throttle.md | 50 ++++++++ 18 files changed, 673 insertions(+), 54 deletions(-) create mode 100644 app/app/Services/Autopodbor/Agent/Aggregator/BatchAggregatorClassifier.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborQueueThrottleTest.php create mode 100644 docs/superpowers/plans/2026-07-01-autopodbor-scale-throttle.md diff --git a/app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php b/app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php index 79606366..f8e36c9f 100644 --- a/app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php +++ b/app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Jobs\Autopodbor; -use App\Models\AutopodborRun; use App\Models\AutopodborCompetitor; +use App\Models\AutopodborRun; use App\Services\Autopodbor\Agent\CompetitorAgent; use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest; use App\Services\Autopodbor\AutopodborDedup; @@ -13,6 +13,7 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\DB; @@ -24,7 +25,21 @@ class RunAutopodborResolveJob implements ShouldQueue public array $backoff = [15, 60, 300]; - public function __construct(public int $runId) {} + public function __construct(public int $runId) + { + // Та же выделенная очередь, что и поиск — единый глобальный потолок внешних сервисов. + $this->onQueue('autopodbor'); + } + + /** + * Защита от нахлёста: один прогон резолва не обрабатывается двумя воркерами разом (ретрай). + * + * @return array + */ + public function middleware(): array + { + return [new WithoutOverlapping((string) $this->runId)]; + } public function handle(CompetitorAgent $agent, AutopodborDedup $dedup): void { @@ -52,25 +67,26 @@ class RunAutopodborResolveJob implements ShouldQueue if ($unique === []) { $run->update(['status' => 'empty', 'finished_at' => now()]); + return; } foreach ($unique as $c) { AutopodborCompetitor::updateOrCreate( [ - 'tenant_id' => $run->tenant_id, + 'tenant_id' => $run->tenant_id, 'search_run_id' => $run->id, - 'dedup_key' => $c['dedup_key'], + 'dedup_key' => $c['dedup_key'], ], [ - 'name' => $c['name'], - 'description' => $c['description'] ?? null, - 'is_federal' => (bool) ($c['is_federal'] ?? false), - 'relevance_pct' => null, - 'origin' => 'resolve', - 'site_url' => $c['site_url'] ?? null, + 'name' => $c['name'], + 'description' => $c['description'] ?? null, + 'is_federal' => (bool) ($c['is_federal'] ?? false), + 'relevance_pct' => null, + 'origin' => 'resolve', + 'site_url' => $c['site_url'] ?? null, 'directory_urls' => $c['directory_urls'] ?? [], - 'provenance' => $c['provenance'] ?? [], + 'provenance' => $c['provenance'] ?? [], ] ); } @@ -78,8 +94,8 @@ class RunAutopodborResolveJob implements ShouldQueue $run->update(['status' => 'done', 'finished_at' => now()]); } catch (\Throwable $e) { $run->update([ - 'status' => 'failed', - 'error_code' => substr($e->getMessage(), 0, 64), + 'status' => 'failed', + 'error_code' => substr($e->getMessage(), 0, 64), 'finished_at' => now(), ]); throw $e; diff --git a/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php index 23a571c2..72d13383 100644 --- a/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php +++ b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php @@ -17,6 +17,7 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; @@ -33,7 +34,23 @@ class RunAutopodborSearchJob implements ShouldQueue // иначе фоновое задание убьётся дефолтным 60-сек таймаутом на середине прогона. public int $timeout = 900; - public function __construct(public int $runId) {} + public function __construct(public int $runId) + { + // Выделенная очередь: число её воркеров = глобальный потолок одновременных подборов + // (лимиты EXA/xfetch/AITUNNEL — на КЛЮЧ, общий на всех клиентов). + $this->onQueue('autopodbor'); + } + + /** + * Защита от нахлёста: один и тот же прогон не обрабатывается двумя воркерами разом + * (ретрай/повторный dispatch). Ключ — runId. + * + * @return array + */ + public function middleware(): array + { + return [new WithoutOverlapping((string) $this->runId)]; + } public function handle(CompetitorAgent $agent, AutopodborDedup $dedup, AutopodborChargeService $charge): void { diff --git a/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php b/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php index f2862453..aa4d8a4b 100644 --- a/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php +++ b/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php @@ -17,6 +17,7 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\DB; @@ -28,7 +29,21 @@ class RunAutopodborStudyJob implements ShouldQueue public array $backoff = [15, 60, 300]; - public function __construct(public int $runId) {} + public function __construct(public int $runId) + { + // Та же выделенная очередь, что и поиск — единый глобальный потолок внешних сервисов. + $this->onQueue('autopodbor'); + } + + /** + * Защита от нахлёста: один прогон изучения не обрабатывается двумя воркерами разом (ретрай). + * + * @return array + */ + public function middleware(): array + { + return [new WithoutOverlapping((string) $this->runId)]; + } public function handle( CompetitorAgent $agent, diff --git a/app/app/Services/Autopodbor/Agent/Aggregator/AggregatorFilter.php b/app/app/Services/Autopodbor/Agent/Aggregator/AggregatorFilter.php index 831146d2..b5078277 100644 --- a/app/app/Services/Autopodbor/Agent/Aggregator/AggregatorFilter.php +++ b/app/app/Services/Autopodbor/Agent/Aggregator/AggregatorFilter.php @@ -19,13 +19,26 @@ final class AggregatorFilter */ public function filter(array $candidates): array { + $candidates = array_values($candidates); + + // Классификатор умеет батч → один запрос на весь список (иначе ~90 запросов по одному, «умирает»). + $verdicts = $this->classifier instanceof BatchAggregatorClassifier && $candidates !== [] + ? $this->classifier->classifyManyAggregators(array_map(fn (array $c): array => [ + 'name' => (string) ($c['name'] ?? ''), + 'site_url' => $c['site_url'] ?? null, + 'description' => $c['description'] ?? null, + ], $candidates)) + : null; + $out = []; - foreach ($candidates as $c) { - $verdict = $this->classifier->isAggregator( - (string) ($c['name'] ?? ''), - $c['site_url'] ?? null, - $c['description'] ?? null, - ); + foreach ($candidates as $i => $c) { + $verdict = $verdicts !== null + ? ($verdicts[$i] ?? null) + : $this->classifier->isAggregator( + (string) ($c['name'] ?? ''), + $c['site_url'] ?? null, + $c['description'] ?? null, + ); if ($verdict === true) { continue; // площадка — не конкурент } diff --git a/app/app/Services/Autopodbor/Agent/Aggregator/AitunnelAggregatorClassifier.php b/app/app/Services/Autopodbor/Agent/Aggregator/AitunnelAggregatorClassifier.php index 7ab645e6..95efb48b 100644 --- a/app/app/Services/Autopodbor/Agent/Aggregator/AitunnelAggregatorClassifier.php +++ b/app/app/Services/Autopodbor/Agent/Aggregator/AitunnelAggregatorClassifier.php @@ -12,10 +12,126 @@ use Illuminate\Http\Client\Factory as HttpFactory; * {choices:[{message:{content}}]}. Ответ AGGREGATOR → true, SUPPLIER → false, иначе/без ключа/ * при ошибке → null (тогда конкурента НЕ выкидываем — консервативно). */ -final class AitunnelAggregatorClassifier implements AggregatorClassifier +final class AitunnelAggregatorClassifier implements AggregatorClassifier, BatchAggregatorClassifier { public function __construct(private readonly HttpFactory $http) {} + /** + * Батч отсева агрегаторов: список режем на ПОРЦИИ по aggregator_batch_size (иначе один запрос + * на ~100 фирм долгий и рискует не успеть в таймаут). Каждая порция — один запрос с длинным + * таймаутом aggregator_timeout_sec. Пропущенные/непонятные/сбой порции — null (консервативно). + * + * @param list $companies + * @return list + */ + public function classifyManyAggregators(array $companies): array + { + $companies = array_values($companies); + $n = count($companies); + $none = array_fill(0, $n, null); + if ($n === 0) { + return []; + } + + $cfg = (array) config('services.aitunnel'); + $key = (string) ($cfg['key'] ?? ''); + if ($key === '') { + return $none; + } + + $batchSize = max(1, (int) ($cfg['aggregator_batch_size'] ?? 40)); + $out = $none; + $offset = 0; + foreach (array_chunk($companies, $batchSize) as $chunk) { + foreach ($this->classifyChunk($chunk, $key, $cfg) as $i => $verdict) { + $out[$offset + $i] = $verdict; + } + $offset += count($chunk); + } + + return $out; + } + + /** + * Одна порция: нумеруем 1..m, один запрос к модели с длинным таймаутом. Ответ → вердикты порции. + * + * @param list $chunk + * @return list + */ + private function classifyChunk(array $chunk, string $key, array $cfg): array + { + $none = array_fill(0, count($chunk), null); + + $lines = []; + foreach ($chunk as $i => $c) { + $lines[] = ($i + 1).'. «'.($c['name'] ?? '').'» сайт: '.($c['site_url'] ?: '—'). + ' описание: '.($c['description'] ?: '—'); + } + $prompt = "Список компаний:\n".implode("\n", $lines)."\n\n". + 'Для КАЖДОЙ определи: это сам поставщик услуги/товара (SUPPLIER) или площадка-агрегатор/' + .'каталог/маркетплейс/сравнение/справочник, который сводит клиентов с разными компаниями ' + .'(AGGREGATOR)? Ответ — СТРОГО JSON-массив по номерам, без текста вне него: ' + .'[{"n":1,"v":"SUPPLIER"},{"n":2,"v":"AGGREGATOR"}]'; + + try { + $resp = $this->http + ->withToken($key) + ->timeout((int) ($cfg['aggregator_timeout_sec'] ?? 90)) + ->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [ + 'model' => $cfg['chat_model'] ?? 'gpt-4o-mini', + 'messages' => [['role' => 'user', 'content' => $prompt]], + 'temperature' => 0, + ]); + + if (! $resp->successful()) { + return $none; + } + + return $this->parseVerdicts((string) $resp->json('choices.0.message.content'), $none); + } catch (\Throwable) { + return $none; + } + } + + /** + * JSON-массив [{n,v}] из ответа → вердикты по индексам (n − 1). Пропущенные — остаются null. + * + * @param list $none болванка нужной длины + * @return list + */ + private function parseVerdicts(string $content, array $none): array + { + $start = strpos($content, '['); + $end = strrpos($content, ']'); + if ($start === false || $end === false || $end < $start) { + return $none; + } + $arr = json_decode(substr($content, $start, $end - $start + 1), true); + if (! is_array($arr)) { + return $none; + } + + $out = $none; + $count = count($none); + foreach ($arr as $row) { + if (! is_array($row) || ! isset($row['n'])) { + continue; + } + $idx = ((int) $row['n']) - 1; + if ($idx < 0 || $idx >= $count) { + continue; + } + $v = mb_strtolower((string) ($row['v'] ?? '')); + if (str_contains($v, 'aggregator')) { + $out[$idx] = true; + } elseif (str_contains($v, 'supplier')) { + $out[$idx] = false; + } + } + + return $out; + } + public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool { $cfg = (array) config('services.aitunnel'); diff --git a/app/app/Services/Autopodbor/Agent/Aggregator/BatchAggregatorClassifier.php b/app/app/Services/Autopodbor/Agent/Aggregator/BatchAggregatorClassifier.php new file mode 100644 index 00000000..ba686a5c --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Aggregator/BatchAggregatorClassifier.php @@ -0,0 +1,19 @@ + $companies + * @return list вердикты по индексам входа: true — агрегатор, false — поставщик, null — не решил + */ + public function classifyManyAggregators(array $companies): array; +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/AitunnelQueryAnalyzer.php b/app/app/Services/Autopodbor/Agent/ChannelA/AitunnelQueryAnalyzer.php index 4b9040e6..d3db677b 100644 --- a/app/app/Services/Autopodbor/Agent/ChannelA/AitunnelQueryAnalyzer.php +++ b/app/app/Services/Autopodbor/Agent/ChannelA/AitunnelQueryAnalyzer.php @@ -31,13 +31,18 @@ final class AitunnelQueryAnalyzer implements QueryAnalyzer } // Примеры НАМЕРЕННО из РАЗНЫХ отраслей — движок универсален (клиент Лидерры может быть любой - // сферы и региона). Рубрики ИИ подбирает ПОД ОПИСАНИЕ клиента, а не под какую-то одну нишу. + // сферы и региона). Рубрики ИИ подбирает ПОД ОПИСАНИЕ клиента. Требуем УЗКИЕ категории и + // запрещаем зонтичные слова («финансовые услуги» и т.п.) — иначе канал А тащит не тех (юрфирмы, банки). $prompt = "Описание деятельности клиента: «{$description}». Регион: {$region}.\n". - 'Дай от 3 до 6 КОРОТКИХ поисковых запросов (рубрик), по которым в справочниках 2ГИС и '. - 'Яндекс.Карты ищут его КОНКУРЕНТОВ — так, как ищут категорию. Примеры для разных сфер: '. - 'стоматология → «стоматология», «имплантация зубов»; автосервис → «автосервис», «шиномонтаж»; '. - 'доставка еды → «доставка суши», «пиццерия». Подбери рубрики строго ПОД ОПИСАНИЕ клиента выше. '. - 'Только сами запросы, без пояснений, без названий конкретных фирм и без города. '. + 'Дай от 3 до 6 КОРОТКИХ запросов-рубрик, по которым в справочниках 2ГИС и Яндекс.Карты '. + "ищут именно ПРЯМЫХ конкурентов клиента — как называется его УЗКАЯ категория.\n". + 'Правило: рубрики только КОНКРЕТНЫЕ и узкие. ЗАПРЕЩЕНЫ общие «зонтичные» слова, которые '. + 'тащат кого попало: «услуги», «финансовые услуги», «финансы», «медицина», «товары», «магазин», '. + "«сервис», «компания». Каждая рубрика — точная специализация, а не сфера целиком.\n". + 'Примеры (узкое, НЕ зонтичное): стоматология → «стоматология», «имплантация зубов» (НЕ «медицина»); '. + 'автосервис → «шиномонтаж», «автосервис» (НЕ «услуги»); займы под залог авто → «автоломбард», '. + '«займ под ПТС» (НЕ «финансовые услуги»). Подбери рубрики строго ПОД ОПИСАНИЕ клиента выше. '. + 'Только сами запросы, без пояснений, без названий фирм и без города. '. 'Ответ — строго JSON-массив строк: ["...","..."]'; try { diff --git a/app/app/Services/Autopodbor/Agent/ChannelB/ExaSiteFinder.php b/app/app/Services/Autopodbor/Agent/ChannelB/ExaSiteFinder.php index 71182baa..1763f2ed 100644 --- a/app/app/Services/Autopodbor/Agent/ChannelB/ExaSiteFinder.php +++ b/app/app/Services/Autopodbor/Agent/ChannelB/ExaSiteFinder.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Services\Autopodbor\Agent\ChannelB; use Illuminate\Http\Client\Factory as HttpFactory; +use Illuminate\Http\Client\Response; /** * Нормализация канала В (ZAFIKSIROVANO / §11.4–11.5): у федерала НЕТ карточки в 2ГИС/Яндексе на регион, @@ -42,30 +43,92 @@ final class ExaSiteFinder $resp = $this->http ->withHeaders(['x-api-key' => $key]) ->timeout((int) ($cfg['timeout_sec'] ?? 30)) - ->post(rtrim((string) ($cfg['base_url'] ?? 'https://api.exa.ai'), '/').'/search', [ - 'query' => $name.' официальный сайт', - 'numResults' => 10, - 'type' => 'auto', - ]); + ->post(rtrim((string) ($cfg['base_url'] ?? 'https://api.exa.ai'), '/').'/search', $this->body($name)); - if (! $resp->successful()) { - return null; - } - - foreach ((array) $resp->json('results') as $r) { - $url = is_array($r) ? (string) ($r['url'] ?? '') : ''; - $domain = $this->domain($url); - if ($domain !== null && $this->isRealSite($domain)) { - return $domain; - } - } - - return null; + return $this->pickDomain($resp); } catch (\Throwable) { return null; } } + /** + * Пакетный поиск сайтов ПАРАЛЛЕЛЬНО — пулом Laravel Http, порциями по services.exa.concurrency + * (внутри порции одновременно, порции по очереди). Лимит EXA — на КЛЮЧ (один на всех клиентов), + * поэтому не «все разом», а бережно; глобальный потолок между клиентами держит очередь. + * Сбой/429 по имени → его сайт null (не роняет пул и не теряет остальных). + * + * @param list $names + * @return array имя → домен (или null) + */ + public function findSites(array $names, string $region): array + { + $names = array_values(array_unique(array_filter( + array_map(static fn ($n): string => trim((string) $n), $names), + static fn (string $n): bool => $n !== '', + ))); + + $out = []; + foreach ($names as $n) { + $out[$n] = null; + } + if ($names === []) { + return $out; + } + + $cfg = (array) config('services.exa'); + $key = (string) ($cfg['key'] ?? ''); + if ($key === '') { + return $out; + } + + $url = rtrim((string) ($cfg['base_url'] ?? 'https://api.exa.ai'), '/').'/search'; + $timeout = (int) ($cfg['timeout_sec'] ?? 30); + $concurrency = max(1, (int) ($cfg['concurrency'] ?? 5)); + + foreach (array_chunk($names, $concurrency) as $chunk) { + try { + $responses = $this->http->pool(fn ($pool) => array_map( + fn (string $n) => $pool->as($n) + ->withHeaders(['x-api-key' => $key]) + ->timeout($timeout) + ->post($url, $this->body($n)), + $chunk, + )); + } catch (\Throwable) { + continue; // весь чанк не удался — имена остаются null, идём к следующему + } + + foreach ($chunk as $n) { + $out[$n] = $this->pickDomain($responses[$n] ?? null); + } + } + + return $out; + } + + /** Тело запроса EXA по имени. */ + private function body(string $name): array + { + return ['query' => $name.' официальный сайт', 'numResults' => 10, 'type' => 'auto']; + } + + /** Первый «настоящий» домен из ответа EXA (не каталог/агрегатор/иностранный TLD), иначе null. */ + private function pickDomain(mixed $resp): ?string + { + if (! $resp instanceof Response || ! $resp->successful()) { + return null; + } + foreach ((array) $resp->json('results') as $r) { + $url = is_array($r) ? (string) ($r['url'] ?? '') : ''; + $domain = $this->domain($url); + if ($domain !== null && $this->isRealSite($domain)) { + return $domain; + } + } + + return null; + } + /** Голый домен из URL: без схемы/www/пути, нижний регистр. */ private function domain(string $url): ?string { diff --git a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php index e2b2362e..a2ef0804 100644 --- a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php +++ b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php @@ -73,8 +73,11 @@ final class LiveFindCompetitors $this->stringList($r->examples), ); $names = $this->channelB->harvest($profile, $city, $clientSite, $known, $this->passes); - foreach (array_slice($names, 0, max(0, $this->channelBCap)) as $cand) { - $site = $this->exa->findSite($cand['name'], $city); + $capped = array_slice($names, 0, max(0, $this->channelBCap)); + // Сайты федералов добываем ПАРАЛЛЕЛЬНО пулом (не по одному в цикле) — см. ExaSiteFinder::findSites. + $sites = $this->exa->findSites(array_map(static fn (array $c): string => (string) $c['name'], $capped), $city); + foreach ($capped as $cand) { + $site = $sites[trim((string) $cand['name'])] ?? null; if ($site !== null) { $bCards[] = $this->federalCard($cand['name'], $site); } diff --git a/app/app/Services/Autopodbor/AutopodborDedup.php b/app/app/Services/Autopodbor/AutopodborDedup.php index e95c079f..887d606c 100644 --- a/app/app/Services/Autopodbor/AutopodborDedup.php +++ b/app/app/Services/Autopodbor/AutopodborDedup.php @@ -97,9 +97,13 @@ final class AutopodborDedup return []; } + // Словарь слов-категорий из рубрик ВСЕХ кандидатов — им стрижём имена («Яричъ Ломбард» → «Яричъ»). + // Из данных прогона, не из зашитого списка — универсально для любой отрасли. + $rubricVocab = $this->rubricVocab($candidates); + $keysOf = []; foreach ($candidates as $i => $c) { - $keysOf[$i] = $this->candidateKeys($c); + $keysOf[$i] = $this->candidateKeys($c, $rubricVocab); } // union-find: общий ключ → одна группа @@ -130,7 +134,7 @@ final class AutopodborDedup // ключи клиента (для вычета самого себя) $client = []; foreach ($clientKeys as $ck) { - foreach ($this->candidateKeys(['name' => $ck, 'site_url' => $ck]) as $k) { + foreach ($this->candidateKeys(['name' => $ck, 'site_url' => $ck], []) as $k) { $client[$k] = true; } } @@ -163,13 +167,14 @@ final class AutopodborDedup } /** - * Ключи кандидата для union-find: корень имени и корень домена под общим префиксом - * (чтобы имя сцеплялось с доменом), телефоны — под отдельным. + * Ключи кандидата для union-find: корень имени, имя-минус-словарь-рубрик, корень домена, + * телефоны, коды справочников. Всё под общим префиксом 'k:' (кроме телефонов 'p:'). * - * @param array{name?:string,site_url?:?string,phones?:array} $c + * @param array{name?:string,site_url?:?string,description?:?string,phones?:array,directory_keys?:array} $c + * @param list $rubricVocab словарь слов-категорий из рубрик всех кандидатов * @return list */ - private function candidateKeys(array $c): array + private function candidateKeys(array $c, array $rubricVocab = []): array { $keys = []; $name = isset($c['name']) ? (string) $c['name'] : ''; @@ -178,6 +183,12 @@ final class AutopodborDedup if ($nk !== '') { $keys[] = 'k:'.$nk; } + // Ключ имени без слов-категорий из словаря рубрик: «Яричъ Ломбард» сцепляется с «Яричъ». + // Слово-категория — из данных прогона (словарь рубрик), не из зашитого списка ниш. + $stripped = $this->norm->nameKeyMinusWords($name, $rubricVocab); + if ($stripped !== '' && $stripped !== $nk) { + $keys[] = 'k:'.$stripped; + } } if (! empty($c['site_url'])) { $dr = $this->norm->domainRoot((string) $c['site_url']); @@ -203,6 +214,25 @@ final class AutopodborDedup return array_values(array_unique($keys)); } + /** + * Словарь слов-категорий из рубрик (описаний) ВСЕХ кандидатов — уникальные слова. + * Из данных прогона, не из зашитого списка ниш (универсально для любой отрасли). + * + * @param array $candidates + * @return list + */ + private function rubricVocab(array $candidates): array + { + $vocab = []; + foreach ($candidates as $c) { + foreach ($this->norm->words((string) ($c['description'] ?? '')) as $w) { + $vocab[$w] = true; + } + } + + return array_keys($vocab); + } + /** * Сливает группу совпавших кандидатов в одну карточку: имя/сайт/описание — первое непустое, * ссылки справочников и телефоны — объединением, is_federal — местная карточка перевешивает. diff --git a/app/app/Services/Autopodbor/AutopodborNormalizer.php b/app/app/Services/Autopodbor/AutopodborNormalizer.php index 542d66b1..55224bba 100644 --- a/app/app/Services/Autopodbor/AutopodborNormalizer.php +++ b/app/app/Services/Autopodbor/AutopodborNormalizer.php @@ -129,6 +129,36 @@ final class AutopodborNormalizer return $this->alnumKey(implode('.', $parts)); } + /** + * Ключ имени БЕЗ слов-категорий из СЛОВАРЯ рубрик — чтобы «Яричъ Ломбард» сцепился с «Яричъ». + * Слова-категории берутся ИЗ ДАННЫХ прогона (словарь рубрик справочника), а НЕ из зашитого + * списка ниш — работает для любой отрасли («Улыбка Стоматология» → «Улыбка»). Слово может + * прийти из рубрики ДРУГОЙ фирмы (общий словарь), поэтому ловит и имена без своей рубрики. + * Ключ создаём ТОЛЬКО если после стрижки остался РОВНО ОДИН слово-токен (бренд типа «Яричъ»), + * а НЕ описательная фраза из нескольких слов («займы под залог») — генерики не склеиваем. + * Иначе (0 или >1 слова) → '' (ключа нет). + * + * @param list $vocabWords словарь слов-категорий (уже нормализованы через words()) + */ + public function nameKeyMinusWords(string $name, array $vocabWords): string + { + if ($vocabWords === []) { + return ''; + } + $remove = array_flip($vocabWords); + $kept = array_values(array_filter($this->words($name), static fn (string $w): bool => ! isset($remove[$w]))); + + return count($kept) === 1 ? $kept[0] : ''; + } + + /** @return list слова строки: нижний регистр, ё→е, разбивка по не-буквам/цифрам. */ + public function words(string $s): array + { + $s = str_replace('ё', 'е', mb_strtolower(trim($s))); + + return array_values(preg_split('/[^\p{L}\p{N}]+/u', $s, -1, PREG_SPLIT_NO_EMPTY) ?: []); + } + /** Нижний регистр + ё→е + только буквы/цифры (Unicode). */ private function alnumKey(string $s): string { diff --git a/app/config/services.php b/app/config/services.php index 2d3c416f..f099a1f4 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -90,6 +90,10 @@ return [ 'embed_model' => env('AITUNNEL_EMBED_MODEL', 'text-embedding-3-small'), 'chat_model' => env('AITUNNEL_CHAT_MODEL', 'gpt-4o-mini'), 'timeout_sec' => (int) env('AITUNNEL_TIMEOUT_SEC', 30), + // Батч отсева агрегаторов: список фирм режем на порции по aggregator_batch_size (иначе + // один запрос на ~100 фирм долгий и рискует не успеть) + отдельный долгий таймаут на порцию. + 'aggregator_batch_size' => (int) env('AITUNNEL_AGGREGATOR_BATCH_SIZE', 40), + 'aggregator_timeout_sec' => (int) env('AITUNNEL_AGGREGATOR_TIMEOUT_SEC', 90), // Канал В — генератор имён (ZAFIKSIROVANO §0-БИС): одна рассуждающая модель, ~68 с/вызов, // потому отдельный долгий таймаут. 'research_model' => env('AITUNNEL_RESEARCH_MODEL', 'sonar-reasoning-pro'), @@ -102,6 +106,9 @@ return [ 'key' => env('EXA_API_KEY'), 'base_url' => env('EXA_BASE_URL', 'https://api.exa.ai'), 'timeout_sec' => (int) env('EXA_TIMEOUT_SEC', 30), + // Сколько запросов сайта-федерала слать ОДНОВРЕМЕННО (пул EXA). Лимит EXA — на КЛЮЧ, + // общий на всех клиентов, поэтому бережно; глобальный потолок между клиентами — очередь. + 'concurrency' => (int) env('EXA_CONCURRENCY', 5), ], // Плитка балансов (28.06): Yandex Cloud биллинг (серверы + Managed PG ~18к/мес). diff --git a/app/tests/Feature/Autopodbor/AutopodborQueueThrottleTest.php b/app/tests/Feature/Autopodbor/AutopodborQueueThrottleTest.php new file mode 100644 index 00000000..b787e079 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborQueueThrottleTest.php @@ -0,0 +1,40 @@ +middleware(); + + expect($mw)->toHaveCount(1) + ->and($mw[0])->toBeInstanceOf(WithoutOverlapping::class); +}); + +it('шаг 2 (изучение) и резолв — на той же очереди и защищены от нахлёста', function () { + Queue::fake(); + RunAutopodborStudyJob::dispatch(7); + RunAutopodborResolveJob::dispatch(8); + + Queue::assertPushedOn('autopodbor', RunAutopodborStudyJob::class); + Queue::assertPushedOn('autopodbor', RunAutopodborResolveJob::class); + + expect((new RunAutopodborStudyJob(7))->middleware()[0])->toBeInstanceOf(WithoutOverlapping::class) + ->and((new RunAutopodborResolveJob(8))->middleware()[0])->toBeInstanceOf(WithoutOverlapping::class); +}); diff --git a/app/tests/Unit/Autopodbor/Aggregator/AggregatorFilterTest.php b/app/tests/Unit/Autopodbor/Aggregator/AggregatorFilterTest.php index 9698f78f..8351e3a8 100644 --- a/app/tests/Unit/Autopodbor/Aggregator/AggregatorFilterTest.php +++ b/app/tests/Unit/Autopodbor/Aggregator/AggregatorFilterTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); use App\Services\Autopodbor\Agent\Aggregator\AggregatorClassifier; use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter; +use App\Services\Autopodbor\Agent\Aggregator\BatchAggregatorClassifier; /** Подставной классификатор: по имени отдаёт true (агрегатор) / false (поставщик) / null (не смог). */ function fakeClassifier(array $byName): AggregatorClassifier @@ -47,3 +48,38 @@ it('при неуверенности классификатора (null) кон it('пустой вход → пустой выход', function () { expect((new AggregatorFilter(fakeClassifier([])))->filter([]))->toBe([]); }); + +it('если классификатор умеет батч — зовёт ОДНИМ запросом на весь список, а не по одному', function () { + // Классификатор умеет и по-одному, и батчем. Фильтр обязан выбрать батч (быстро) и не звать single. + $classifier = new class(['Авито' => true, 'Zoon' => true, 'АвтоДеньги' => false]) implements AggregatorClassifier, BatchAggregatorClassifier + { + /** @var list лог вызовов */ + public array $log = []; + + /** @param array $map */ + public function __construct(private array $map) {} + + public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool + { + $this->log[] = 'single'; + + return $this->map[$name] ?? null; + } + + public function classifyManyAggregators(array $companies): array + { + $this->log[] = 'batch:'.count($companies); + + return array_map(fn (array $c): ?bool => $this->map[$c['name']] ?? null, $companies); + } + }; + + $filtered = (new AggregatorFilter($classifier))->filter([ + ['name' => 'Авито', 'site_url' => 'avito.ru'], + ['name' => 'АвтоДеньги', 'site_url' => 'avtodengi.ru'], + ['name' => 'Zoon', 'site_url' => 'zoon.ru'], + ]); + + expect(array_column($filtered, 'name'))->toBe(['АвтоДеньги']) + ->and($classifier->log)->toBe(['batch:3']); // ровно один батч-запрос, ноль одиночных +}); diff --git a/app/tests/Unit/Autopodbor/Aggregator/AitunnelAggregatorClassifierTest.php b/app/tests/Unit/Autopodbor/Aggregator/AitunnelAggregatorClassifierTest.php index aee02858..dbfc3b3d 100644 --- a/app/tests/Unit/Autopodbor/Aggregator/AitunnelAggregatorClassifierTest.php +++ b/app/tests/Unit/Autopodbor/Aggregator/AitunnelAggregatorClassifierTest.php @@ -41,3 +41,53 @@ it('ошибка сети → null', function () { Http::fake(fn () => throw new ConnectionException('timeout')); expect(app(AitunnelAggregatorClassifier::class)->isAggregator('X', null, null))->toBeNull(); }); + +it('батч: один запрос классифицирует весь список, вердикты по номерам', function () { + config(['services.aitunnel.key' => 'sk', 'services.aitunnel.base_url' => 'https://api.aitunnel.ru/v1']); + fakeChat('[{"n":1,"v":"AGGREGATOR"},{"n":2,"v":"SUPPLIER"},{"n":3,"v":"AGGREGATOR"}]'); + + $out = app(AitunnelAggregatorClassifier::class)->classifyManyAggregators([ + ['name' => 'Авито', 'site_url' => 'avito.ru', 'description' => null], + ['name' => 'АвтоДеньги', 'site_url' => 'ad.ru', 'description' => 'займы'], + ['name' => 'Zoon', 'site_url' => 'zoon.ru', 'description' => null], + ]); + + expect($out)->toBe([true, false, true]); + Http::assertSentCount(1); // ровно один запрос на весь список +}); + +it('батч режется на порции (aggregator_batch_size) — вердикты собираются по всем чанкам', function () { + config(['services.aitunnel.key' => 'sk', 'services.aitunnel.base_url' => 'https://api.aitunnel.ru/v1', 'services.aitunnel.aggregator_batch_size' => 2]); + fakeChat('[{"n":1,"v":"AGGREGATOR"},{"n":2,"v":"SUPPLIER"}]'); + + $out = app(AitunnelAggregatorClassifier::class)->classifyManyAggregators([ + ['name' => 'A', 'site_url' => null, 'description' => null], // чанк 1, n1 → AGGREGATOR + ['name' => 'B', 'site_url' => null, 'description' => null], // чанк 1, n2 → SUPPLIER + ['name' => 'C', 'site_url' => null, 'description' => null], // чанк 2, n1 → AGGREGATOR + ]); + + expect($out)->toBe([true, false, true]); + Http::assertSentCount(2); // два чанка = два запроса +}); + +it('батч без ключа → все null (консервативно)', function () { + config(['services.aitunnel.key' => '']); + $out = app(AitunnelAggregatorClassifier::class)->classifyManyAggregators([ + ['name' => 'X', 'site_url' => null, 'description' => null], + ['name' => 'Y', 'site_url' => null, 'description' => null], + ]); + expect($out)->toBe([null, null]); +}); + +it('батч: пропущенные в ответе номера остаются null (не теряем)', function () { + config(['services.aitunnel.key' => 'sk']); + fakeChat('[{"n":2,"v":"AGGREGATOR"}]'); // про №1 и №3 модель молчит + + $out = app(AitunnelAggregatorClassifier::class)->classifyManyAggregators([ + ['name' => 'A', 'site_url' => null, 'description' => null], + ['name' => 'B', 'site_url' => null, 'description' => null], + ['name' => 'C', 'site_url' => null, 'description' => null], + ]); + + expect($out)->toBe([null, true, null]); +}); diff --git a/app/tests/Unit/Autopodbor/AutopodborMergeTest.php b/app/tests/Unit/Autopodbor/AutopodborMergeTest.php index 5a4b16a5..05e7ae9d 100644 --- a/app/tests/Unit/Autopodbor/AutopodborMergeTest.php +++ b/app/tests/Unit/Autopodbor/AutopodborMergeTest.php @@ -63,6 +63,64 @@ it('склеивает по общему коду справочника (slug ->and($merged[0]['directory_urls'])->toHaveCount(2); }); +it('склеивает «Имя» и «Имя + слово-рубрику» — слово берётся из рубрики фирмы (не из списка)', function () { + $merged = mergeService()->mergeCompetitors([ + ['name' => 'Яричъ Ломбард', 'description' => 'Ломбард', 'directory_urls' => ['https://yandex.ru/maps/org/yarichl/1']], + ['name' => 'Яричъ', 'description' => 'Ломбард', 'directory_urls' => ['https://yandex.ru/maps/org/yarich/2']], + ]); + + expect($merged)->toHaveCount(1); +}); + +it('склейка по словарю рубрик: длинное имя БЕЗ своей рубрики тоже цепляется (слово из общего словаря)', function () { + // У «Яричъ Ломбард» своей рубрики нет, но «Ломбард» есть в словаре (его дала рубрика «Яричъ»). + $merged = mergeService()->mergeCompetitors([ + ['name' => 'Яричъ Ломбард', 'description' => null], + ['name' => 'Яричъ', 'description' => 'Ломбард'], + ]); + + expect($merged)->toHaveCount(1); +}); + +it('универсально: та же склейка для стоматолога (слово-категория из рубрики, без зашитых ниш)', function () { + $merged = mergeService()->mergeCompetitors([ + ['name' => 'Улыбка Стоматология', 'description' => 'Стоматология'], + ['name' => 'Улыбка', 'description' => 'Стоматология'], + ]); + + expect($merged)->toHaveCount(1); +}); + +it('генерик из нескольких слов НЕ склеивается («Ломбард Займы под залог» ≠ «Займы под залог»)', function () { + // После стрижки остаётся описательная фраза «займы под залог» (3 слова) — это генерик, не бренд. + // Третья фирма кладёт «ломбард» в словарь рубрик (как в реальном прогоне). + $merged = mergeService()->mergeCompetitors([ + ['name' => 'Ломбард Займы под залог', 'description' => null], + ['name' => 'Займы под залог', 'description' => 'Микрофинансовая организация'], + ['name' => 'Рубин', 'description' => 'Ломбард'], + ]); + + expect($merged)->toHaveCount(3); +}); + +it('НЕ склеивает разные имена с одинаковой рубрикой (защита от пере-склейки)', function () { + $merged = mergeService()->mergeCompetitors([ + ['name' => 'Быстро Ломбард', 'description' => 'Ломбард'], + ['name' => 'Дёшево Ломбард', 'description' => 'Ломбард'], + ]); + + expect($merged)->toHaveCount(2); // «быстро» ≠ «дёшево» +}); + +it('голое имя-рубрика не плодит пустой ключ (генерики не слипаются по пустоте)', function () { + $merged = mergeService()->mergeCompetitors([ + ['name' => 'Ломбард', 'description' => 'Ломбард'], + ['name' => 'Займ', 'description' => 'Займ'], + ]); + + expect($merged)->toHaveCount(2); +}); + it('не склеивает разных конкурентов', function () { $merged = mergeService()->mergeCompetitors([ ['name' => 'Окна Комфорт', 'site_url' => 'okna-komfort.ru'], diff --git a/app/tests/Unit/Autopodbor/ChannelB/ExaSiteFinderTest.php b/app/tests/Unit/Autopodbor/ChannelB/ExaSiteFinderTest.php index 7dbfd2ed..c486a439 100644 --- a/app/tests/Unit/Autopodbor/ChannelB/ExaSiteFinderTest.php +++ b/app/tests/Unit/Autopodbor/ChannelB/ExaSiteFinderTest.php @@ -66,3 +66,54 @@ it('нет подходящих результатов или ошибка се $http2->fake(['api.exa.ai/*' => $http2->response('boom', 500)]); expect((new ExaSiteFinder($http2))->findSite('Y', 'Красноярский край'))->toBeNull(); }); + +it('findSites: пул отдаёт домены по именам; чёрный список → null', function () { + Config::set('services.exa', ['key' => 'k', 'base_url' => 'https://api.exa.ai', 'timeout_sec' => 30, 'concurrency' => 5]); + + $http = app(HttpFactory::class); + $http->fake(function ($request) use ($http) { + $q = (string) ($request->data()['query'] ?? ''); + if (str_contains($q, 'CarMoney')) { + return $http->response(['results' => [['url' => 'https://www.carmoney.ru/about']]], 200); + } + if (str_contains($q, 'Пусто')) { + return $http->response(['results' => [['url' => 'https://zoon.ru/x']]], 200); // только каталог → null + } + if (str_contains($q, 'Cashmotor')) { + return $http->response(['results' => [['url' => 'https://cashmotor.ru/']]], 200); + } + + return $http->response(['results' => []], 200); + }); + + $map = (new ExaSiteFinder($http))->findSites(['CarMoney', 'Пусто', 'Cashmotor'], 'Красноярский край'); + + expect($map)->toBe(['CarMoney' => 'carmoney.ru', 'Пусто' => null, 'Cashmotor' => 'cashmotor.ru']); +}); + +it('findSites: больше порции (concurrency=2) — все имена обработаны по чанкам', function () { + Config::set('services.exa', ['key' => 'k', 'base_url' => 'https://api.exa.ai', 'timeout_sec' => 30, 'concurrency' => 2]); + + $http = app(HttpFactory::class); + $http->fake(function ($request) use ($http) { + $q = (string) ($request->data()['query'] ?? ''); + preg_match('/Ф(\d)/u', $q, $m); + $i = $m[1] ?? '0'; + + return $http->response(['results' => [['url' => "https://firma{$i}.ru/"]]], 200); + }); + + $map = (new ExaSiteFinder($http))->findSites(['Ф1', 'Ф2', 'Ф3', 'Ф4', 'Ф5'], 'Красноярский край'); + + expect($map)->toHaveCount(5) + ->and($map['Ф1'])->toBe('firma1.ru') + ->and($map['Ф5'])->toBe('firma5.ru'); +}); + +it('findSites: без ключа → все null', function () { + Config::set('services.exa.key', ''); + + $map = (new ExaSiteFinder(app(HttpFactory::class)))->findSites(['A', 'B'], 'Красноярский край'); + + expect($map)->toBe(['A' => null, 'B' => null]); +}); diff --git a/docs/superpowers/plans/2026-07-01-autopodbor-scale-throttle.md b/docs/superpowers/plans/2026-07-01-autopodbor-scale-throttle.md new file mode 100644 index 00000000..9888c8af --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-autopodbor-scale-throttle.md @@ -0,0 +1,50 @@ +# План: масштаб автоподбора — ускорение и защита от нескольких клиентов + +> Воркстри `worktree-avtopodbor`. НЕ прод. Коммит — «эскейп». По TDD. + +**Проблема:** шаг 1 зовёт внешние сервисы, а лимиты у них — **на КЛЮЧ (один на всех клиентов)**, не на клиента. +- Агрегатор-фильтр бил gpt-4o-mini по одному (~90 запросов) → **уже починено батчем** (коммит будет). +- EXA бьёт по одному федералу в цикле (до 40 подряд) → **распараллелить пулом**. +- Полезут 3-4 клиента разом → 4×пул на один ключ → 429. **Нужен глобальный потолок на уровне очереди.** + +**Принцип:** два слоя throttle. Очередь держит «сколько КЛИЕНТОВ разом», пул держит «сколько внутри одного клиента». Итог на ключ = очередь × пул ≤ реальный лимит. + +--- + +## Задача 1 — EXA параллельно, ограниченным пулом (по TDD) + +**Файлы:** `app/Services/Autopodbor/Agent/ChannelB/ExaSiteFinder.php`, `LiveFindCompetitors.php`, `config/services.php`, тест `ExaSiteFinderTest.php`. + +- Новый метод `findSites(array $names, string $region): array` — имя → домен (или null). + Пускает запросы **пулом Laravel Http**, порциями по `services.exa.concurrency` (дефолт 5): внутри порции — одновременно, порции — по очереди. Каждый ответ через ту же логику `domain()`/`isRealSite()`. +- `LiveFindCompetitors`: вместо `foreach findSite()` — один `findSites($names)`; собрать федерал-карточки из карты. +- Конфиг: `services.exa.concurrency` (env `EXA_CONCURRENCY`, дефолт 5). +- `findSite()` (одиночный) оставить — обратная совместимость/резолвер. + +**Тесты:** 3 имени (сайт / только-чёрный-список→null / сайт) → верная карта; >5 имён (две порции) → все обработаны; без ключа → все null; чёрный список/иностранный TLD отсекается. + +## Задача 2 — глобальный потолок очереди (несколько клиентов) + +**Файлы:** `app/Jobs/Autopodbor/RunAutopodborSearchJob.php` (+ Study/Resolve), тест джобы. + +- Джобу — на **выделенную очередь** `autopodbor` (`->onQueue('autopodbor')` при dispatch или `$queue` в джобе). +- Middleware `WithoutOverlapping($tenantId)` — один и тот же клиент не запускает два подбора внахлёст. +- **Глобальный потолок N одновременных** = число воркеров на очереди `autopodbor` (ops-рычаг, supervisor `numprocs=2`). Это в плане как настройка, не код. +- (Опция) `Redis::funnel('autopodbor:step1')->limit(N)` внутри джобы — жёсткий код-потолок, если воркеров нельзя ограничить. + +**Тесты:** dispatch → джоба уходит на очередь `autopodbor`; middleware содержит `WithoutOverlapping` с ключом тенанта. + +## Задача 3 — 429 не теряет федерала (мягкая деградация) + +- В пуле: ответ 429/сбой по имени → это имя `null` (как сейчас), НЕ роняет весь пул. Уже покрыто консервативным null. +- (Опция, если 429 частый) один повтор порции через паузу — по факту с очередью-потолком не понадобится. + +--- + +## Порядок +1. Задача 1 (EXA пул) — код-ядро, полностью TDD. ← начинаем +2. Задача 2 (очередь + overlap) — код + запись ops-настройки воркеров. +3. Замер реального лимита EXA (когда основной прогон освободит ключ) → подгон `concurrency` и числа воркеров. + +## Жёсткое +Лимит — на КЛЮЧ, не на клиента · пул + очередь — два слоя · 429 не теряет данные · универсально (не под нишу) · НЕ прод · коммит «эскейп». From f04c82e5ee0bb4fed67a0072795905a6a1089e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 1 Jul 2026 09:27:26 +0300 Subject: [PATCH 106/417] =?UTF-8?q?fix(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D0=B4=D0=B5=D0=B4=D1=83?= =?UTF-8?q?=D0=BF=20=D0=94=D0=9E=20=D0=BE=D1=82=D1=81=D0=B5=D0=B2=D0=B0=20?= =?UTF-8?q?=D0=B0=D0=B3=D1=80=D0=B5=D0=B3=D0=B0=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=20+=20=D1=84=D0=B0=D0=B9=D0=BB=20=D1=81=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F/=D0=BF=D1=80=D0=BE=D0=BC?= =?UTF-8?q?=D1=82=20=D0=BF=D1=80=D0=BE=D0=B4=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отсев агрегаторов шёл по СЫРЬЮ до склейки — gpt-4o-mini залетало 227 позиций с дублями (Ломбардико ×6). Порядок в FindCompetitorsAssembler: сначала склейка/дедуп, потом отсев — классифицируем ~90 уникальных, а не 227. Плюс файл состояния сессии (что сделано/осталось) + промт восстановления контекста для компакта. TDD, 234/234 зелёные. НЕ прод. Co-Authored-By: Claude Opus 4.8 --- .../Agent/FindCompetitorsAssembler.php | 15 ++- .../FindCompetitorsAssemblerTest.php | 36 +++++ ...01-AVTOPODBOR-SOSTOYANIE-i-prodolzhenie.md | 125 ++++++++++++++++++ 3 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-01-AVTOPODBOR-SOSTOYANIE-i-prodolzhenie.md diff --git a/app/app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php b/app/app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php index cb689937..1b7596a8 100644 --- a/app/app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php +++ b/app/app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php @@ -11,8 +11,9 @@ use App\Services\Autopodbor\AutopodborDedup; /** * Сборка ядра шага 1 (§12.1, хвост движка v4): резолвленные кандидаты каналов (A/B/0) → - * отсев агрегаторов (§12.6) → слияние+дедуп+вычет клиента (E, §12) → отсев федералов (если не нужны) - * → похожесть-эмбеддинги (F, §12.5) → срез top-N → DTO {@see FindCompetitorsResult} (§7.2). + * слияние+дедуп+вычет клиента (E, §12) → отсев агрегаторов (§12.6, ПОСЛЕ дедупа — по уникальным + * фирмам) → отсев федералов (если не нужны) → похожесть-эмбеддинги (F, §12.5) → срез top-N → + * DTO {@see FindCompetitorsResult} (§7.2). * * Это ЧИСТАЯ сборка: добыча страниц/имён (каналы) и резолв — выше по течению, за своими границами. * Поэтому всё ядро тестируется офлайн. Провайдер на боевой движок флипается отдельно (за флагом). @@ -37,14 +38,16 @@ final class FindCompetitorsAssembler bool $includeFederal, int $maxCompetitors, ): FindCompetitorsResult { - $filtered = $this->aggregatorFilter->filter($candidates); - $merged = $this->dedup->mergeCompetitors($filtered, $clientKeys); + // Склейка/дедуп ПЕРВОЙ — иначе отсев агрегаторов классифицирует дубли-филиалы по многу раз + // (реальный случай: gpt-4o-mini залетело 227 позиций с повторами вместо ~90 уникальных). + $merged = $this->dedup->mergeCompetitors($candidates, $clientKeys); + $filtered = $this->aggregatorFilter->filter($merged); if (! $includeFederal) { - $merged = array_values(array_filter($merged, fn (array $c): bool => empty($c['is_federal']))); + $filtered = array_values(array_filter($filtered, fn (array $c): bool => empty($c['is_federal']))); } - $ranked = $this->relevance->rank($clientExamples, $merged); + $ranked = $this->relevance->rank($clientExamples, $filtered); if ($maxCompetitors > 0) { $ranked = array_slice($ranked, 0, $maxCompetitors); diff --git a/app/tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php b/app/tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php index 17e8d648..f32d105d 100644 --- a/app/tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php +++ b/app/tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php @@ -87,6 +87,42 @@ it('собирает ядро: отсев агрегаторов → слиян expect($top)->toHaveKeys(['name', 'description', 'is_federal', 'relevance_pct', 'site_url', 'directory_urls', 'provenance']); }); +it('дедуп ДО отсева агрегаторов — классификатор видит уникальные фирмы, а не дубли-филиалы', function () { + // sampleCandidates() содержит «АвтоДеньги» дважды (дубль по домену). Отсев агрегаторов должен + // идти ПОСЛЕ склейки — иначе gpt-4o-mini классифицирует один и тот же дубль по многу раз (реальный + // случай: агрегатору залетело 227 позиций с кучей повторов). + $recorder = new class implements AggregatorClassifier + { + /** @var list */ + public array $asked = []; + + public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool + { + $this->asked[] = $name; + + return $name === 'Авито'; + } + }; + $zero = new class implements Embedder + { + public function embed(array $texts): array + { + return array_map(fn () => [0.0], $texts); + } + }; + + $a = new FindCompetitorsAssembler( + new AggregatorFilter($recorder), + new AutopodborDedup(new AutopodborNormalizer), + new EmbeddingRelevance($zero), + ); + + $a->assemble(sampleCandidates(), ['залог авто'], ['moy-biznes.ru'], true, 10); + + // «АвтоДеньги» (дубль) — спрошен РОВНО один раз (склейка схлопнула до отсева). + expect(array_count_values($recorder->asked)['АвтоДеньги'] ?? 0)->toBe(1); +}); + it('includeFederal=true оставляет федерала; maxCompetitors режет хвост', function () { $a = assembler( aggByName: ['Авито' => true], diff --git a/docs/superpowers/plans/2026-07-01-AVTOPODBOR-SOSTOYANIE-i-prodolzhenie.md b/docs/superpowers/plans/2026-07-01-AVTOPODBOR-SOSTOYANIE-i-prodolzhenie.md new file mode 100644 index 00000000..9b08bb2c --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-AVTOPODBOR-SOSTOYANIE-i-prodolzhenie.md @@ -0,0 +1,125 @@ +# 🔧 Автоподбор шаг 1 — СОСТОЯНИЕ + промт продолжения (01.07.2026, вечер) + +> Воркстри `worktree-avtopodbor` (код под `app/`). **НЕ прод. Коммит — «эскейп». Пуш — только gitea. +> Простым языком — владелец не программист. Руками 2ГИС/Яндекс не парсить — только сервисы.** + +--- + +## 0. ОДНОЙ СТРОКОЙ +Движок шага 1 работает и выдаёт ~100 конкурентов на живых данных. За сессию — сильно ускорен +(пул EXA, батч/чанкинг агрегатора, очередь-throttle) и **почищен** (узкие рубрики, дедуп по словарю +рубрик, дедуп ДО отсева агрегаторов). Осталось три рычага чистоты/скорости — см. §3. + +## 1. КОММИТЫ СЕССИИ (gitea, ветка `worktree-avtopodbor`) +- `6e970d72` — канал В: чистый стоп-лист (дубли-филиалы схлопнуты перед отправкой ИИ). +- `01a057c6` — Яндекс дедупит по **slug** (код фирмы от источника) + slug в склейку (214→94 живьём). +- `c2598553` — **рубрика** из списка Яндекса (Playwright) в описание конкурента + **межотраслевой** + промпт анализатора (движок универсален, не под займы). +- `f1ab6084` — масштаб+чистота пачкой: **пул EXA** (concurrency=5), **очередь `autopodbor`** + + WithoutOverlapping (3 джобы), **батч+чанкинг** агрегатора (по 40, таймаут 90с), **узкие рубрики** + (запрет зонтичных «финансовые услуги»), **дедуп по словарю рубрик** («Яричъ Ломбард»→«Яричъ»), + **гард генериков** (склейка только если остался ровно один слово-токен). +- **(этот коммит)** — сборщик: **дедуп ДО отсева агрегаторов** (агрегатору шло 227 позиций с дублями, + стало ~90 уникальных). + +## 2. ✅ ЧТО СДЕЛАНО (детально) +**Скорость / масштаб** (лимиты EXA/xfetch/AITUNNEL — на КЛЮЧ, общий на всех клиентов): +- EXA: `ExaSiteFinder::findSites()` — пул порциями по `services.exa.concurrency` (5). Сбой/429 по + имени → только его null, пул цел. +- Агрегатор: `classifyManyAggregators()` — батч, режется на порции `aggregator_batch_size` (40), + таймаут `aggregator_timeout_sec` (90с). Раньше ~90 запросов по одному → «умирал». +- Очередь `autopodbor` на всех 3 джобах (search/study/resolve) + `WithoutOverlapping(runId)`. + Глобальный потолок = число воркеров этой очереди (ops-рычаг). «Один клиент = один подбор» держит + `AutopodborRunService::assertNoInFlight`. +- **Шаг 2** (`studyCompetitor`) тоже под очередью `autopodbor` — защита от нескольких клиентов есть. + Внутри шага 2 ~3 запроса на конкурента (сайт+2 карточки) — параллель пока НЕ сделана (мелко). + +**Чистота выдачи** (всё универсально, без зашитых ниш): +- Анализатор: узкие рубрики, запрет зонтичных слов (иначе «финансовые услуги» тащит юрфирмы/банки). +- Дедуп по slug Яндекса (`YandexListParser`) — филиалы одной фирмы схлопнуты. +- Дедуп по **словарю рубрик** прогона (`AutopodborNormalizer::nameKeyMinusWords` + + `AutopodborDedup::rubricVocab`): «Яричъ Ломбард»→«Яричъ», слово-категория из ДАННЫХ, не из списка. + Гард: только если после стрижки остался ровно ОДИН слово-токен (генерики не трогаем). +- Дедуп ДО отсева агрегаторов (порядок в `FindCompetitorsAssembler::assemble`). + +**Тесты:** 234/234 (unit+feature) зелёные, Pint чист. + +## 3. 🔴 ЧТО ОСТАЛОСЬ (по приоритету) +1. **Центр похожести из описания ниши** — ГЛАВНЫЙ рычаг чистоты. Сейчас «центр» эмбеддингов строится + ТОЛЬКО из `r->examples` (два домена клиента) → проценты низкие и сбитые (топ 39%, банк вверху). + Надо добавить в центр описание ниши клиента (`aboutSelf`), тогда банки/юрфирмы уедут вниз. + Где: `LiveFindCompetitors::find` (что передаём как `$examples` в `assembler->assemble`) + + `EmbeddingRelevance::rank`. По TDD. +2. **Вычет себя по имени** — «Омега» лезет в свои же конкуренты. Сейчас вычет клиента по домену + `lkomega.ru`, а бренд «Омега» по имени не срезается. Где: `clientKeys` / `mergeCompetitors` вычет + клиента. Добавить имя клиента в ключи вычета. По TDD. +3. **Замер лимита xfetch → параллель 2ГИС + шаг 2** — xfetch сейчас последовательный (2ГИС ~30 + запросов по очереди, ~5 мин). Лимит в коде НЕ зашит — надо ЗАМЕРИТЬ эмпирически (пустить 2/3/4/6 + разом, глянуть 429), потом пул по рубрикам (как EXA) + распараллелить 2 карточки шага 2. Владелец + думает «xfetch пропускает ~3» — проверить. Считать: воркеры очереди × пул ≤ лимит. + +**Известные болячки из последнего прогона (для калибровки):** лезут ~20 банков (Яндекс на «займ под +залог» подмешивает банки — лечится рычагом №1 похожести); канал В нагаллюцинировал «Тор»→torproject.org +(EXA подтвердила мимо); осталась юрфирма «Ресурс», автосалон «С-Авто», комиссионки — тоже уедут вниз +рычагом №1. + +## 4. ФАКТЫ ЖИВОГО ПРОГОНА (данные владельца из карточки) +- Реальный ввод — в БД `autopodbor_runs` #6, тенант 34: регион **29 = Красноярский край** (у приложения + СВОЯ нумерация регионов, 29≠Архангельск!); examples `on.vashinvestor.ru`, `www.caranga.ru`; + about_self «займы под залог авто/спецтехники… физлица… деньги на бизнес» + `lkomega.ru`; федералы ВКЛ. +- Последний прогон на новом коде: **100 конкурентов** (упёрлись в maxCompetitors=100; эталон + `AUTOPODBOR-client-report-top.png` = 72), за 676с. +- Запуск прогона: `scripts/run-step1-live.php` (флаг `autopodbor.real_find` включается в скрипте). +- НЕ полный прогон, проверка дедупа: `scripts/diag-merge-real.php` (100 имён+рубрик зашиты). + +## 5. КЛЮЧИ/ОКРУЖЕНИЕ (секреты только в `app/.env`, в гит НИКОГДА) +- `EXA_API_KEY`, `EXA_CONCURRENCY` (дефолт 5). AITUNNEL: `AITUNNEL_API_KEY` + `aggregator_batch_size`/ + `aggregator_timeout_sec`/`research_model`=sonar-reasoning-pro (владелец: **модель не менять**, чистим + фильтром). Баланс AITUNNEL ~2966₽ (~7 дней) на 01.07. +- xfetch: `XFETCH_API_KEY` (2ГИС). Playwright: node_modules в **корне репо** (`Документация/node_modules`), + `require('playwright')` резолвится из-под `app/scripts/`. +- Тесты: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor tests/Feature/Autopodbor`; PHP `c:/tools/php83/php`. +- diag-скрипты в `app/scripts/diag-*.php`, `probe-*.cjs` — времянки, в гит НЕ идут. + +## 6. КАРТА КОДА +``` +app/Services/Autopodbor/Agent/ + ChannelA/ AitunnelQueryAnalyzer (узкие рубрики) · CategoryScraper+CategoryListingParser (2ГИС xfetch) + YandexListParser (slug+рубрика) · PlaywrightYandexDirectory + scripts/render-yandex-list.cjs + ChannelB/ ChannelBSearch (чистый стоп-лист) · AitunnelResearcher (sonar) · ExaSiteFinder (пул findSites) + Aggregator/ AggregatorFilter (батч если умеет) · AitunnelAggregatorClassifier (батч+чанкинг) + BatchAggregatorClassifier (интерфейс) + Similarity/ EmbeddingRelevance (центр — РЫЧАГ №1) · AitunnelEmbedder + LiveFindCompetitors (оркестратор) · FindCompetitorsAssembler (дедуп→отсев→похожесть→DTO) +AutopodborDedup (rubricVocab, nameKeyMinusWords, slug/phone/site union-find) · AutopodborNormalizer +Jobs/Autopodbor/ Run{Search,Study,Resolve}Job (очередь autopodbor + WithoutOverlapping) +``` + +## 7. ПРОМТ ДЛЯ ВОССТАНОВЛЕНИЯ КОНТЕКСТА (скопировать в новый чат после компакта) + +> Продолжаем движок **«Автоподбор конкурентов»** Лидерры, ШАГ 1. Воркстри `worktree-avtopodbor` (код +> под `app/`). **НЕ прод. Коммит — спросив «эскейп». Пуш — только gitea. Простым языком — владелец не +> программист. Руками 2ГИС/Яндекс не парсить — только сервисы (xfetch/Playwright/EXA).** +> +> 🔴 **ПРОЧИТАЙ ПЕРВЫМ ДЕЛОМ (durable, не по памяти):** +> `docs/superpowers/plans/2026-07-01-AVTOPODBOR-SOSTOYANIE-i-prodolzhenie.md` — полное состояние: что +> сделано, что осталось, карта кода, ключи, факты живого прогона. Тесты `cd app && ./vendor/bin/pest +> tests/Unit/Autopodbor tests/Feature/Autopodbor` должны быть 234/234 зелёные. +> +> **ГДЕ МЫ:** движок шага 1 работает (~100 конкурентов на данных карточки #6, регион 29=Красноярск). +> За сессию ускорен (пул EXA, батч+чанкинг агрегатора, очередь-throttle `autopodbor`+overlap) и почищен +> (узкие рубрики без зонтичных, дедуп по slug Яндекса, дедуп по СЛОВАРЮ рубрик с гардом генериков, дедуп +> ДО отсева агрегаторов). Всё универсально — без зашитых ниш. HEAD gitea — см. §1 файла состояния. +> +> **СЛЕДУЮЩЕЕ (по приоритету, §3 файла):** (1) центр похожести строить из ОПИСАНИЯ ниши клиента, а не +> только из двух доменов-примеров — сейчас проценты сбиты (топ 39%, банк вверху), это ГЛАВНЫЙ рычаг +> чистоты; (2) вычитать себя по ИМЕНИ («Омега» лезет в свои же конкуренты); (3) замерить лимит xfetch +> эмпирически → распараллелить 2ГИС пулом + 2 карточки шага 2. Всё по TDD. +> +> **НЕ ломать/не заужать под нишу:** движок универсален (клиент любой отрасли/региона); слова-категории +> берём из ДАННЫХ (рубрики), не из списков; модель канала В не меняем (sonar-reasoning-pro), хлам чистим +> фильтром/похожестью; лимиты сервисов — на КЛЮЧ, потолок держит очередь. Перед коммитом — «эскейп». + +## 8. ЖЁСТКОЕ +Универсально (не под нишу) · слова-категории из данных, не из списков · лимиты на КЛЮЧ, потолок=очередь · +модель канала В не менять · секреты только в `.env` · НЕ прод · коммит — «эскейп» · пуш — gitea. From d84bc396cf03fc52259b867dc423f6774cb024c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 1 Jul 2026 15:44:43 +0300 Subject: [PATCH 107/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=86=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D1=80=20=D0=BF=D0=BE=D1=85=D0=BE=D0=B6=D0=B5=D1=81=D1=82=D0=B8?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BD=D0=B8=D1=88=D0=B8,=20=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B4=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2-=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D1=80=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../Autopodbor/Agent/LiveFindCompetitors.php | 29 +++++- .../Autopodbor/LiveFindCompetitorsTest.php | 93 +++++++++++++++++++ 2 files changed, 119 insertions(+), 3 deletions(-) diff --git a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php index a2ef0804..1504a447 100644 --- a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php +++ b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php @@ -86,9 +86,9 @@ final class LiveFindCompetitors $candidates = array_merge($aCards, $bCards); $clientKeys = $this->stringList($r->aboutSelf); - $examples = $this->stringList($r->examples); + $profileTexts = $this->clientProfileTexts($r); - return $this->assembler->assemble($candidates, $examples, $clientKeys, $r->includeFederal, $r->maxCompetitors); + return $this->assembler->assemble($candidates, $profileTexts, $clientKeys, $r->includeFederal, $r->maxCompetitors); } /** @@ -144,7 +144,7 @@ final class LiveFindCompetitors { foreach ($aboutSelf as $v) { $v = trim((string) $v); - if ($v !== '' && ! str_contains($v, ' ') && str_contains($v, '.')) { + if ($v !== '' && $this->looksLikeDomain($v)) { return $v; } } @@ -152,6 +152,29 @@ final class LiveFindCompetitors return ''; } + /** + * Тексты клиента для ЦЕНТРА похожести (§12.5): описание ниши из «о себе» + примеры-конкуренты. + * Собственный домен клиента (напр. lkomega.ru) в центр НЕ тащим — он строку-имя, а не смысл ниши, + * и смещал бы центр (рычаг чистоты №1: раньше центр строился ТОЛЬКО из доменов-примеров и был «сбит»). + * + * @return list + */ + private function clientProfileTexts(FindCompetitorsRequest $r): array + { + $niche = array_values(array_filter( + $this->stringList($r->aboutSelf), + fn (string $v): bool => ! $this->looksLikeDomain($v), + )); + + return array_values(array_unique(array_merge($niche, $this->stringList($r->examples)))); + } + + /** Похоже на домен: есть точка, нет пробела (например «lkomega.ru», но не «займы под залог»). */ + private function looksLikeDomain(string $v): bool + { + return ! str_contains($v, ' ') && str_contains($v, '.'); + } + /** @return list */ private function stringList(array $items): array { diff --git a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php index 86678d38..33eab5c8 100644 --- a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php +++ b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php @@ -103,6 +103,58 @@ function leanEngine($pages, ResearcherClient $researcher, HttpFactory $http, arr ); } +/** Сборщик с НАСТОЯЩИМ (не нулевым) эмбеддером — чтобы проверить, ЧТО уходит в центр похожести. */ +function embedAssembler(Embedder $embedder): FindCompetitorsAssembler +{ + $nullClassifier = new class implements AggregatorClassifier + { + public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool + { + return null; + } + }; + + return new FindCompetitorsAssembler( + new AggregatorFilter($nullClassifier), + new AutopodborDedup(new AutopodborNormalizer), + new EmbeddingRelevance($embedder), + ); +} + +/** Детерминированный эмбеддер по подстроке (нет совпадения → нулевой вектор). */ +function needleEmbedder(array $byNeedle): Embedder +{ + return new class($byNeedle) implements Embedder + { + public function __construct(private array $map) {} + + public function embed(array $texts): array + { + return array_map(function (string $t): array { + foreach ($this->map as $needle => $vec) { + if (str_contains($t, $needle)) { + return $vec; + } + } + + return [0.0, 0.0, 0.0]; + }, $texts); + } + }; +} + +function leanEngineEmbed($pages, ResearcherClient $researcher, HttpFactory $http, array $queries, array $yandexRows, Embedder $embedder): LiveFindCompetitors +{ + return new LiveFindCompetitors( + fakeAnalyzer($queries), + new CategoryScraper($pages, new CategoryListingParser, maxPages: 2), + fakeYandex($yandexRows), + new ChannelBSearch($researcher, new ResearcherParser), + new ExaSiteFinder($http), + embedAssembler($embedder), + ); +} + it('сквозной: канал А (2ГИС-список: имя+карточка+сайт) + канал В (федерал с сайтом EXA)', function () { Config::set('services.exa', ['key' => 'k', 'base_url' => 'https://api.exa.ai', 'timeout_sec' => 30]); $http = app(HttpFactory::class); @@ -197,3 +249,44 @@ it('пусто везде → пустой список, без падения', expect($res->competitors)->toBe([]); }); + +it('центр похожести берёт описание ниши (а не только домены-примеры)', function () { + Config::set('services.exa.key', ''); + $http = app(HttpFactory::class); + + // Два конкурента из Яндекса, с рубрикой-описанием. Один — по нише клиента, другой — нет. + $yandex = [ + ['name' => 'ЗалогЦентр', 'card_url' => 'https://yandex.ru/maps/org/zalogcentr/1', 'description' => 'займ под залог авто'], + ['name' => 'Окна Комфорт', 'card_url' => 'https://yandex.ru/maps/org/okna/2', 'description' => 'пластиковые окна'], + ]; + // Ниша «займы под залог авто» ≡ вектор [1,0,0]; окна — ортогональны. + $embedder = needleEmbedder(['залог авто' => [1.0, 0.0, 0.0], 'пластиковые окна' => [0.0, 1.0, 0.0]]); + + // examples ПУСТ — на старом коде центр был бы пуст → все 0%. Ниша в «о себе» должна спасти. + $res = leanEngineEmbed(stubPages([]), fakeResearcher(['[]']), $http, ['ломбард'], $yandex, $embedder) + ->find(new FindCompetitorsRequest(29, [], ['займы под залог авто', 'lkomega.ru'], false, 20)); + + expect(array_column($res->competitors, 'name'))->toBe(['ЗалогЦентр', 'Окна Комфорт']); + $zc = collect($res->competitors)->firstWhere('name', 'ЗалогЦентр'); + $ok = collect($res->competitors)->firstWhere('name', 'Окна Комфорт'); + expect($zc['relevance_pct'])->toBe(100) // по нише — попадание + ->and($ok['relevance_pct'])->toBe(0); // не по нише — мимо +}); + +it('свой домен клиента в «о себе» НЕ тащится в центр (иначе центр смещён)', function () { + Config::set('services.exa.key', ''); + $http = app(HttpFactory::class); + + $yandex = [ + ['name' => 'ЗалогЦентр', 'card_url' => 'https://yandex.ru/maps/org/zalogcentr/1', 'description' => 'займ под залог авто'], + ]; + // Ниша ≡ [1,0,0]; домен клиента lkomega.ru ≡ [0,1,0] (ортогонален). Если домен попадёт в центр — + // центр станет [0.5,0.5,0], косинус к кандидату [1,0,0] упадёт до ~71%. Значит домен вычитаем. + $embedder = needleEmbedder(['залог авто' => [1.0, 0.0, 0.0], 'lkomega.ru' => [0.0, 1.0, 0.0]]); + + $res = leanEngineEmbed(stubPages([]), fakeResearcher(['[]']), $http, ['ломбард'], $yandex, $embedder) + ->find(new FindCompetitorsRequest(29, [], ['займы под залог авто', 'lkomega.ru'], false, 20)); + + $zc = collect($res->competitors)->firstWhere('name', 'ЗалогЦентр'); + expect($zc['relevance_pct'])->toBe(100); // домен клиента не сместил центр +}); From 2834ff0f5a0a4715198f54d87b905b131aad1f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 1 Jul 2026 15:45:11 +0300 Subject: [PATCH 108/417] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=81=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BD=D0=B0=D1=85=D0=BE=D0=B4=D0=BE=D0=BA=20?= =?UTF-8?q?=D1=81=D0=BE=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=D0=BC=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?=E2=80=94=203=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20?= =?UTF-8?q?+=20=D1=81=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D0=B5=20=D0=B4=D1=83?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8,=20=D0=BC=D1=8F=D0=B3=D0=BA=D0=BE=D0=B5=20?= =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=20?= =?UTF-8?q?=D0=B0=D1=80=D1=85=D0=B8=D0=B2,=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=B2=D1=8B=D1=87=D0=B5=D1=82=20=D1=81=D0=B5=D0=B1?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1: опознавалка фирмы по сайту/карточкам, ProposalClassifier new/actualize/archived/hidden, box=archived, bulk удалить всех ранее удалённых. Backend 249/249, front autopodbor 61/61. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/Api/AutopodborController.php | 76 ++++++++-- .../Autopodbor/RunAutopodborSearchJob.php | 49 +++++-- .../Agent/FindCompetitorsAssembler.php | 6 +- .../Autopodbor/CompetitorIdentity.php | 54 +++++++ .../Autopodbor/ProposalClassifier.php | 72 +++++++++ ..._100000_add_archived_box_to_autopodbor.php | 30 ++++ app/resources/js/api/autopodbor.ts | 37 ++++- app/resources/js/stores/autopodborStore.ts | 24 ++- .../screens/FieldProposalsScreen.vue | 115 ++++++++++----- .../screens/FieldWorkspaceScreen.vue | 9 +- app/routes/web.php | 1 + .../AutopodborCompetitorCrudApiTest.php | 8 +- .../Autopodbor/AutopodborProposalsApiTest.php | 27 ++-- .../Feature/Autopodbor/ProposalGroupsTest.php | 137 ++++++++++++++++++ .../AutopodborFieldProposalsScreen.spec.ts | 54 +++++-- .../Autopodbor/CompetitorIdentityTest.php | 38 +++++ .../FindCompetitorsAssemblerTest.php | 4 +- .../Autopodbor/ProposalClassifierTest.php | 78 ++++++++++ db/CHANGELOG_schema.md | 24 ++- db/schema.sql | 6 +- 20 files changed, 737 insertions(+), 112 deletions(-) create mode 100644 app/app/Services/Autopodbor/CompetitorIdentity.php create mode 100644 app/app/Services/Autopodbor/ProposalClassifier.php create mode 100644 app/database/migrations/2026_07_01_100000_add_archived_box_to_autopodbor.php create mode 100644 app/tests/Feature/Autopodbor/ProposalGroupsTest.php create mode 100644 app/tests/Unit/Autopodbor/CompetitorIdentityTest.php create mode 100644 app/tests/Unit/Autopodbor/ProposalClassifierTest.php diff --git a/app/app/Http/Controllers/Api/AutopodborController.php b/app/app/Http/Controllers/Api/AutopodborController.php index b4e788c7..86f60bd9 100644 --- a/app/app/Http/Controllers/Api/AutopodborController.php +++ b/app/app/Http/Controllers/Api/AutopodborController.php @@ -20,6 +20,7 @@ use App\Services\Autopodbor\AutopodborDedup; use App\Services\Autopodbor\AutopodborNormalizer; use App\Services\Autopodbor\AutopodborProjectCreator; use App\Services\Autopodbor\AutopodborRunService; +use App\Services\Autopodbor\ProposalClassifier; use App\Services\Billing\BalancePreflightService; use App\Support\SystemSettings; use Illuminate\Http\JsonResponse; @@ -337,22 +338,77 @@ class AutopodborController extends Controller return response()->json(['error' => 'has_active_projects'], 409); } - $comp->sources()->delete(); - $comp->delete(); + // Мягкое удаление: прячем конкурента в архив (для группы «ранее удалённые» при повторном + // подборе), источники и опознавалки сохраняем — историю не стираем (спека §4). + $comp->update(['box' => 'archived']); return response()->json(null, 204); } - /** GET /api/autopodbor/proposals — конкуренты в ящике «предложения», сорт по похожести. */ - public function proposals(Request $request): JsonResponse + /** + * GET /api/autopodbor/proposals — предложения, разложенные по группам (спека §3, §5.1): + * new (новые) / actualize (на актуализацию) / archived (ранее удалённые). Скрытые полные дубли + * активных фирм не показываем. Сверка «та же фирма» — {@see ProposalClassifier} по опознавалкам. + */ + public function proposals(Request $request, ProposalClassifier $classifier): JsonResponse { - $competitors = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id) - ->where('box', 'proposal') - ->orderByDesc('relevance_pct') - ->orderBy('id') - ->get(); + $tenantId = $request->user()->tenant_id; - return response()->json(['data' => CompetitorResource::collection($competitors)]); + $proposals = AutopodborCompetitor::where('tenant_id', $tenantId)->where('box', 'proposal') + ->orderByDesc('relevance_pct')->orderBy('id')->get(); + $existing = AutopodborCompetitor::where('tenant_id', $tenantId) + ->whereIn('box', ['field', 'archived'])->get(); + + $finds = $proposals->map(fn (AutopodborCompetitor $c) => $this->toClassifyRow($c))->all(); + $state = $existing->map(fn (AutopodborCompetitor $c) => $this->toClassifyRow($c) + ['box' => $c->box])->all(); + + $groups = ['new' => [], 'actualize' => [], 'archived' => []]; + $byId = $proposals->keyBy('id'); + foreach ($classifier->classify($finds, $state) as $row) { + if ($row['group'] === 'hidden') { + continue; // скрытые дубли не показываем + } + $card = (new CompetitorResource($byId->get($row['find']['id'])))->resolve(); + if ($row['group'] === 'actualize') { + $card['matched_id'] = $row['matched_id']; + $card['delta_keys'] = $row['delta_keys']; + } + $groups[$row['group']][] = $card; + } + + return response()->json(['groups' => $groups]); + } + + /** + * POST /api/autopodbor/proposals/dismiss-archived — убрать всю группу «ранее удалённые» разом + * (снова спрятать в архив всплывшие предложения; §5.1). Новые/актуализацию не трогает. + */ + public function dismissArchivedGroup(Request $request, ProposalClassifier $classifier): JsonResponse + { + $tenantId = $request->user()->tenant_id; + + $proposals = AutopodborCompetitor::where('tenant_id', $tenantId)->where('box', 'proposal')->get(); + $existing = AutopodborCompetitor::where('tenant_id', $tenantId)->whereIn('box', ['field', 'archived'])->get(); + + $finds = $proposals->map(fn (AutopodborCompetitor $c) => $this->toClassifyRow($c))->all(); + $state = $existing->map(fn (AutopodborCompetitor $c) => $this->toClassifyRow($c) + ['box' => $c->box])->all(); + + $ids = collect($classifier->classify($finds, $state)) + ->where('group', 'archived')->pluck('find.id')->all(); + + AutopodborCompetitor::where('tenant_id', $tenantId)->whereIn('id', $ids)->update(['box' => 'archived']); + + return response()->json(['dismissed' => count($ids)]); + } + + /** + * Строка конкурента для {@see ProposalClassifier}: id + опознавалки (сайт/карточки). Имя — для UI. + * + * @return array{id:int,name:string,site_url:?string,directory_urls:array} + */ + private function toClassifyRow(AutopodborCompetitor $c): array + { + return ['id' => $c->id, 'name' => $c->name, 'site_url' => $c->site_url, 'directory_urls' => $c->directory_urls ?? []]; } /** PATCH /api/autopodbor/competitors/{id}/box — перенос конкурента предложение↔поле */ diff --git a/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php index 72d13383..3b133950 100644 --- a/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php +++ b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php @@ -12,6 +12,7 @@ use App\Services\Autopodbor\Agent\CompetitorAgent; use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest; use App\Services\Autopodbor\AutopodborChargeService; use App\Services\Autopodbor\AutopodborDedup; +use App\Services\Autopodbor\CompetitorIdentity; use App\Support\SystemSettings; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -52,7 +53,7 @@ class RunAutopodborSearchJob implements ShouldQueue return [new WithoutOverlapping((string) $this->runId)]; } - public function handle(CompetitorAgent $agent, AutopodborDedup $dedup, AutopodborChargeService $charge): void + public function handle(CompetitorAgent $agent, AutopodborDedup $dedup, AutopodborChargeService $charge, CompetitorIdentity $identity): void { $run = AutopodborRun::findOrFail($this->runId); @@ -80,21 +81,45 @@ class RunAutopodborSearchJob implements ShouldQueue $unique = $dedup->dedupCompetitors($res->competitors); - // Сквозной дедуп: убираем конкурентов, уже известных тенанту (в поле или предложениях - // из прошлых прогонов) — иначе повторный подбор плодит дубли карточек. Если после - // фильтра ничего нового не осталось — прогон пустой и НЕ списывается (как и обычное «пусто»). - // Исключаем конкурентов ЭТОГО же прогона (иначе ретрай упавшего прогона схлопнул бы - // собственные результаты в «пусто»). Фильтруем только чужие прогоны и ручных. - $existingKeys = AutopodborCompetitor::where('tenant_id', $run->tenant_id) + // На записи выкидываем ТОЛЬКО полный дубль АКТИВНОЙ фирмы (поле/предложения) — та же + // опознавалка и ни одной новой (это «скрытая» группа, спека §8). Находку, совпавшую с + // АРХИВОМ удалённых, сохраняем — её покажем как «ранее удалён». Деление на группы — + // при показе /proposals. Исключаем свой же прогон (ретрай не должен схлопнуть свои + // результаты). Конкурентов без опознавалок (нет сайта/карточек) не с чем сверять — оставляем. + $activeKeySets = AutopodborCompetitor::where('tenant_id', $run->tenant_id) + ->where('box', '!=', 'archived') ->where(function ($q) use ($run) { $q->where('search_run_id', '!=', $run->id)->orWhereNull('search_run_id'); }) - ->pluck('dedup_key') + ->get(['site_url', 'directory_urls']) + ->map(fn ($c) => array_flip($identity->keys([ + 'site_url' => $c->site_url, + 'directory_urls' => $c->directory_urls ?? [], + ]))) ->all(); - $unique = array_values(array_filter( - $unique, - fn (array $c) => ! in_array($c['dedup_key'], $existingKeys, true), - )); + + $unique = array_values(array_filter($unique, function (array $c) use ($identity, $activeKeySets): bool { + $keys = $identity->keys(['site_url' => $c['site_url'] ?? null, 'directory_urls' => $c['directory_urls'] ?? []]); + if ($keys === []) { + return true; + } + foreach ($activeKeySets as $have) { + $shares = false; + $hasNew = false; + foreach ($keys as $k) { + if (isset($have[$k])) { + $shares = true; + } else { + $hasNew = true; + } + } + if ($shares && ! $hasNew) { + return false; // полный дубль активной фирмы → скрыть + } + } + + return true; + })); if ($unique === []) { $run->update(['status' => 'empty', 'finished_at' => now()]); diff --git a/app/app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php b/app/app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php index 1b7596a8..cfe38b34 100644 --- a/app/app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php +++ b/app/app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php @@ -29,7 +29,7 @@ final class FindCompetitorsAssembler /** * @param array $candidates резолвленные кандидаты (имя/сайт/описание/телефоны/ссылки/is_federal) * @param list $clientExamples тексты-примеры клиента (для похожести) - * @param list $clientKeys имя/сайт клиента (для вычета себя) + * @param list $clientKeys НЕ используется (вычет себя убран, спека §7) — параметр оставлен для совместимости вызова */ public function assemble( array $candidates, @@ -40,7 +40,9 @@ final class FindCompetitorsAssembler ): FindCompetitorsResult { // Склейка/дедуп ПЕРВОЙ — иначе отсев агрегаторов классифицирует дубли-филиалы по многу раз // (реальный случай: gpt-4o-mini залетело 227 позиций с повторами вместо ~90 уникальных). - $merged = $this->dedup->mergeCompetitors($candidates, $clientKeys); + // «Вычет себя» УБРАН (спека §7): свой сайт/пример-конкурент клиент вправе вернуть и поставить + // на прослушку — поэтому clientKeys в склейку НЕ передаём (параметр оставлен для совместимости). + $merged = $this->dedup->mergeCompetitors($candidates); $filtered = $this->aggregatorFilter->filter($merged); if (! $includeFederal) { diff --git a/app/app/Services/Autopodbor/CompetitorIdentity.php b/app/app/Services/Autopodbor/CompetitorIdentity.php new file mode 100644 index 00000000..cd4ded5a --- /dev/null +++ b/app/app/Services/Autopodbor/CompetitorIdentity.php @@ -0,0 +1,54 @@ +` / `d:ya:`). ИМЯ НЕ участвует — клиент называет + * фирму как хочет, совпадение ищем только по сайту/карточкам (спека §3). + */ +final class CompetitorIdentity +{ + public function __construct(private readonly AutopodborNormalizer $norm) {} + + /** + * @param array{site_url?:?string,directory_urls?:array} $competitor + * @return list уникальные ключи-опознавалки (пусто, если ни сайта, ни карточек) + */ + public function keys(array $competitor): array + { + $keys = []; + + $site = $competitor['site_url'] ?? null; + if (is_string($site) && trim($site) !== '') { + $head = $this->norm->domainHead($site); + if ($head !== '') { + $keys[] = 's:'.$head; + } + } + + foreach ($competitor['directory_urls'] ?? [] as $url) { + $k = $this->directoryKey((string) $url); + if ($k !== null) { + $keys[] = $k; + } + } + + return array_values(array_unique($keys)); + } + + /** Ссылка справочника → устойчивый ключ фирмы (id 2ГИС / slug Яндекса) или null. */ + private function directoryKey(string $url): ?string + { + if (preg_match('#/firm/(\d+)#', $url, $m) === 1) { + return 'd:2gis:'.$m[1]; + } + if (preg_match('#/maps/org/([a-z0-9_-]+)#i', $url, $m) === 1) { + return 'd:ya:'.mb_strtolower($m[1]); + } + + return null; + } +} diff --git a/app/app/Services/Autopodbor/ProposalClassifier.php b/app/app/Services/Autopodbor/ProposalClassifier.php new file mode 100644 index 00000000..5c3c3d85 --- /dev/null +++ b/app/app/Services/Autopodbor/ProposalClassifier.php @@ -0,0 +1,72 @@ + $finds + * @param array $existing + * @return list}> + */ + public function classify(array $finds, array $existing): array + { + // Индекс: ключ-опознавалка → список [existing_id, активна ли] (для приоритета активного). + $index = []; + $keysById = []; + foreach ($existing as $e) { + $keys = $this->identity->keys($e); + $keysById[$e['id']] = $keys; + $active = $e['box'] !== 'archived'; + foreach ($keys as $k) { + $index[$k][] = ['id' => $e['id'], 'active' => $active]; + } + } + + $out = []; + foreach ($finds as $find) { + $findKeys = $this->identity->keys($find); + + // Первое активное и первое архивное совпадение по общим ключам. + $matchActive = null; + $matchArchived = null; + foreach ($findKeys as $k) { + foreach ($index[$k] ?? [] as $hit) { + if ($hit['active'] && $matchActive === null) { + $matchActive = $hit['id']; + } + if (! $hit['active'] && $matchArchived === null) { + $matchArchived = $hit['id']; + } + } + } + + if ($matchActive !== null) { + // Дельта = ключи находки, которых нет у совпавшей активной фирмы. + $have = array_flip($keysById[$matchActive] ?? []); + $delta = array_values(array_filter($findKeys, static fn (string $k): bool => ! isset($have[$k]))); + $out[] = [ + 'find' => $find, + 'group' => $delta === [] ? 'hidden' : 'actualize', + 'matched_id' => $matchActive, + 'delta_keys' => $delta, + ]; + } elseif ($matchArchived !== null) { + $out[] = ['find' => $find, 'group' => 'archived', 'matched_id' => $matchArchived, 'delta_keys' => []]; + } else { + $out[] = ['find' => $find, 'group' => 'new', 'matched_id' => null, 'delta_keys' => []]; + } + } + + return $out; + } +} diff --git a/app/database/migrations/2026_07_01_100000_add_archived_box_to_autopodbor.php b/app/database/migrations/2026_07_01_100000_add_archived_box_to_autopodbor.php new file mode 100644 index 00000000..f5110181 --- /dev/null +++ b/app/database/migrations/2026_07_01_100000_add_archived_box_to_autopodbor.php @@ -0,0 +1,30 @@ + { return data.competitors; } -/** Конкуренты в ящике «предложения» (сорт по похожести). */ -export async function fetchProposals(): Promise { - const { data } = await apiClient.get<{ data: CompetitorDto[] }>('/api/autopodbor/proposals'); - return data.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 { + const { data } = await apiClient.get<{ groups: ProposalGroups }>('/api/autopodbor/proposals'); + return data.groups; +} + +/** Убрать всю группу «ранее удалённые» одним махом (снова в архив). */ +export async function dismissArchivedProposals(): Promise { + await apiClient.post('/api/autopodbor/proposals/dismiss-archived'); } export async function setCompetitorBox(id: number, box: Box): Promise { diff --git a/app/resources/js/stores/autopodborStore.ts b/app/resources/js/stores/autopodborStore.ts index 31ddbb7c..3c3d74e7 100644 --- a/app/resources/js/stores/autopodborStore.ts +++ b/app/resources/js/stores/autopodborStore.ts @@ -12,7 +12,8 @@ import { createProjects, fetchRunCompetitors, fetchField, - fetchProposals, + fetchProposalGroups, + dismissArchivedProposals, setCompetitorBox, setSourceBox, updateCompetitor, @@ -32,8 +33,11 @@ import { type SourcePatch, type FieldCompetitorDto, type FieldSourceDto, + type ProposalGroups, } from '../api/autopodbor'; +const EMPTY_GROUPS: ProposalGroups = { new: [], actualize: [], archived: [] }; + /** Задержка между тиками опроса (вынесена для тестируемости). */ export const POLL_MS = 2500; @@ -48,7 +52,7 @@ export const useAutopodborStore = defineStore('autopodbor', () => { const sources = ref([]); const runCompetitors = ref([]); const field = ref([]); - const proposals = ref([]); + const proposalGroups = ref({ ...EMPTY_GROUPS }); const loading = ref(false); // Internal poll handle — not exposed as reactive state. @@ -180,8 +184,15 @@ export const useAutopodborStore = defineStore('autopodbor', () => { field.value = (await fetchField()) ?? []; } - async function loadProposals(): Promise { - proposals.value = (await fetchProposals()) ?? []; + /** Загрузить предложения, разложенные по группам (новые / актуализация / ранее удалённые). */ + async function loadProposalGroups(): Promise { + proposalGroups.value = (await fetchProposalGroups()) ?? { ...EMPTY_GROUPS }; + } + + /** Убрать всю группу «ранее удалённые» разом (снова в архив), затем перезагрузить группы. */ + async function dismissArchivedGroup(): Promise { + await dismissArchivedProposals(); + await loadProposalGroups(); } /** Перенос конкурента предложение↔поле; уход из поля убирает карточку из списка. */ @@ -277,7 +288,7 @@ export const useAutopodborStore = defineStore('autopodbor', () => { sources, runCompetitors, field, - proposals, + proposalGroups, loading, // Actions loadState, @@ -293,7 +304,8 @@ export const useAutopodborStore = defineStore('autopodbor', () => { loadRunCompetitors, // «Конкурентное поле» loadField, - loadProposals, + loadProposalGroups, + dismissArchivedGroup, moveCompetitorToBox, editCompetitor, removeCompetitor, diff --git a/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue b/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue index 7bff402a..391a0236 100644 --- a/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue +++ b/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue @@ -19,9 +19,16 @@ const toast = ref(''); const regions = REGIONS.filter((r) => r.code > 0); const prices = computed(() => store.prices); -const list = computed(() => - [...store.proposals].sort((a, b) => (b.relevance_pct ?? -1) - (a.relevance_pct ?? -1)), -); +// Предложения приходят разложенными по группам (спека §3): новые / на актуализацию / ранее удалённые. +const groups = computed(() => store.proposalGroups); +// Секции показа. «На актуализацию» появится на Стадии 2 (кнопки Добавить/Заменить) — здесь пока +// показываем «новые» и «ранее удалённые». +const sections = computed(() => [ + { key: 'new', title: '🆕 Новые находки', items: groups.value.new as CompetitorDto[] }, + { key: 'archived', title: '♻️ Ранее удалённые', items: groups.value.archived as CompetitorDto[] }, +]); +// Плоский список для выделения и массового переноса в поле (новые + ранее удалённые). +const list = computed(() => [...groups.value.new, ...groups.value.archived]); const allSelected = computed(() => list.value.length > 0 && selected.value.length === list.value.length); function flash(m: string) { @@ -53,10 +60,20 @@ function clearSel() { async function reload() { busy.value = true; try { - await store.loadProposals(); + await store.loadProposalGroups(); + } finally { + busy.value = false; + selected.value = selected.value.filter((id) => list.value.some((c) => c.id === id)); + } +} + +async function dismissArchived() { + busy.value = true; + try { + await store.dismissArchivedGroup(); + flash('Ранее удалённые убраны'); } finally { busy.value = false; - selected.value = selected.value.filter((id) => store.proposals.some((c) => c.id === id)); } } @@ -223,40 +240,51 @@ defineExpose({ list, selected, toggle, toggleAll, moveToField, moveSelected, ope

Соберите конкурентов — найденные появятся здесь, и вы перенесёте нужных в поле.

-
-
-
-
-
- - {{ c.name }} -
-
- {{ c.is_federal ? 'федеральный' : 'региональный' }} - добавлен вручную - предложение -
-
-
-
- {{ c.relevance_pct }}% -
-
похожесть
-
-
+
@@ -363,4 +391,17 @@ defineExpose({ list, selected, toggle, toggleAll, moveToField, moveSelected, ope grid-template-columns: 1fr; } } +.ld-sect { + margin-bottom: 22px; +} +.ld-secthd { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 15px; + font-weight: 700; + color: #012019; + margin: 0 0 10px; +} diff --git a/app/resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue b/app/resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue index 204b35e0..a23541ba 100644 --- a/app/resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue +++ b/app/resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue @@ -22,7 +22,10 @@ const prices = computed(() => store.prices); const competitors = computed(() => [...store.field].sort((a, b) => (b.relevance_pct ?? -1) - (a.relevance_pct ?? -1)), ); -const proposalsCount = computed(() => store.proposals.length); +const proposalsCount = computed(() => { + const g = store.proposalGroups; + return g.new.length + g.actualize.length + g.archived.length; +}); const projectsInWork = computed(() => competitors.value.reduce((a, c) => a + c.counters.projects_in_work, 0)); const allSelected = computed(() => competitors.value.length > 0 && selected.value.length === competitors.value.length); @@ -64,7 +67,7 @@ function openCompetitor(c: FieldCompetitorDto) { async function reload() { busy.value = true; try { - await Promise.all([store.loadField(), store.loadProposals()]); + await Promise.all([store.loadField(), store.loadProposalGroups()]); } finally { busy.value = false; selected.value = selected.value.filter((id) => store.field.some((c) => c.id === id)); @@ -127,7 +130,7 @@ async function runCollect() { include_federal: collect.includeFederal, }); await store.pollRun(run.id); - await store.loadProposals(); + await store.loadProposalGroups(); collect.open = false; flash('Готово: добавлены предложения'); nav.go('field-proposals'); diff --git a/app/routes/web.php b/app/routes/web.php index 1c5c98ea..dad6de01 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -335,6 +335,7 @@ Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/autopo Route::get('/state', 'App\Http\Controllers\Api\AutopodborController@state'); Route::get('/field', 'App\Http\Controllers\Api\AutopodborController@field'); Route::get('/proposals', 'App\Http\Controllers\Api\AutopodborController@proposals'); + Route::post('/proposals/dismiss-archived', 'App\Http\Controllers\Api\AutopodborController@dismissArchivedGroup'); Route::get('/runs/{run}', 'App\Http\Controllers\Api\AutopodborController@run')->where('run', '[0-9]+'); Route::get('/runs/{run}/competitors', 'App\Http\Controllers\Api\AutopodborController@runCompetitors')->where('run', '[0-9]+'); Route::get('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@competitor')->where('competitor', '[0-9]+'); diff --git a/app/tests/Feature/Autopodbor/AutopodborCompetitorCrudApiTest.php b/app/tests/Feature/Autopodbor/AutopodborCompetitorCrudApiTest.php index b80957d2..2233bcb6 100644 --- a/app/tests/Feature/Autopodbor/AutopodborCompetitorCrudApiTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborCompetitorCrudApiTest.php @@ -64,7 +64,7 @@ it('PATCH competitors/{id} — чужой тенант не правит (404)', ->assertStatus(404); }); -it('DELETE competitors/{id} — удаляет конкурента и его источники', function () { +it('DELETE competitors/{id} — прячет конкурента в архив (box=archived), источники сохраняет', function () { [$tenant, $user, $run, $comp] = compCrudSetup(); $src = AutopodborSource::create([ 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, @@ -74,8 +74,10 @@ it('DELETE competitors/{id} — удаляет конкурента и его и $this->actingAs($user)->deleteJson("/api/autopodbor/competitors/{$comp->id}") ->assertStatus(204); - expect(AutopodborCompetitor::find($comp->id))->toBeNull() - ->and(AutopodborSource::find($src->id))->toBeNull(); + // мягкое удаление: строка жива, box='archived', источник не тронут (спека §4) + expect(AutopodborCompetitor::find($comp->id))->not->toBeNull() + ->and($comp->fresh()->box)->toBe('archived') + ->and(AutopodborSource::find($src->id))->not->toBeNull(); }); it('DELETE competitors/{id} — блок, если у источника активный проект (409)', function () { diff --git a/app/tests/Feature/Autopodbor/AutopodborProposalsApiTest.php b/app/tests/Feature/Autopodbor/AutopodborProposalsApiTest.php index c3e98949..2e8d392f 100644 --- a/app/tests/Feature/Autopodbor/AutopodborProposalsApiTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborProposalsApiTest.php @@ -12,7 +12,7 @@ use Tests\Concerns\SharesSupplierPdo; uses(DatabaseTransactions::class, SharesSupplierPdo::class); -it('GET /api/autopodbor/proposals — отдаёт только конкурентов-предложения, сорт по похожести', function () { +it('GET /api/autopodbor/proposals — делит на группы; новые сортируются по похожести', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); DB::statement('SET app.current_tenant_id = '.$tenant->id); @@ -20,27 +20,30 @@ it('GET /api/autopodbor/proposals — отдаёт только конкурен AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Низкая', 'dedup_key' => 'low', 'box' => 'proposal', 'relevance_pct' => 40]); AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Высокая', 'dedup_key' => 'high', 'box' => 'proposal', 'relevance_pct' => 95]); - // в поле — не предложение, не должен попасть + // в поле — не предложение, в группы не попадает AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'В поле', 'dedup_key' => 'fld', 'box' => 'field', 'relevance_pct' => 100]); - $data = $this->actingAs($user)->getJson('/api/autopodbor/proposals') + $groups = $this->actingAs($user)->getJson('/api/autopodbor/proposals') ->assertOk() - ->assertJsonStructure(['data' => [['id', 'name', 'box', 'relevance_pct']]]) - ->json('data'); + ->json('groups'); - expect($data)->toHaveCount(2) - ->and($data[0]['name'])->toBe('Высокая') // сорт по похожести - ->and($data[1]['name'])->toBe('Низкая'); + expect($groups['new'])->toHaveCount(2) + ->and($groups['new'][0]['name'])->toBe('Высокая') // сорт по похожести + ->and($groups['new'][1]['name'])->toBe('Низкая') + ->and($groups['actualize'])->toBe([]) + ->and($groups['archived'])->toBe([]); }); -it('GET /api/autopodbor/proposals — чужой тенант своих не видит', function () { +it('GET /api/autopodbor/proposals — чужой тенант своих не видит (группы пусты)', function () { $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); DB::statement('SET app.current_tenant_id = '.$tenant->id); $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]); AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Чужой', 'dedup_key' => 'a', 'box' => 'proposal']); $other = User::factory()->create(['tenant_id' => Tenant::factory()->create()->id]); - $data = $this->actingAs($other)->getJson('/api/autopodbor/proposals')->assertOk()->json('data'); - expect($data)->toHaveCount(0); + $groups = $this->actingAs($other)->getJson('/api/autopodbor/proposals')->assertOk()->json('groups'); + + expect($groups['new'])->toBe([]) + ->and($groups['actualize'])->toBe([]) + ->and($groups['archived'])->toBe([]); }); diff --git a/app/tests/Feature/Autopodbor/ProposalGroupsTest.php b/app/tests/Feature/Autopodbor/ProposalGroupsTest.php new file mode 100644 index 00000000..544e2692 --- /dev/null +++ b/app/tests/Feature/Autopodbor/ProposalGroupsTest.php @@ -0,0 +1,137 @@ +inner = new FakeCompetitorAgent; + } + + public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult + { + return new FindCompetitorsResult($this->competitors); + } + + public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult + { + return $this->inner->studyCompetitor($r); + } + + public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult + { + return $this->inner->resolveByName($r); + } + }; +} + +function groupsTestSettings(): void +{ + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '300', 'type' => 'decimal']); + SystemSetting::updateOrCreate(['key' => 'autopodbor_max_competitors'], ['value' => '15', 'type' => 'int']); +} + +function mkSearchRun(int $tenantId): AutopodborRun +{ + return AutopodborRun::create([ + 'tenant_id' => $tenantId, 'kind' => 'search', 'status' => 'queued', 'region_code' => 16, + 'params' => ['examples' => [], 'about_self' => ['ломбард'], 'include_federal' => false], + ]); +} + +it('находка, совпавшая с АРХИВОМ, сохраняется в предложения (не прячется на записи)', function () { + Mail::fake(); + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + groupsTestSettings(); + + // В архиве — ранее удалённый «Яричъ» с сайтом yarich.ru + AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'name' => 'Яричъ', 'origin' => 'auto', 'box' => 'archived', + 'site_url' => 'yarich.ru', 'dedup_key' => 'site:yarich.ru', + ]); + + app()->bind(CompetitorAgent::class, fn () => agentReturning([ + ['name' => 'Яричъ', 'site_url' => 'yarich.ru', 'description' => 'ломбард', 'is_federal' => false, + 'directory_urls' => [], 'relevance_pct' => 90, 'provenance' => []], + ])); + + $run = mkSearchRun($tenant->id); + app()->call([new RunAutopodborSearchJob($run->id), 'handle']); + + expect(AutopodborCompetitor::where('tenant_id', $tenant->id)->where('box', 'proposal')->where('name', 'Яричъ')->exists()) + ->toBeTrue(); +}); + +it('находка — полный дубль АКТИВНОЙ фирмы поля — на записи выкидывается (пусто)', function () { + Mail::fake(); + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + groupsTestSettings(); + + AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'name' => 'Яричъ', 'origin' => 'auto', 'box' => 'field', + 'site_url' => 'yarich.ru', 'dedup_key' => 'site:yarich.ru', + ]); + + app()->bind(CompetitorAgent::class, fn () => agentReturning([ + ['name' => 'Яричъ', 'site_url' => 'yarich.ru', 'description' => 'ломбард', 'is_federal' => false, + 'directory_urls' => [], 'relevance_pct' => 90, 'provenance' => []], + ])); + + $run = mkSearchRun($tenant->id); + app()->call([new RunAutopodborSearchJob($run->id), 'handle']); + + expect(AutopodborCompetitor::where('tenant_id', $tenant->id)->where('box', 'proposal')->exists())->toBeFalse() + ->and($run->fresh()->status)->toBe('empty'); +}); + +it('GET /proposals раскладывает по группам; bulk убирает всю группу «ранее удалённые»', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + + // в поле — Alpha (a.ru); в архиве — Beta (b.ru) + AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'Alpha', 'origin' => 'auto', 'box' => 'field', 'site_url' => 'a.ru', 'dedup_key' => 'site:a.ru']); + AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'Beta', 'origin' => 'auto', 'box' => 'archived', 'site_url' => 'b.ru', 'dedup_key' => 'site:b.ru']); + // предложения: Gamma (новый), «Beta заново» (совпал с архивом b.ru → ранее удалён) + $new = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'Gamma', 'origin' => 'auto', 'box' => 'proposal', 'site_url' => 'g.ru', 'dedup_key' => 'site:g.ru']); + $del = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'Beta заново', 'origin' => 'auto', 'box' => 'proposal', 'site_url' => 'b.ru', 'dedup_key' => 'site:b.ru']); + + $groups = $this->actingAs($user)->getJson('/api/autopodbor/proposals')->assertOk()->json('groups'); + expect($groups['new'][0]['name'])->toBe('Gamma') + ->and($groups['archived'][0]['name'])->toBe('Beta заново'); + + // bulk: убрать всю группу «ранее удалённые» + $this->actingAs($user)->postJson('/api/autopodbor/proposals/dismiss-archived')->assertOk(); + + expect(AutopodborCompetitor::find($del->id)->box)->toBe('archived') // ранее удалённый снова в архив + ->and(AutopodborCompetitor::find($new->id)->box)->toBe('proposal'); // новых не трогаем +}); diff --git a/app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts b/app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts index a41da91d..5c009d57 100644 --- a/app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts +++ b/app/tests/Frontend/AutopodborFieldProposalsScreen.spec.ts @@ -7,14 +7,15 @@ import { reactive, ref } from 'vue'; vi.mock('../../resources/js/api/autopodbor'); import FieldProposalsScreen from '../../resources/js/views/autopodbor/screens/FieldProposalsScreen.vue'; import { useAutopodborStore } from '../../resources/js/stores/autopodborStore'; +import type { CompetitorDto, ProposalGroups } from '../../resources/js/api/autopodbor'; const vuetify = createVuetify(); function makeNav() { - return { go: vi.fn(), ctx: reactive({ competitorId: null }), screen: ref('field-proposals') }; + return { go: vi.fn(), ctx: reactive({ competitorId: null as number | null }), screen: ref('field-proposals') }; } -function comp(over: Partial = {}) { +function comp(over: Partial = {}): CompetitorDto { return { id: 1, name: 'Окна', @@ -32,7 +33,12 @@ function comp(over: Partial = {}) { }; } -function mountP(nav: any) { +/** Разложить набор предложений по группам (актуализация/архив пусты, если не заданы). */ +function groups(over: Partial = {}): ProposalGroups { + return { new: [], actualize: [], archived: [], ...over }; +} + +function mountP(nav: ReturnType) { return mount(FieldProposalsScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } }); } @@ -42,14 +48,14 @@ describe('FieldProposalsScreen', () => { vi.clearAllMocks(); }); - it('грузит предложения и показывает карточку-плитку с похожестью и Справочником', async () => { + it('грузит предложения группами и показывает карточку-плитку с похожестью и Справочником', async () => { const store = useAutopodborStore(); - vi.spyOn(store, 'loadProposals').mockImplementation(async () => { - store.proposals = [comp({ id: 1, name: 'Окна Комфорт', relevance_pct: 80 })] as any; + vi.spyOn(store, 'loadProposalGroups').mockImplementation(async () => { + store.proposalGroups = groups({ new: [comp({ id: 1, name: 'Окна Комфорт', relevance_pct: 80 })] }); }); const w = mountP(makeNav()); await new Promise((r) => setTimeout(r, 0)); - expect(store.loadProposals).toHaveBeenCalled(); + expect(store.loadProposalGroups).toHaveBeenCalled(); expect(w.find('.ld-card').exists()).toBe(true); expect(w.text()).toContain('Окна Комфорт'); expect(w.text()).toContain('80'); @@ -60,8 +66,8 @@ describe('FieldProposalsScreen', () => { it('«В поле →» по конкуренту зовёт moveCompetitorToBox(field)', async () => { const store = useAutopodborStore(); - vi.spyOn(store, 'loadProposals').mockImplementation(async () => { - store.proposals = [comp({ id: 7 })] as any; + vi.spyOn(store, 'loadProposalGroups').mockImplementation(async () => { + store.proposalGroups = groups({ new: [comp({ id: 7 })] }); }); const moveSpy = vi.spyOn(store, 'moveCompetitorToBox').mockResolvedValue(); const w = mountP(makeNav()); @@ -74,8 +80,8 @@ describe('FieldProposalsScreen', () => { it('пусто показывает заглушку', async () => { const store = useAutopodborStore(); - vi.spyOn(store, 'loadProposals').mockImplementation(async () => { - store.proposals = [] as any; + vi.spyOn(store, 'loadProposalGroups').mockImplementation(async () => { + store.proposalGroups = groups(); }); const w = mountP(makeNav()); await new Promise((r) => setTimeout(r, 0)); @@ -84,7 +90,7 @@ describe('FieldProposalsScreen', () => { it('«Собрать конкурентов» открывает окно сбора с ценой 300 ₽ (не уходит на старую форму)', async () => { const store = useAutopodborStore(); - vi.spyOn(store, 'loadProposals').mockResolvedValue(); + vi.spyOn(store, 'loadProposalGroups').mockResolvedValue(); store.prices = { search: '300', study: '50' }; const nav = makeNav(); const w = mountP(nav); @@ -99,8 +105,8 @@ describe('FieldProposalsScreen', () => { it('массово переносит выбранных в поле при ≥2', async () => { const store = useAutopodborStore(); - vi.spyOn(store, 'loadProposals').mockImplementation(async () => { - store.proposals = [comp({ id: 1 }), comp({ id: 2 })] as any; + vi.spyOn(store, 'loadProposalGroups').mockImplementation(async () => { + store.proposalGroups = groups({ new: [comp({ id: 1 }), comp({ id: 2 })] }); }); const moveSpy = vi.spyOn(store, 'moveCompetitorToBox').mockResolvedValue(); const w = mountP(makeNav()); @@ -118,8 +124,8 @@ describe('FieldProposalsScreen', () => { it('«Изменить» открывает окно правки карточки конкурента', async () => { const store = useAutopodborStore(); - vi.spyOn(store, 'loadProposals').mockImplementation(async () => { - store.proposals = [comp({ id: 1, name: 'Окна Комфорт' })] as any; + vi.spyOn(store, 'loadProposalGroups').mockImplementation(async () => { + store.proposalGroups = groups({ new: [comp({ id: 1, name: 'Окна Комфорт' })] }); }); const w = mountP(makeNav()); await new Promise((r) => setTimeout(r, 0)); @@ -129,4 +135,20 @@ describe('FieldProposalsScreen', () => { expect(w.find('.ld-ovl').exists()).toBe(true); expect(w.text()).toContain('карточку конкурента'); }); + + it('группа «Ранее удалённые» с кнопкой «Удалить всех» зовёт dismissArchivedGroup', async () => { + const store = useAutopodborStore(); + vi.spyOn(store, 'loadProposalGroups').mockImplementation(async () => { + store.proposalGroups = groups({ archived: [comp({ id: 9, name: 'Бета заново' })] }); + }); + const dismissSpy = vi.spyOn(store, 'dismissArchivedGroup').mockResolvedValue(); + const w = mountP(makeNav()); + await new Promise((r) => setTimeout(r, 0)); + expect(w.text()).toContain('Ранее удалённые'); + expect(w.text()).toContain('ранее удалён'); + const btn = w.findAll('button').find((b) => b.text().includes('Удалить всех')); + await btn!.trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + expect(dismissSpy).toHaveBeenCalled(); + }); }); diff --git a/app/tests/Unit/Autopodbor/CompetitorIdentityTest.php b/app/tests/Unit/Autopodbor/CompetitorIdentityTest.php new file mode 100644 index 00000000..68dbef20 --- /dev/null +++ b/app/tests/Unit/Autopodbor/CompetitorIdentityTest.php @@ -0,0 +1,38 @@ +keys(['site_url' => 'https://www.Yarich.RU/contacts', 'directory_urls' => []])) + ->toBe(['s:yarich.ru']); +}); + +it('ключ карточки 2ГИС — firm id; Яндекс — slug', function () { + $keys = identity()->keys([ + 'site_url' => null, + 'directory_urls' => [ + 'https://2gis.ru/krasnoyarsk/firm/985690700540003', + 'https://yandex.ru/maps/org/yarich/175852236692', + ], + ]); + expect($keys)->toContain('d:2gis:985690700540003')->toContain('d:ya:yarich'); +}); + +it('имя НЕ участвует; без опознавалок — пусто', function () { + expect(identity()->keys(['name' => 'Любое Имя', 'site_url' => null, 'directory_urls' => []])) + ->toBe([]); +}); + +it('пересечение опознавалок = та же фирма (сайт сменился, карточка та же)', function () { + $a = identity()->keys(['site_url' => 'yarich.ru', 'directory_urls' => ['https://2gis.ru/x/firm/111']]); + $b = identity()->keys(['site_url' => 'yarich-new.ru', 'directory_urls' => ['https://2gis.ru/y/firm/111']]); + expect(array_values(array_intersect($a, $b)))->toBe(['d:2gis:111']); +}); diff --git a/app/tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php b/app/tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php index f32d105d..e9f0f57f 100644 --- a/app/tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php +++ b/app/tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php @@ -74,9 +74,9 @@ it('собирает ядро: отсев агрегаторов → слиян $names = array_column($res->competitors, 'name'); expect($names)->not->toContain('Авито') // агрегатор отсеян - ->not->toContain('Мой Бизнес') // клиент вычтен + ->toContain('Мой Бизнес') // «вычет себя» убран (спека §7) — клиент остаётся ->not->toContain('CarMoney'); // федерал исключён (includeFederal=false) - expect($names)->toBe(['АвтоДеньги', 'Окна Комфорт']); // отсортированы по похожести + expect($names)->toBe(['АвтоДеньги', 'Окна Комфорт', 'Мой Бизнес']); // клиент внизу (похожесть 0) $top = $res->competitors[0]; expect($top['name'])->toBe('АвтоДеньги'); diff --git a/app/tests/Unit/Autopodbor/ProposalClassifierTest.php b/app/tests/Unit/Autopodbor/ProposalClassifierTest.php new file mode 100644 index 00000000..91ba7bc5 --- /dev/null +++ b/app/tests/Unit/Autopodbor/ProposalClassifierTest.php @@ -0,0 +1,78 @@ +classify( + finds: [['id' => 1, 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => []]], + existing: [['id' => 9, 'box' => 'field', 'name' => 'B', 'site_url' => 'b.ru', 'directory_urls' => []]], + ); + expect(grp($out, 1)['group'])->toBe('new'); +}); + +it('скрытая — полный дубль активной (те же опознавалки)', function () { + $out = classifier()->classify( + finds: [['id' => 1, 'name' => 'A новое имя', 'site_url' => 'a.ru', 'directory_urls' => []]], + existing: [['id' => 9, 'box' => 'field', 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => []]], + ); + expect(grp($out, 1)['group'])->toBe('hidden'); +}); + +it('на актуализацию — совпала карточка, но новый сайт (дельта не пуста)', function () { + $out = classifier()->classify( + finds: [['id' => 1, 'name' => 'A', 'site_url' => 'a-new.ru', 'directory_urls' => ['https://2gis.ru/x/firm/111']]], + existing: [['id' => 9, 'box' => 'field', 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => ['https://2gis.ru/x/firm/111']]], + ); + $r = grp($out, 1); + expect($r['group'])->toBe('actualize') + ->and($r['matched_id'])->toBe(9) + ->and($r['delta_keys'])->toBe(['s:a-new.ru']); +}); + +it('ранее удалённая — совпала только с архивом', function () { + $out = classifier()->classify( + finds: [['id' => 1, 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => []]], + existing: [['id' => 9, 'box' => 'archived', 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => []]], + ); + $r = grp($out, 1); + expect($r['group'])->toBe('archived')->and($r['matched_id'])->toBe(9); +}); + +it('приоритет активного над архивом', function () { + $out = classifier()->classify( + finds: [['id' => 1, 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => []]], + existing: [ + ['id' => 8, 'box' => 'archived', 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => []], + ['id' => 9, 'box' => 'field', 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => []], + ], + ); + $r = grp($out, 1); + expect($r['group'])->toBe('hidden')->and($r['matched_id'])->toBe(9); +}); + +it('без опознавалок находка всегда новая', function () { + $out = classifier()->classify( + finds: [['id' => 1, 'name' => 'Безымянный', 'site_url' => null, 'directory_urls' => []]], + existing: [['id' => 9, 'box' => 'field', 'name' => 'Безымянный', 'site_url' => null, 'directory_urls' => []]], + ); + expect(grp($out, 1)['group'])->toBe('new'); +}); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 8067b040..3c933f92 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -2,7 +2,29 @@ **Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. -**Файл схемы:** `schema.sql` (текущая версия — v8.59, консолидированная — разворачивает БД с нуля). +**Файл схемы:** `schema.sql` (текущая версия — v8.60, консолидированная — разворачивает БД с нуля). + +## v8.60 (2026-07-01) — Автоподбор: box += 'archived' (мягкое удаление + архив старого сайта) + +Фича «сверка находок со состоянием клиента» (воркстри avtopodbor, НЕ на проде). Удаление конкурента +из предложений/поля становится **мягким**: вместо стирания строка помечается `box='archived'` — это +нужно для группы «ранее удалённые» при повторном подборе (снова нашли то, что клиент удалял). При +актуализации (замена сайта) старый сайт-источник тоже уходит в архив. Поэтому ящик `box` получает +третье значение `archived` у обеих таблиц автоподбора. + +Миграция: `app/database/migrations/2026_07_01_100000_add_archived_box_to_autopodbor.php` +(идемпотентная: `DROP CONSTRAINT IF EXISTS` перед `ADD CONSTRAINT`). + +**Изменено:** + +- **`autopodbor_sources.box`** — CHECK `IN ('proposal','field')` → `IN ('proposal','field','archived')` + (синкано в этот DDL, строка 1447). +- **`autopodbor_competitors.box`** — тот же CHECK в БД расширен миграцией до `archived`; в DDL + `schema.sql` колонка `box` этой таблицы по-прежнему **отложенный хвост** canon-sync (как отмечено в + шапке с v8.59) — при будущем синке добавить `archived` в её CHECK. + +RLS таблиц (`tenant_isolation`) — построчная, CHECK её не меняет. Значений/структуры прочих таблиц +не трогаем. ## v8.59 (2026-06-30) — Автоподбор: богатый провенанс источника + canon-sync инкрементов 29.06 diff --git a/db/schema.sql b/db/schema.sql index 67f2e2ae..b19cbd45 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,6 +1,6 @@ -- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») --- Версия: v8.59 (30.06.2026 — Автоподбор, богатый провенанс источника + canon-sync инкрементов 29.06: autopodbor_sources +3 колонки where_found JSONB (список «где нашли» [{label,url}], подтверждения = длина) / office VARCHAR(255) (адрес филиала из карточки справочника) / confirmations SMALLINT DEFAULT 1 (сортировка «больше подтверждений — выше»), миграция 2026_06_30_120000 (идемпотентная ADD COLUMN, nullable/default — старые строки не переписываются, RLS построчная не меняется). В ТОТ ЖЕ DDL внесены ранее не синканные инкременты 29.06: phone_type VARCHAR(12)+chk (миграция 2026_06_29_120100), box VARCHAR(16) DEFAULT 'proposal'+chk+индекс autopodbor_sources_competitor_box_idx (миграция 2026_06_29_120000). Структурно для autopodbor_sources: +5 колонок (3 новых + 2 catch-up), +1 индекс, +2 CHECK. ⏸ ХВОСТ: box у autopodbor_competitors (+колонка+chk+индекс autopodbor_competitors_tenant_box_idx, та же миграция 2026_06_29_120000) в DDL ещё НЕ синкан — отдельный canon-sync; точная пересверка счётчиков таблиц/индексов/RLS отложена (прецедент v8.54/v8.55). Воркстри-фича avtopodbor, НЕ на проде. RLS/функций/триггеров/партиций без изменений.) +-- Версия: v8.60 (01.07.2026 — Автоподбор: box += 'archived' у autopodbor_sources CHECK (мягкое удаление конкурента + архив старого сайта при актуализации), миграция 2026_07_01_100000; тот же 'archived' в DB у autopodbor_competitors.box — но его CHECK в этом DDL по-прежнему отложенный хвост, см. ниже. Прежняя v8.59, 30.06.2026 — Автоподбор, богатый провенанс источника + canon-sync инкрементов 29.06: autopodbor_sources +3 колонки where_found JSONB (список «где нашли» [{label,url}], подтверждения = длина) / office VARCHAR(255) (адрес филиала из карточки справочника) / confirmations SMALLINT DEFAULT 1 (сортировка «больше подтверждений — выше»), миграция 2026_06_30_120000 (идемпотентная ADD COLUMN, nullable/default — старые строки не переписываются, RLS построчная не меняется). В ТОТ ЖЕ DDL внесены ранее не синканные инкременты 29.06: phone_type VARCHAR(12)+chk (миграция 2026_06_29_120100), box VARCHAR(16) DEFAULT 'proposal'+chk+индекс autopodbor_sources_competitor_box_idx (миграция 2026_06_29_120000). Структурно для autopodbor_sources: +5 колонок (3 новых + 2 catch-up), +1 индекс, +2 CHECK. ⏸ ХВОСТ: box у autopodbor_competitors (+колонка+chk+индекс autopodbor_competitors_tenant_box_idx, та же миграция 2026_06_29_120000) в DDL ещё НЕ синкан — отдельный canon-sync; точная пересверка счётчиков таблиц/индексов/RLS отложена (прецедент v8.54/v8.55). Воркстри-фича avtopodbor, НЕ на проде. RLS/функций/триггеров/партиций без изменений.) -- Базовая версия: v8.58 (28.06.2026 — Автоподбор конкурентов: +3 regular-таблицы autopodbor_runs/competitors/sources, RLS tenant_isolation в харднинг-форме (ENABLE+FORCE+NULLIF), +6 индексов (вкл. 2 UNIQUE dedup: autopodbor_competitor_dedup, autopodbor_source_dedup), +3 RLS-политики. Счётчики: таблиц 79→82 (regular 69→72) / индексов 124→130 / RLS 44→47. Миграции 2026_06_28_100000/100100/100200. Функций/триггеров/партиций без изменений. GRANTs через blanket ON ALL TABLES в db/02_grants.sql — отдельных строк не добавлено.) -- Базовая версия: v8.57 (26.06.2026 — RLS GUC hardening: ВСЕ 44 политики tenant_isolation приведены к NULLIF(current_setting('app.current_tenant_id', true), '')::bigint — устранён класс отказов на Managed PG/PgBouncer, когда GUC app.current_tenant_id пуст ('' → 22P02) либо не задан (→ 42704). Инцидент 26.06: вход в портал падал на резолве users (60 ошибок, все на users). 5 bootstrap-таблиц (users, auth_log, email_verifications, user_recovery_codes, user_sessions) дополнительно получили разрешающую ветку «NULLIF(...) IS NULL OR ...» — читаются/пишутся ДО tenant-контекста на auth-роутах без 'tenant' middleware; при ЗАДАННОМ tenant изоляция НЕ меняется (rls-reviewer APPROVE). Структурно БД НЕ меняется — переписаны только USING/WITH CHECK (счётчики таблиц/индексов/RLS=44/функций/триггеров без изменений). Миграция 2026_06_26_153000_rls_nullif_guc_hardening (идемпотентна). Применена на боевой кластер; lead_charges FORCE RLS сохранён.) -- Базовая версия: v8.56 (26.06.2026 — Путь А, шов C: тело функции audit_block_mutation() пропускает пересчёт hash-цепочки по метке GUC app.audit_rebuild='on' (+ superuser ИЛИ член crm_migrator) ВМЕСТО session_replication_role (superuser-only, недоступен в Yandex Managed PG). Append-only сохранён: без метки UPDATE/DELETE аудита запрещён. Структурно БД НЕ меняется — только тело функции (счётчики таблиц/индексов/RLS/функций/триггеров без изменений, функций по-прежнему 5). Миграция 2026_06_26_140000_audit_block_mutation_guc_rebuild_flag. AuditRebuildChain команда переведена на SET LOCAL app.audit_rebuild в транзакции (Odyssey-safe).) @@ -1441,10 +1441,10 @@ CREATE TABLE autopodbor_sources ( confirmations SMALLINT NOT NULL DEFAULT 1, -- число подтверждений номера (сортировка «больше — выше») dedup_key VARCHAR(255) NOT NULL, created_project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL, - box VARCHAR(16) NOT NULL DEFAULT 'proposal', -- proposal | field («два ящика», §14.1) + box VARCHAR(16) NOT NULL DEFAULT 'proposal', -- proposal | field | archived (архив старого сайта при актуализации) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT autopodbor_sources_phone_type_chk CHECK (phone_type IS NULL OR phone_type IN ('city', 'mobile', 'tollfree')), - CONSTRAINT autopodbor_sources_box_chk CHECK (box IN ('proposal', 'field')) + CONSTRAINT autopodbor_sources_box_chk CHECK (box IN ('proposal', 'field', 'archived')) ); CREATE UNIQUE INDEX autopodbor_source_dedup ON autopodbor_sources (competitor_id, dedup_key); CREATE INDEX ON autopodbor_sources (tenant_id, competitor_id); From aef695e2e2f3cea5f6757aff1e1b252393c37a8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 1 Jul 2026 15:45:37 +0300 Subject: [PATCH 109/417] =?UTF-8?q?style(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20pint-=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=E2=80=94=20=D1=81=D0=BA=D0=BE=D0=B1=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B2,=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=BE=D0=B2,=20?= =?UTF-8?q?=D0=B2=D1=8B=D1=80=D0=B0=D0=B2=D0=BD=D0=B8=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5;=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../Autopodbor/AutopodborChargeService.php | 17 ++--- .../Autopodbor/AutopodborProjectCreator.php | 8 +-- .../Autopodbor/AutopodborRunService.php | 70 +++++++++---------- app/tests/Doubles/EmptyCompetitorAgent.php | 4 +- .../Feature/Autopodbor/AutopodborApiTest.php | 33 ++++----- .../AutopodborChargeServiceTest.php | 10 +-- .../AutopodborCompetitorsSchemaTest.php | 7 +- .../Autopodbor/AutopodborDedupTest.php | 5 +- .../AutopodborJobIdempotencyTest.php | 17 ++--- .../Autopodbor/AutopodborManualApiTest.php | 25 +++---- .../Autopodbor/AutopodborModelsTest.php | 37 +++++----- .../AutopodborProjectCreatorTest.php | 7 +- .../AutopodborRunCompetitorsApiTest.php | 25 +++---- .../Autopodbor/AutopodborRunServiceTest.php | 23 +++--- .../Autopodbor/AutopodborSettingsSeedTest.php | 5 +- .../AutopodborSourcesSchemaTest.php | 7 +- .../RunAutopodborResolveJobTest.php | 33 +++++---- .../Feature/Billing/TopupFlagForkTest.php | 11 +-- .../DeleteSupplierProjectTailGuardTest.php | 1 - .../Supplier/OnlineDeferWindowTest.php | 2 +- .../Unit/Billing/PaymentGatewayModelTest.php | 3 +- .../Unit/Billing/SystemSettingsHelperTest.php | 3 +- app/tests/Unit/Billing/YooKassaDriverTest.php | 3 +- 23 files changed, 188 insertions(+), 168 deletions(-) diff --git a/app/app/Services/Autopodbor/AutopodborChargeService.php b/app/app/Services/Autopodbor/AutopodborChargeService.php index b382396f..b739e7a1 100644 --- a/app/app/Services/Autopodbor/AutopodborChargeService.php +++ b/app/app/Services/Autopodbor/AutopodborChargeService.php @@ -43,6 +43,7 @@ final class AutopodborChargeService $locked->price_rub_charged = '0.00'; $locked->save(); } + return; } @@ -67,15 +68,15 @@ final class AutopodborChargeService // Записываем транзакцию $tx = BalanceTransaction::create([ - 'tenant_id' => $tenant->id, - 'type' => BalanceTransaction::TYPE_AUTOPODBOR_CHARGE, - 'amount_rub' => '-'.$priceRub, - 'amount_leads' => null, - 'balance_rub_after' => $newBalance, + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_AUTOPODBOR_CHARGE, + 'amount_rub' => '-'.$priceRub, + 'amount_leads' => null, + 'balance_rub_after' => $newBalance, 'balance_leads_after' => null, - 'related_type' => AutopodborRun::class, - 'related_id' => $locked->id, - 'created_at' => now(), + 'related_type' => AutopodborRun::class, + 'related_id' => $locked->id, + 'created_at' => now(), ]); // Фиксируем на run идемпотентный маркер diff --git a/app/app/Services/Autopodbor/AutopodborProjectCreator.php b/app/app/Services/Autopodbor/AutopodborProjectCreator.php index e4f107f1..0de8edcd 100644 --- a/app/app/Services/Autopodbor/AutopodborProjectCreator.php +++ b/app/app/Services/Autopodbor/AutopodborProjectCreator.php @@ -28,11 +28,11 @@ final class AutopodborProjectCreator foreach ($sources as $src) { $name = $this->uniqueName($tenantId, $this->displayName($src)); $project = $this->projects->create($tenant, [ - 'name' => $name, - 'signal_type' => $src->signal_type, - 'signal_identifier' => $src->identifier, + 'name' => $name, + 'signal_type' => $src->signal_type, + 'signal_identifier' => $src->identifier, 'daily_limit_target' => $common['daily_limit_target'], - 'regions' => $common['regions'], + 'regions' => $common['regions'], 'delivery_days_mask' => $common['delivery_days_mask'], ]); if (! $launch) { diff --git a/app/app/Services/Autopodbor/AutopodborRunService.php b/app/app/Services/Autopodbor/AutopodborRunService.php index ee599f02..5213f797 100644 --- a/app/app/Services/Autopodbor/AutopodborRunService.php +++ b/app/app/Services/Autopodbor/AutopodborRunService.php @@ -4,20 +4,20 @@ declare(strict_types=1); namespace App\Services\Autopodbor; -use App\Exceptions\Billing\InsufficientBalanceException; use App\Exceptions\Autopodbor\RunInFlightException; +use App\Exceptions\Billing\InsufficientBalanceException; +use App\Jobs\Autopodbor\RunAutopodborResolveJob; use App\Jobs\Autopodbor\RunAutopodborSearchJob; use App\Jobs\Autopodbor\RunAutopodborStudyJob; -use App\Jobs\Autopodbor\RunAutopodborResolveJob; -use App\Models\AutopodborRun; use App\Models\AutopodborCompetitor; +use App\Models\AutopodborRun; use App\Models\Tenant; use App\Support\SystemSettings; final class AutopodborRunService { public function __construct( - private AutopodborNormalizer $normalizer = new AutopodborNormalizer(), + private AutopodborNormalizer $normalizer = new AutopodborNormalizer, ) {} private function assertNoInFlight(int $tenantId, string $kind): void @@ -28,19 +28,19 @@ final class AutopodborRunService ->exists(); if ($exists) { - throw new RunInFlightException(); + throw new RunInFlightException; } } private function priceGate(int $tenantId, string $key): string { - $price = (string) (SystemSettings::get($key) ?? '0'); + $price = (string) (SystemSettings::get($key) ?? '0'); $balance = (string) Tenant::whereKey($tenantId)->value('balance_rub'); if (bccomp($balance, $price, 2) < 0) { throw new InsufficientBalanceException( priceKopecks: (int) bcmul($price, '100', 0), - balanceRub: $balance, + balanceRub: $balance, ); } @@ -58,13 +58,13 @@ final class AutopodborRunService $this->priceGate($tenantId, 'autopodbor_price_search_rub'); $run = AutopodborRun::create([ - 'tenant_id' => $tenantId, - 'kind' => 'search', - 'status' => 'queued', + 'tenant_id' => $tenantId, + 'kind' => 'search', + 'status' => 'queued', 'region_code' => $regionCode, - 'params' => [ - 'examples' => $examples, - 'about_self' => $aboutSelf, + 'params' => [ + 'examples' => $examples, + 'about_self' => $aboutSelf, 'include_federal' => $includeFederal, ], ]); @@ -86,12 +86,12 @@ final class AutopodborRunService $this->priceGate($tenantId, 'autopodbor_price_study_rub'); $run = AutopodborRun::create([ - 'tenant_id' => $tenantId, - 'kind' => 'study', - 'status' => 'queued', - 'region_code' => $comp->searchRun?->region_code, + 'tenant_id' => $tenantId, + 'kind' => 'study', + 'status' => 'queued', + 'region_code' => $comp->searchRun?->region_code, 'competitor_id' => $comp->id, - 'params' => [], + 'params' => [], ]); RunAutopodborStudyJob::dispatch($run->id); @@ -103,7 +103,7 @@ final class AutopodborRunService * Ручное изучение: создаём конкурента origin='manual' и сразу ставим study-прогон * с ЯВНЫМ регионом (у ручного конкурента нет searchRun, откуда взять регион). * - * @param array{name:string, site_url:?string, directory_urls:array} $competitorData + * @param array{name:string, site_url:?string, directory_urls:array} $competitorData */ public function startManualStudy(int $tenantId, array $competitorData, int $regionCode): AutopodborRun { @@ -111,23 +111,23 @@ final class AutopodborRunService $this->priceGate($tenantId, 'autopodbor_price_study_rub'); $comp = AutopodborCompetitor::create([ - 'tenant_id' => $tenantId, - 'search_run_id' => null, - 'name' => $competitorData['name'], - 'origin' => 'manual', - 'relevance_pct' => null, - 'site_url' => $competitorData['site_url'] ?? null, + 'tenant_id' => $tenantId, + 'search_run_id' => null, + 'name' => $competitorData['name'], + 'origin' => 'manual', + 'relevance_pct' => null, + 'site_url' => $competitorData['site_url'] ?? null, 'directory_urls' => $competitorData['directory_urls'] ?? [], - 'dedup_key' => $this->normalizer->competitorKey($competitorData['name'], $competitorData['site_url'] ?? null), + 'dedup_key' => $this->normalizer->competitorKey($competitorData['name'], $competitorData['site_url'] ?? null), ]); $run = AutopodborRun::create([ - 'tenant_id' => $tenantId, - 'kind' => 'study', - 'status' => 'queued', - 'region_code' => $regionCode, + 'tenant_id' => $tenantId, + 'kind' => 'study', + 'status' => 'queued', + 'region_code' => $regionCode, 'competitor_id' => $comp->id, - 'params' => [], + 'params' => [], ]); RunAutopodborStudyJob::dispatch($run->id); @@ -141,11 +141,11 @@ final class AutopodborRunService // resolve бесплатный — без priceGate $run = AutopodborRun::create([ - 'tenant_id' => $tenantId, - 'kind' => 'resolve', - 'status' => 'queued', + 'tenant_id' => $tenantId, + 'kind' => 'resolve', + 'status' => 'queued', 'region_code' => $regionCode, - 'params' => ['name' => $name], + 'params' => ['name' => $name], ]); RunAutopodborResolveJob::dispatch($run->id); diff --git a/app/tests/Doubles/EmptyCompetitorAgent.php b/app/tests/Doubles/EmptyCompetitorAgent.php index 279ccfca..afe3b380 100644 --- a/app/tests/Doubles/EmptyCompetitorAgent.php +++ b/app/tests/Doubles/EmptyCompetitorAgent.php @@ -5,10 +5,10 @@ namespace Tests\Doubles; use App\Services\Autopodbor\Agent\CompetitorAgent; use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest; use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult; -use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest; -use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult; use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest; use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult; +use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest; +use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult; final class EmptyCompetitorAgent implements CompetitorAgent { diff --git a/app/tests/Feature/Autopodbor/AutopodborApiTest.php b/app/tests/Feature/Autopodbor/AutopodborApiTest.php index fb4a11e5..2007ec3e 100644 --- a/app/tests/Feature/Autopodbor/AutopodborApiTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborApiTest.php @@ -2,18 +2,19 @@ declare(strict_types=1); -use App\Models\Tenant; -use App\Models\User; -use App\Models\SystemSetting; -use App\Models\AutopodborRun; use App\Models\AutopodborCompetitor; +use App\Models\AutopodborRun; use App\Models\AutopodborSource; use App\Models\Project; +use App\Models\SystemSetting; +use App\Models\Tenant; +use App\Models\User; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; +use Tests\Concerns\SharesSupplierPdo; -uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); beforeEach(fn () => Queue::fake()); it('GET /api/autopodbor/state — доступность, прогоны, цены', function () { @@ -41,26 +42,26 @@ it('POST /api/autopodbor/search — стартует прогон (201)', functi it('GET /api/autopodbor/competitors/{id} — источники с existing_project_id', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); - DB::statement("SET app.current_tenant_id = ".$tenant->id); - $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'done','region_code'=>16,'params'=>[]]); - $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'name'=>'Окна Комфорт','dedup_key'=>'okna']); - AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>$run->id,'signal_type'=>'site','identifier'=>'okna-komfort.ru','dedup_key'=>'site:okna-komfort.ru']); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]); + $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Окна Комфорт', 'dedup_key' => 'okna']); + AutopodborSource::create(['tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, 'signal_type' => 'site', 'identifier' => 'okna-komfort.ru', 'dedup_key' => 'site:okna-komfort.ru']); $this->actingAs($user)->getJson("/api/autopodbor/competitors/{$comp->id}") ->assertOk() - ->assertJsonStructure(['data'=>['id','name'], 'sources'=>[['id','signal_type','identifier','existing_project_id']]]); + ->assertJsonStructure(['data' => ['id', 'name'], 'sources' => [['id', 'signal_type', 'identifier', 'existing_project_id']]]); }); it('POST /api/autopodbor/projects — создаёт проекты из источников (201)', function () { $tenant = Tenant::factory()->create(['balance_rub' => '500000.00']); $user = User::factory()->create(['tenant_id' => $tenant->id]); - DB::statement("SET app.current_tenant_id = ".$tenant->id); - $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'done','region_code'=>16,'params'=>[]]); - $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'name'=>'Окна Комфорт','dedup_key'=>'okna']); - $s1 = AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>$run->id,'signal_type'=>'site','identifier'=>'okna-komfort.ru','dedup_key'=>'site:okna-komfort.ru']); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]); + $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Окна Комфорт', 'dedup_key' => 'okna']); + $s1 = AutopodborSource::create(['tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, 'signal_type' => 'site', 'identifier' => 'okna-komfort.ru', 'dedup_key' => 'site:okna-komfort.ru']); $this->actingAs($user)->postJson('/api/autopodbor/projects', [ - 'source_ids'=>[$s1->id], 'regions'=>[16], 'daily_limit_target'=>20, 'delivery_days_mask'=>127, 'launch'=>false, + 'source_ids' => [$s1->id], 'regions' => [16], 'daily_limit_target' => 20, 'delivery_days_mask' => 127, 'launch' => false, ])->assertCreated(); - expect(Project::where('tenant_id',$tenant->id)->where('signal_identifier','okna-komfort.ru')->exists())->toBeTrue(); + expect(Project::where('tenant_id', $tenant->id)->where('signal_identifier', 'okna-komfort.ru')->exists())->toBeTrue(); }); diff --git a/app/tests/Feature/Autopodbor/AutopodborChargeServiceTest.php b/app/tests/Feature/Autopodbor/AutopodborChargeServiceTest.php index 55480859..fd3da80d 100644 --- a/app/tests/Feature/Autopodbor/AutopodborChargeServiceTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborChargeServiceTest.php @@ -2,18 +2,20 @@ declare(strict_types=1); +use App\Exceptions\Billing\InsufficientBalanceException; use App\Models\AutopodborRun; use App\Models\BalanceTransaction; use App\Models\Tenant; use App\Services\Autopodbor\AutopodborChargeService; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; +use Tests\Concerns\SharesSupplierPdo; -uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); it('списывает один раз и идемпотентно по run_id', function () { $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); - DB::statement("SET LOCAL app.current_tenant_id = ".$tenant->id); + DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id); $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'running', 'params' => []]); $svc = app(AutopodborChargeService::class); @@ -27,11 +29,11 @@ it('списывает один раз и идемпотентно по run_id', it('не списывает при нехватке баланса (бросает, баланс цел)', function () { $tenant = Tenant::factory()->create(['balance_rub' => '100.00']); - DB::statement("SET LOCAL app.current_tenant_id = ".$tenant->id); + DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id); $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'running', 'params' => []]); expect(fn () => app(AutopodborChargeService::class)->chargeForRun($run, '300.00')) - ->toThrow(\App\Exceptions\Billing\InsufficientBalanceException::class); + ->toThrow(InsufficientBalanceException::class); expect((string) $tenant->fresh()->balance_rub)->toBe('100.00') ->and(BalanceTransaction::where('related_id', $run->id)->count())->toBe(0); }); diff --git a/app/tests/Feature/Autopodbor/AutopodborCompetitorsSchemaTest.php b/app/tests/Feature/Autopodbor/AutopodborCompetitorsSchemaTest.php index 6b82a156..e06f6676 100644 --- a/app/tests/Feature/Autopodbor/AutopodborCompetitorsSchemaTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborCompetitorsSchemaTest.php @@ -1,11 +1,12 @@ hasTable('autopodbor_competitors'))->toBeTrue(); expect(DB::getSchemaBuilder()->hasColumns('autopodbor_competitors', [ - 'id','tenant_id','search_run_id','name','description','is_federal', - 'relevance_pct','origin','site_url','directory_urls','provenance', - 'dedup_key','study_run_id','studied_at','created_at', + 'id', 'tenant_id', 'search_run_id', 'name', 'description', 'is_federal', + 'relevance_pct', 'origin', 'site_url', 'directory_urls', 'provenance', + 'dedup_key', 'study_run_id', 'studied_at', 'created_at', ]))->toBeTrue(); }); diff --git a/app/tests/Feature/Autopodbor/AutopodborDedupTest.php b/app/tests/Feature/Autopodbor/AutopodborDedupTest.php index 997a9ae4..ca5d0dd8 100644 --- a/app/tests/Feature/Autopodbor/AutopodborDedupTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborDedupTest.php @@ -7,12 +7,13 @@ use App\Models\Tenant; use App\Services\Autopodbor\AutopodborDedup; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; +use Tests\Concerns\SharesSupplierPdo; -uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); it('находит существующий проект клиента по типу+идентификатору', function () { $tenant = Tenant::factory()->create(); - DB::statement("SET LOCAL app.current_tenant_id = ".$tenant->id); + DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id); Project::factory()->create(['tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'okna.ru']); $dedup = app(AutopodborDedup::class); diff --git a/app/tests/Feature/Autopodbor/AutopodborJobIdempotencyTest.php b/app/tests/Feature/Autopodbor/AutopodborJobIdempotencyTest.php index f5cc3a96..f8ba873b 100644 --- a/app/tests/Feature/Autopodbor/AutopodborJobIdempotencyTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborJobIdempotencyTest.php @@ -2,20 +2,21 @@ declare(strict_types=1); -use App\Models\Tenant; -use App\Models\SystemSetting; -use App\Models\AutopodborRun; -use App\Models\AutopodborCompetitor; -use App\Models\BalanceTransaction; use App\Jobs\Autopodbor\RunAutopodborSearchJob; +use App\Models\AutopodborCompetitor; +use App\Models\AutopodborRun; +use App\Models\BalanceTransaction; +use App\Models\SystemSetting; +use App\Models\Tenant; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; +use Tests\Concerns\SharesSupplierPdo; -uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); it('повторный запуск SearchJob не плодит конкурентов и не списывает дважды', function () { $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']); SystemSetting::updateOrCreate(['key' => 'autopodbor_max_competitors'], ['value' => '15', 'type' => 'int']); $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'queued', 'region_code' => 16, 'params' => ['examples' => ['okna.ru'], 'about_self' => [], 'include_federal' => true]]); @@ -38,7 +39,7 @@ it('повторный запуск SearchJob не плодит конкурен it('done-прогон при повторном dispatch сразу выходит (top-guard)', function () { $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']); $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => ['examples' => [], 'about_self' => [], 'include_federal' => false]]); app()->call([new RunAutopodborSearchJob($run->id), 'handle']); diff --git a/app/tests/Feature/Autopodbor/AutopodborManualApiTest.php b/app/tests/Feature/Autopodbor/AutopodborManualApiTest.php index 5986360f..dcf6ae02 100644 --- a/app/tests/Feature/Autopodbor/AutopodborManualApiTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborManualApiTest.php @@ -2,17 +2,18 @@ declare(strict_types=1); +use App\Models\AutopodborCompetitor; +use App\Models\AutopodborRun; +use App\Models\AutopodborSource; +use App\Models\SystemSetting; use App\Models\Tenant; use App\Models\User; -use App\Models\SystemSetting; -use App\Models\AutopodborRun; -use App\Models\AutopodborCompetitor; -use App\Models\AutopodborSource; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; +use Tests\Concerns\SharesSupplierPdo; -uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); beforeEach(fn () => Queue::fake()); it('manual-study по сайту создаёт ручного конкурента и study-прогон', function () { @@ -24,7 +25,7 @@ it('manual-study по сайту создаёт ручного конкурен 'site_url' => 'https://okna-komfort-kzn.ru/contacts', 'region_code' => 16, ])->assertCreated()->assertJsonPath('data.kind', 'study'); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); expect(AutopodborCompetitor::where('tenant_id', $tenant->id)->where('origin', 'manual')->exists())->toBeTrue(); }); @@ -40,9 +41,9 @@ it('manual-study без названия и без сайта → 422', function it('sources/manual добавляет источник изученному конкуренту', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); - DB::statement("SET app.current_tenant_id = ".$tenant->id); - $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'done','region_code'=>16,'params'=>[]]); - $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'study_run_id'=>$run->id,'studied_at'=>now(),'name'=>'Окна Комфорт','origin'=>'auto','dedup_key'=>'okna']); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]); + $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'study_run_id' => $run->id, 'studied_at' => now(), 'name' => 'Окна Комфорт', 'origin' => 'auto', 'dedup_key' => 'okna']); $this->actingAs($user)->postJson('/api/autopodbor/sources/manual', [ 'competitor_id' => $comp->id, 'raw' => 'okna-komfort.ru', @@ -54,9 +55,9 @@ it('sources/manual добавляет источник изученному ко it('sources/manual телефоном создаёт call-источник', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); - DB::statement("SET app.current_tenant_id = ".$tenant->id); - $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'done','region_code'=>16,'params'=>[]]); - $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'study_run_id'=>$run->id,'studied_at'=>now(),'name'=>'Окна','origin'=>'auto','dedup_key'=>'okna2']); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]); + $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'study_run_id' => $run->id, 'studied_at' => now(), 'name' => 'Окна', 'origin' => 'auto', 'dedup_key' => 'okna2']); $this->actingAs($user)->postJson('/api/autopodbor/sources/manual', [ 'competitor_id' => $comp->id, 'raw' => '+7 (843) 200-11-22', diff --git a/app/tests/Feature/Autopodbor/AutopodborModelsTest.php b/app/tests/Feature/Autopodbor/AutopodborModelsTest.php index 474bf3cc..c7f6c7c7 100644 --- a/app/tests/Feature/Autopodbor/AutopodborModelsTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborModelsTest.php @@ -1,41 +1,42 @@ create(); - DB::statement("SET LOCAL app.current_tenant_id = " . $tenant->id); + DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id); $run = AutopodborRun::create([ - 'tenant_id' => $tenant->id, - 'kind' => 'search', - 'status' => 'done', + 'tenant_id' => $tenant->id, + 'kind' => 'search', + 'status' => 'done', 'region_code' => 16, - 'params' => [], + 'params' => [], ]); $comp = AutopodborCompetitor::create([ - 'tenant_id' => $tenant->id, - 'search_run_id' => $run->id, - 'name' => 'Окна Комфорт', - 'dedup_key' => 'okna-komfort', - 'relevance_pct' => 100, + 'tenant_id' => $tenant->id, + 'search_run_id' => $run->id, + 'name' => 'Окна Комфорт', + 'dedup_key' => 'okna-komfort', + 'relevance_pct' => 100, ]); $src = AutopodborSource::create([ - 'tenant_id' => $tenant->id, - 'competitor_id' => $comp->id, - 'study_run_id' => $run->id, - 'signal_type' => 'site', - 'identifier' => 'okna-komfort.ru', - 'dedup_key' => 'site:okna-komfort.ru', + 'tenant_id' => $tenant->id, + 'competitor_id' => $comp->id, + 'study_run_id' => $run->id, + 'signal_type' => 'site', + 'identifier' => 'okna-komfort.ru', + 'dedup_key' => 'site:okna-komfort.ru', ]); expect($comp->sources()->count())->toBe(1) diff --git a/app/tests/Feature/Autopodbor/AutopodborProjectCreatorTest.php b/app/tests/Feature/Autopodbor/AutopodborProjectCreatorTest.php index 6c6d78d0..fe161679 100644 --- a/app/tests/Feature/Autopodbor/AutopodborProjectCreatorTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborProjectCreatorTest.php @@ -11,13 +11,14 @@ use App\Services\Autopodbor\AutopodborProjectCreator; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; +use Tests\Concerns\SharesSupplierPdo; -uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); it('создаёт проекты из выбранных источников с общими настройками', function () { Queue::fake(); $tenant = Tenant::factory()->create(['balance_rub' => '500000.00']); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16]); $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'Окна Комфорт', 'dedup_key' => 'okna']); $s1 = AutopodborSource::create(['tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, 'signal_type' => 'site', 'identifier' => 'okna-komfort.ru', 'dedup_key' => 'site:okna-komfort.ru']); @@ -35,7 +36,7 @@ it('создаёт проекты из выбранных источников it('разруливает коллизию одинаковых имён суффиксом', function () { Queue::fake(); $tenant = Tenant::factory()->create(['balance_rub' => '500000.00']); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16]); $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'Окна Комфорт', 'dedup_key' => 'okna2']); $s1 = AutopodborSource::create(['tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, 'signal_type' => 'site', 'identifier' => 'a.ru', 'dedup_key' => 'site:a.ru']); diff --git a/app/tests/Feature/Autopodbor/AutopodborRunCompetitorsApiTest.php b/app/tests/Feature/Autopodbor/AutopodborRunCompetitorsApiTest.php index c339d85a..a40e8542 100644 --- a/app/tests/Feature/Autopodbor/AutopodborRunCompetitorsApiTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborRunCompetitorsApiTest.php @@ -2,28 +2,29 @@ declare(strict_types=1); +use App\Models\AutopodborCompetitor; +use App\Models\AutopodborRun; use App\Models\Tenant; use App\Models\User; -use App\Models\AutopodborRun; -use App\Models\AutopodborCompetitor; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; +use Tests\Concerns\SharesSupplierPdo; -uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); beforeEach(fn () => Queue::fake()); it('GET /api/autopodbor/runs/{run}/competitors отдаёт конкурентов прогона по убыванию релевантности', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); - DB::statement("SET app.current_tenant_id = ".$tenant->id); - $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'done','region_code'=>16,'params'=>[]]); - AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'name'=>'Б','relevance_pct'=>60,'dedup_key'=>'b']); - AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'name'=>'А','relevance_pct'=>100,'dedup_key'=>'a']); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]); + AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'Б', 'relevance_pct' => 60, 'dedup_key' => 'b']); + AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'name' => 'А', 'relevance_pct' => 100, 'dedup_key' => 'a']); $resp = $this->actingAs($user)->getJson("/api/autopodbor/runs/{$run->id}/competitors") ->assertOk() - ->assertJsonStructure(['data' => [['id','name','relevance_pct']]]); + ->assertJsonStructure(['data' => [['id', 'name', 'relevance_pct']]]); $data = $resp->json('data'); expect($data[0]['name'])->toBe('А')->and($data[1]['name'])->toBe('Б'); // 100 раньше 60 }); @@ -31,10 +32,10 @@ it('GET /api/autopodbor/runs/{run}/competitors отдаёт конкуренто it('study-прогон в RunResource отдаёт competitor_id', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); - DB::statement("SET app.current_tenant_id = ".$tenant->id); - $search = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'done','region_code'=>16,'params'=>[]]); - $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$search->id,'name'=>'Окна','dedup_key'=>'o']); - $study = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'done','region_code'=>16,'competitor_id'=>$comp->id,'params'=>[]]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $search = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done', 'region_code' => 16, 'params' => []]); + $comp = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'search_run_id' => $search->id, 'name' => 'Окна', 'dedup_key' => 'o']); + $study = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'competitor_id' => $comp->id, 'params' => []]); $this->actingAs($user)->getJson("/api/autopodbor/runs/{$study->id}") ->assertOk() diff --git a/app/tests/Feature/Autopodbor/AutopodborRunServiceTest.php b/app/tests/Feature/Autopodbor/AutopodborRunServiceTest.php index b26ef6c9..40fc4066 100644 --- a/app/tests/Feature/Autopodbor/AutopodborRunServiceTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborRunServiceTest.php @@ -2,23 +2,24 @@ declare(strict_types=1); -use App\Models\AutopodborRun; -use App\Models\AutopodborCompetitor; +use App\Exceptions\Autopodbor\RunInFlightException; +use App\Exceptions\Billing\InsufficientBalanceException; +use App\Jobs\Autopodbor\RunAutopodborResolveJob; +use App\Jobs\Autopodbor\RunAutopodborSearchJob; use App\Models\SystemSetting; use App\Models\Tenant; use App\Services\Autopodbor\AutopodborRunService; -use App\Jobs\Autopodbor\RunAutopodborSearchJob; -use App\Jobs\Autopodbor\RunAutopodborResolveJob; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; +use Tests\Concerns\SharesSupplierPdo; -uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); it('стартует search, создаёт queued-прогон и ставит джобу', function () { Queue::fake(); $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']); $run = app(AutopodborRunService::class)->startSearch($tenant->id, 16, ['okna.ru'], [], true); @@ -30,30 +31,30 @@ it('стартует search, создаёт queued-прогон и ставит it('не стартует второй in-flight search того же tenant', function () { Queue::fake(); $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']); $svc = app(AutopodborRunService::class); $svc->startSearch($tenant->id, 16, ['okna.ru'], [], true); expect(fn () => $svc->startSearch($tenant->id, 16, ['okna.ru'], [], true)) - ->toThrow(\App\Exceptions\Autopodbor\RunInFlightException::class); + ->toThrow(RunInFlightException::class); }); it('гейтит по балансу: нехватка на цену search → InsufficientBalanceException', function () { Queue::fake(); $tenant = Tenant::factory()->create(['balance_rub' => '100.00']); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']); expect(fn () => app(AutopodborRunService::class)->startSearch($tenant->id, 16, ['okna.ru'], [], true)) - ->toThrow(\App\Exceptions\Billing\InsufficientBalanceException::class); + ->toThrow(InsufficientBalanceException::class); Queue::assertNotPushed(RunAutopodborSearchJob::class); }); it('startResolve бесплатный: стартует даже при нулевом балансе, ставит resolve-джобу', function () { Queue::fake(); $tenant = Tenant::factory()->create(['balance_rub' => '0.00']); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); $run = app(AutopodborRunService::class)->startResolve($tenant->id, 'Окна Комфорт', 16); diff --git a/app/tests/Feature/Autopodbor/AutopodborSettingsSeedTest.php b/app/tests/Feature/Autopodbor/AutopodborSettingsSeedTest.php index 369aada7..faad10b1 100644 --- a/app/tests/Feature/Autopodbor/AutopodborSettingsSeedTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborSettingsSeedTest.php @@ -1,5 +1,6 @@ toBeFalse() // default OFF ->and(SystemSettings::get('autopodbor_max_competitors'))->toBe('15') - ->and(\App\Models\SystemSetting::whereKey('autopodbor_price_search_rub')->exists())->toBeTrue() - ->and(\App\Models\SystemSetting::whereKey('autopodbor_price_study_rub')->exists())->toBeTrue(); + ->and(SystemSetting::whereKey('autopodbor_price_search_rub')->exists())->toBeTrue() + ->and(SystemSetting::whereKey('autopodbor_price_study_rub')->exists())->toBeTrue(); }); diff --git a/app/tests/Feature/Autopodbor/AutopodborSourcesSchemaTest.php b/app/tests/Feature/Autopodbor/AutopodborSourcesSchemaTest.php index 2be90d80..75bb32a7 100644 --- a/app/tests/Feature/Autopodbor/AutopodborSourcesSchemaTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborSourcesSchemaTest.php @@ -1,12 +1,13 @@ hasTable('autopodbor_sources'))->toBeTrue(); expect(DB::getSchemaBuilder()->hasColumns('autopodbor_sources', [ - 'id','tenant_id','competitor_id','study_run_id','signal_type','identifier', - 'phone_kind','provenance_url','provenance_label','dedup_key','created_project_id','created_at', + 'id', 'tenant_id', 'competitor_id', 'study_run_id', 'signal_type', 'identifier', + 'phone_kind', 'provenance_url', 'provenance_label', 'dedup_key', 'created_project_id', 'created_at', // §14: богатый провенанс — список «где нашли», офис, число подтверждений - 'where_found','office','confirmations', + 'where_found', 'office', 'confirmations', ]))->toBeTrue(); }); diff --git a/app/tests/Feature/Autopodbor/RunAutopodborResolveJobTest.php b/app/tests/Feature/Autopodbor/RunAutopodborResolveJobTest.php index 522f22aa..f2fd6353 100644 --- a/app/tests/Feature/Autopodbor/RunAutopodborResolveJobTest.php +++ b/app/tests/Feature/Autopodbor/RunAutopodborResolveJobTest.php @@ -2,24 +2,27 @@ declare(strict_types=1); -use App\Models\AutopodborRun; -use App\Models\AutopodborCompetitor; -use App\Models\Tenant; use App\Jobs\Autopodbor\RunAutopodborResolveJob; +use App\Models\AutopodborCompetitor; +use App\Models\AutopodborRun; +use App\Models\Tenant; +use App\Services\Autopodbor\Agent\CompetitorAgent; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; +use Tests\Concerns\SharesSupplierPdo; +use Tests\Doubles\EmptyCompetitorAgent; -uses(DatabaseTransactions::class, \Tests\Concerns\SharesSupplierPdo::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); it('резолв по названию: кандидаты с origin=resolve, status=done, без списания', function () { $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); $run = AutopodborRun::create([ - 'tenant_id' => $tenant->id, - 'kind' => 'resolve', - 'status' => 'queued', + 'tenant_id' => $tenant->id, + 'kind' => 'resolve', + 'status' => 'queued', 'region_code' => 16, - 'params' => ['name' => 'Окна Комфорт'], + 'params' => ['name' => 'Окна Комфорт'], ]); app()->call([new RunAutopodborResolveJob($run->id), 'handle']); @@ -31,15 +34,15 @@ it('резолв по названию: кандидаты с origin=resolve, st }); it('пустой резолв: status=empty без списания', function () { - app()->bind(\App\Services\Autopodbor\Agent\CompetitorAgent::class, \Tests\Doubles\EmptyCompetitorAgent::class); + app()->bind(CompetitorAgent::class, EmptyCompetitorAgent::class); $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); - DB::statement("SET app.current_tenant_id = ".$tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); $run = AutopodborRun::create([ - 'tenant_id' => $tenant->id, - 'kind' => 'resolve', - 'status' => 'queued', + 'tenant_id' => $tenant->id, + 'kind' => 'resolve', + 'status' => 'queued', 'region_code' => 16, - 'params' => ['name' => 'Несуществующая Фирма XYZ'], + 'params' => ['name' => 'Несуществующая Фирма XYZ'], ]); app()->call([new RunAutopodborResolveJob($run->id), 'handle']); diff --git a/app/tests/Feature/Billing/TopupFlagForkTest.php b/app/tests/Feature/Billing/TopupFlagForkTest.php index eba8dd09..b1af00e7 100644 --- a/app/tests/Feature/Billing/TopupFlagForkTest.php +++ b/app/tests/Feature/Billing/TopupFlagForkTest.php @@ -40,12 +40,12 @@ it('флаг ВКЛ — возвращает confirmation_url, баланс не ); $legalEntity = LegalEntity::create([ - 'code' => 'test_le_fork_' . uniqid(), 'name' => 'ООО Тест Форк', 'legal_form' => 'OOO', + 'code' => 'test_le_fork_'.uniqid(), 'name' => 'ООО Тест Форк', 'legal_form' => 'OOO', 'inn' => '7700000001', ]); PaymentGateway::create([ - 'code' => 'yookassa_fork_' . uniqid(), 'name' => 'ЮKassa', 'driver' => 'yookassa', + 'code' => 'yookassa_fork_'.uniqid(), 'name' => 'ЮKassa', 'driver' => 'yookassa', 'legal_entity_id' => $legalEntity->id, 'config' => '', 'is_active' => true, 'accepts_methods' => ['card'], 'min_amount_rub' => '100.00', @@ -53,10 +53,11 @@ it('флаг ВКЛ — возвращает confirmation_url, баланс не $user = actingTenantUser(); // OnlineTopupService — final class: мокаем через instance-binding в контейнер. - $fakeService = new class { - public function start(mixed ...$args): \App\Services\Billing\Gateway\CreatePaymentResult + $fakeService = new class + { + public function start(mixed ...$args): CreatePaymentResult { - return new \App\Services\Billing\Gateway\CreatePaymentResult('pay_x', 'https://yoomoney.ru/checkout/pay_x'); + return new CreatePaymentResult('pay_x', 'https://yoomoney.ru/checkout/pay_x'); } }; app()->instance(OnlineTopupService::class, $fakeService); diff --git a/app/tests/Feature/Supplier/DeleteSupplierProjectTailGuardTest.php b/app/tests/Feature/Supplier/DeleteSupplierProjectTailGuardTest.php index 257ea58c..6e2b7729 100644 --- a/app/tests/Feature/Supplier/DeleteSupplierProjectTailGuardTest.php +++ b/app/tests/Feature/Supplier/DeleteSupplierProjectTailGuardTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use App\Jobs\Supplier\DeleteSupplierProjectJob; -use App\Models\Project; use App\Models\SupplierProject; use App\Models\Tenant; use App\Services\Supplier\SupplierPortalClient; diff --git a/app/tests/Feature/Supplier/OnlineDeferWindowTest.php b/app/tests/Feature/Supplier/OnlineDeferWindowTest.php index aef424eb..fda0d58f 100644 --- a/app/tests/Feature/Supplier/OnlineDeferWindowTest.php +++ b/app/tests/Feature/Supplier/OnlineDeferWindowTest.php @@ -86,7 +86,7 @@ it('вне окна (до 18:00) онлайн НЕ откладывает — и try { (new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class)); - } catch (\Throwable) { + } catch (Throwable) { // Онлайн-путь может бросить retry при неполном портал-ответе — нам важна только defer-очередь. } diff --git a/app/tests/Unit/Billing/PaymentGatewayModelTest.php b/app/tests/Unit/Billing/PaymentGatewayModelTest.php index 3292d431..c278316a 100644 --- a/app/tests/Unit/Billing/PaymentGatewayModelTest.php +++ b/app/tests/Unit/Billing/PaymentGatewayModelTest.php @@ -4,8 +4,9 @@ declare(strict_types=1); use App\Models\PaymentGateway; use Illuminate\Support\Facades\Crypt; +use Tests\TestCase; -uses(Tests\TestCase::class); +uses(TestCase::class); it('расшифровывает config в массив через accessor', function () { $gw = new PaymentGateway; diff --git a/app/tests/Unit/Billing/SystemSettingsHelperTest.php b/app/tests/Unit/Billing/SystemSettingsHelperTest.php index 68381445..813688ef 100644 --- a/app/tests/Unit/Billing/SystemSettingsHelperTest.php +++ b/app/tests/Unit/Billing/SystemSettingsHelperTest.php @@ -5,8 +5,9 @@ declare(strict_types=1); use App\Support\SystemSettings; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; +use Tests\TestCase; -uses(Tests\TestCase::class, DatabaseTransactions::class); +uses(TestCase::class, DatabaseTransactions::class); it('читает bool-флаг из system_settings', function () { DB::table('system_settings')->updateOrInsert( diff --git a/app/tests/Unit/Billing/YooKassaDriverTest.php b/app/tests/Unit/Billing/YooKassaDriverTest.php index ad4a1209..5af7fee9 100644 --- a/app/tests/Unit/Billing/YooKassaDriverTest.php +++ b/app/tests/Unit/Billing/YooKassaDriverTest.php @@ -6,8 +6,9 @@ use App\Models\PaymentGateway; use App\Services\Billing\Gateway\YooKassaDriver; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Http; +use Tests\TestCase; -uses(Tests\TestCase::class); +uses(TestCase::class); function fakeGateway(): PaymentGateway { From f8ac614aa485131529e56916b6fddbf1a183a4f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 1 Jul 2026 15:47:12 +0300 Subject: [PATCH 110/417] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80):=20=D1=81=D0=BF=D0=B5=D0=BA?= =?UTF-8?q?=D0=B0=20+=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=84=D0=B8=D1=87?= =?UTF-8?q?=D0=B8=20=D1=81=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=BE=D0=BA=20+=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F=20Sta?= =?UTF-8?q?ge=201=20=D0=B8=20=D0=BF=D1=80=D0=BE=D0=BC=D1=82=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...PODBOR-STAGE1-SOSTOYANIE-i-prodolzhenie.md | 124 ++ ...07-01-avtopodbor-sverka-nakhodok-gruppy.md | 1054 +++++++++++++++++ ...vtopodbor-sverka-nakhodok-gruppy-design.md | 186 +++ 3 files changed, 1364 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-01-AVTOPODBOR-STAGE1-SOSTOYANIE-i-prodolzhenie.md create mode 100644 docs/superpowers/plans/2026-07-01-avtopodbor-sverka-nakhodok-gruppy.md create mode 100644 docs/superpowers/specs/2026-07-01-avtopodbor-sverka-nakhodok-gruppy-design.md diff --git a/docs/superpowers/plans/2026-07-01-AVTOPODBOR-STAGE1-SOSTOYANIE-i-prodolzhenie.md b/docs/superpowers/plans/2026-07-01-AVTOPODBOR-STAGE1-SOSTOYANIE-i-prodolzhenie.md new file mode 100644 index 00000000..8c892a3e --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-AVTOPODBOR-STAGE1-SOSTOYANIE-i-prodolzhenie.md @@ -0,0 +1,124 @@ +# 🔧 Автоподбор «сверка находок» — СОСТОЯНИЕ Stage 1 + промт продолжения (01.07.2026, вечер) + +> Воркстри `worktree-avtopodbor` (код под `app/`). **НЕ прод. Коммит — «эскейп». Пуш — только gitea. +> Простым языком — владелец не программист. Руками 2ГИС/Яндекс не парсить — только сервисы.** + +--- + +## 0. ОДНОЙ СТРОКОЙ +Фича «сверка находок со состоянием клиента»: предложения делятся на **3 группы** (новые / на +актуализацию / ранее удалённые) + скрытые полные дубли; удаление стало **мягким** (архив); «вычет +себя» убран. **Stage 1 (данные+классификация+показ) — ГОТОВ и зелёный.** Осталась Stage 2 +(актуализация: Добавить/Заменить → проект). Плюс отдельный «центр похожести по нише» — тоже закоммичен. + +## 1. КОММИТЫ ЭТОЙ СЕССИИ (gitea, ветка `worktree-avtopodbor`, поверх `68341e55`) +- `fc4e4bac` — **центр похожести из описания ниши** (не только домены-примеры). `LiveFindCompetitors` + строит центр эмбеддингов из ниши «о себе» + примеров; свой домен клиента в центр не тащим. По TDD. +- `6d48503a` — **Stage 1 сверки находок** (главный коммит фичи): опознавалка фирмы, классификатор, + архив, /proposals группы, bulk, убран вычет себя, фронт 3 секции. +- `5a861e0e` — **style: pint** — форматные правки прошлой сессии (скобки конструкторов, импорты, + выравнивание); логика без изменений. +- (docs-коммит с этим файлом — см. git log). + +## 2. ✅ ЧТО СДЕЛАНО (Stage 1, детально) — всё по TDD +**Данные:** +- `box` += `'archived'` у `autopodbor_competitors` и `autopodbor_sources` (миграция + `2026_07_01_100000_add_archived_box_to_autopodbor.php`; schema.sql sources CHECK + CHANGELOG v8.60; + competitors box в schema.sql — отложенный хвост, помечено). Удаление конкурента + (`AutopodborController::destroyCompetitor`) → `box='archived'` вместо стирания; источники сохраняем; + гард активного проекта остаётся. + +**Логика (новые сервисы, чистые, офлайн-тестируемые):** +- `CompetitorIdentity::keys()` — опознавалки фирмы: `s:<домен>` (голова домена сайта) + `d:2gis:` / + `d:ya:` (карточки справочника). **Имя НЕ участвует.** Тест `CompetitorIdentityTest` 4/4. +- `ProposalClassifier::classify(finds, existing)` — делит на `new|actualize|archived|hidden`. «Та же + фирма» = пересеклись опознавалки. Приоритет активного (поле/предложения) над архивом. Дельта = + новые опознавалки находки. Тест `ProposalClassifierTest` 6/6. + +**Прогон на записи (`RunAutopodborSearchJob`):** старый молчаливый фильтр «против ВСЕГО известного» +заменён на узкий — выкидываем ТОЛЬКО полный дубль **активной** фирмы; совпавших с **архивом** +сохраняем (для «ранее удалён»). Без опознавалок — оставляем. Тест `ProposalGroupsTest` (2 ingest-кейса). + +**API (`AutopodborController`, `routes/web.php`):** +- `GET /api/autopodbor/proposals` → `{groups:{new,actualize,archived}}` (было `{data}`). Скрытые не + отдаём. Для actualize-карточки кладём `matched_id`+`delta_keys`. +- `POST /api/autopodbor/proposals/dismiss-archived` → bulk «убрать всю группу ранее удалённых» в архив. +- Тесты `AutopodborProposalsApiTest` (переписан под группы) + `ProposalGroupsTest` (группы+bulk). + +**Сборщик (`FindCompetitorsAssembler`):** «вычет себя» убран — `mergeCompetitors($candidates)` без +clientKeys (параметр оставлен для совместимости). Тест `FindCompetitorsAssemblerTest` обновлён. + +**Фронт:** +- `api/autopodbor.ts`: типы `ProposalGroups`/`ActualizeCard`/`ActualizeMatched`, `fetchProposalGroups`, + `dismissArchivedProposals`; `Box` += `'archived'`. Удалён мёртвый `fetchProposals`. +- `stores/autopodborStore.ts`: `proposalGroups` + `loadProposalGroups` + `dismissArchivedGroup`; + удалены мёртвые `proposals`/`loadProposals`. +- `FieldProposalsScreen.vue`: секции **🆕 Новые** и **♻️ Ранее удалённые** (с кнопкой **«Удалить всех»**). + Секция «На актуализацию» — на Stage 2 (пока не рендерится). Карточка архива — бейдж «ранее удалён». +- `FieldWorkspaceScreen.vue`: счётчик предложений и сбор переведены на группы. +- Тест `AutopodborFieldProposalsScreen.spec.ts` переписан под группы + тест «Удалить всех». + +**Проверки Stage 1:** backend `pest tests/Unit/Autopodbor tests/Feature/Autopodbor` = **249/249**; +front autopodbor `vitest tests/Frontend/Autopodbor` = **61/61**; ESLint чист; type-check — моих ошибок +нет (2 предсуществующие в FieldCompetitorScreen/FieldWorkspaceScreen — не мои, vue-tsc CI-only). Pint чист. +⚠️ В ПОЛНОМ фронт-наборе 22 падения в 10 ЧУЖИХ файлах (Deals/Projects/Kanban/Settings/ErrorView…) — +**предсуществующие, не мои** (проверено: про RequestId/IncidentId). + +## 3. 🔴 ЧТО ОСТАЛОСЬ — Stage 2 (актуализация) + хвост +План: `docs/superpowers/plans/2026-07-01-avtopodbor-sverka-nakhodok-gruppy.md`, задачи **Task 8–9**. +Спека: `docs/superpowers/specs/2026-07-01-avtopodbor-sverka-nakhodok-gruppy-design.md`. + +- **Task 8 — актуализация.** Endpoint `POST /api/autopodbor/competitors/{field}/actualize` + `{action:add|replace, new_site}` → заводит источник типа `site` у фирмы-в-поле, возвращает `source_id`. + `replace` при активном проекте по старому сайту → 409 `manage_via_project`; без активного — старые + сайт-источники в архив. Обогатить `/proposals` actualize-элементы полем `matched` (её сайты+карточки). + Фронт: секция «На актуализацию» — дельта-карточка: **все текущие и новые сайты/карточки ССЫЛКАМИ, + «проверьте перед решением»**, кнопки **Добавить/Заменить** → «Создать проект? Да» → **существующая + форма `CreateScreen`** с новым источником выбранным. Имя фирмы берём из ПОЛЯ (не перезатираем). +- **Task 9** — дописать тест replace-с-активным-проектом (409), финальный полный прогон. + +**Хвост вне этой фичи (общий список движка, из прошлого файла состояния):** замерить лимит xfetch +эмпирически → распараллелить 2ГИС + 2 карточки шага 2 (владелец: «не забудь»). + +## 4. КЛЮЧЕВЫЕ ФАКТЫ / ГРАБЛИ (для быстрого старта) +- Тест-БД `liderra_testing`; миграции гнать `DB_DATABASE=liderra_testing c:/tools/php83/php artisan migrate --force`. +- RLS-паттерн feature-тестов: `uses(DatabaseTransactions::class, SharesSupplierPdo::class)` + + `DB::statement('SET app.current_tenant_id = '.$tenant->id)`; фабрики `Tenant`/`User`. +- Прогон тестов: `cd app && c:/tools/php83/php vendor/bin/pest tests/Unit/Autopodbor tests/Feature/Autopodbor`; + фронт `npx vitest run tests/Frontend/Autopodbor`; Pint `vendor/bin/pint `; PHP `c:/tools/php83/php`. +- `FakeCompetitorAgent` — `final` (в тестах подставной агент делать композицией, см. `ProposalGroupsTest`). +- Роуты автоподбора — в `app/routes/web.php` (prefix `/api/autopodbor`), стиль строк-контроллеров. +- Форма создания проекта — `CreateScreen.vue` (работает по `nav.ctx.selectedSourceIds`); карточка + конкурента — `DetailScreen.vue`; страница предложений — `FieldProposalsScreen.vue`. +- Diag-скрипты `app/scripts/diag-*.php`, `probe-*.cjs`, `run-step1-live.php` — времянки, в гит НЕ идут + (зашиты данные/риск PII). Лежат на диске. + +## 5. ЖЁСТКОЕ +НЕ прод · коммит — «эскейп» · пуш — gitea · движок универсален, не под нишу · слова-категории из +данных · имя не участвует в сверке, имя из поля сохраняем · модель канала В (sonar-reasoning-pro) не +менять · секреты только в `.env`. + +## 6. ПРОМТ ДЛЯ ВОССТАНОВЛЕНИЯ КОНТЕКСТА (скопировать в новый чат после компакта) + +> Продолжаем фичу **«Автоподбор — сверка находок»** Лидерры. Воркстри `worktree-avtopodbor` (код под +> `app/`). **НЕ прод. Коммит — спросив «эскейп». Пуш — только gitea. Простым языком — владелец не +> программист.** +> +> 🔴 **ПРОЧИТАЙ ПЕРВЫМ ДЕЛОМ (durable, не по памяти):** +> `docs/superpowers/plans/2026-07-01-AVTOPODBOR-STAGE1-SOSTOYANIE-i-prodolzhenie.md` (это состояние), +> план `docs/superpowers/plans/2026-07-01-avtopodbor-sverka-nakhodok-gruppy.md`, спека +> `docs/superpowers/specs/2026-07-01-avtopodbor-sverka-nakhodok-gruppy-design.md`. +> +> **ГДЕ МЫ:** Stage 1 (3 группы предложений: новые/актуализация/ранее удалённые + скрытые дубли, +> мягкое удаление в архив, убран вычет себя) — ГОТОВ, закоммичен (`fc4e4bac` центр-по-нише, +> `6d48503a` Stage 1, `5a861e0e` style pint) и запушен на gitea. Backend 249/249, front autopodbor 61/61. +> +> **СЛЕДУЮЩЕЕ — Stage 2 (Task 8–9 плана): актуализация.** Endpoint `actualize` (add/replace new_site → +> источник, replace при активном проекте → 409), обогатить /proposals полем `matched`, фронт-секция +> «На актуализацию»: дельта-карточка со всеми текущими+новыми сайтами ССЫЛКАМИ («проверьте перед +> решением»), кнопки Добавить/Заменить → «Создать проект?» → форма `CreateScreen`. Имя из поля сохраняем. +> Всё по TDD. CHECK у sources.box уже допускает 'archived'. +> +> **НЕ ломать:** движок универсален (клиент любой отрасли); имя не участвует в сверке; модель канала В +> не менять; секреты только в `.env`. Перед коммитом — «эскейп». Хвост вне фичи: замер лимита xfetch +> → распараллелить 2ГИС/шаг 2. diff --git a/docs/superpowers/plans/2026-07-01-avtopodbor-sverka-nakhodok-gruppy.md b/docs/superpowers/plans/2026-07-01-avtopodbor-sverka-nakhodok-gruppy.md new file mode 100644 index 00000000..16362a90 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-avtopodbor-sverka-nakhodok-gruppy.md @@ -0,0 +1,1054 @@ +# Автоподбор — сверка находок (3 группы + скрытые дубли) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Перестать молча прятать известных конкурентов при повторном подборе — раскладывать предложения на группы (новые / на актуализацию / ранее удалённые), скрывая только полные дубли, и дать поток «Добавить/Заменить» сайт → проект. + +**Architecture:** Опознавалка фирмы (`CompetitorIdentity`) = голова домена сайта + ключи карточек справочника (2ГИС firm id / Яндекс slug), имя не участвует. Чистый `ProposalClassifier` делит находки относительно состояния клиента (поле/предложения/архив). Удаление становится мягким (`box='archived'`). Классификация — на показе; поисковый прогон на записи выкидывает только полные дубли активных фирм. + +**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16 (RLS, box CHECK), Pest 4, Vue 3 + Vuetify (экраны на «сыром» CSS, паттерн существующих screens). + +**Спека:** [docs/superpowers/specs/2026-07-01-avtopodbor-sverka-nakhodok-gruppy-design.md](../specs/2026-07-01-avtopodbor-sverka-nakhodok-gruppy-design.md) + +**Команда прогона тестов (везде ниже):** +`cd app && c:/tools/php83/php vendor/bin/pest tests/Unit/Autopodbor tests/Feature/Autopodbor` +Форматтер после кода: `cd app && c:/tools/php83/php vendor/bin/pint ` + +--- + +## Карта файлов + +**Стадия 1 — данные + классификация + показ (независимо выкатываема):** +- Create: `app/database/migrations/2026_07_01_100000_add_archived_box_to_autopodbor_competitors.php` — `box` CHECK += `archived` у `autopodbor_competitors` И `autopodbor_sources`. +- Modify: `db/schema.sql` (оба CHECK box) + `db/CHANGELOG_schema.md` (запись). +- Create: `app/app/Services/Autopodbor/CompetitorIdentity.php` — опознавалки фирмы (сайт+карточки, без имени). +- Create: `app/app/Services/Autopodbor/ProposalClassifier.php` — деление на группы + дельта. +- Modify: `app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php` — фильтр «только полный дубль активной». +- Modify: `app/app/Http/Controllers/Api/AutopodborController.php` — `destroyCompetitor` → архив; `proposals` → группы; bulk «удалить всех» архивной группы. +- Modify: `app/app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php` + `LiveFindCompetitors.php` — убрать вычет себя. +- Modify: `app/resources/js/api/autopodbor.ts`, `app/resources/js/stores/autopodborStore.ts`, `app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue` — 3 секции + bulk. +- Tests: `app/tests/Unit/Autopodbor/CompetitorIdentityTest.php`, `ProposalClassifierTest.php`, `app/tests/Feature/Autopodbor/ProposalGroupsTest.php`, `CompetitorArchiveTest.php`. + +**Стадия 2 — актуализация (Добавить/Заменить → проект):** +- Modify: `AutopodborController.php` — `actualize(field, {action, new_site})`. +- Modify: `FieldProposalsScreen.vue` — дельта-карточка со ссылками + кнопки → «создать проект?» → форма. +- Tests: `app/tests/Feature/Autopodbor/CompetitorActualizeTest.php`. + +--- + +# СТАДИЯ 1 + +### Task 1: Мягкое удаление — `box='archived'` + +**Files:** +- Create: `app/database/migrations/2026_07_01_100000_add_archived_box_to_autopodbor_competitors.php` +- Modify: `db/schema.sql` (constraint `autopodbor_competitors_box_chk`), `db/CHANGELOG_schema.md` +- Test: `app/tests/Feature/Autopodbor/CompetitorArchiveTest.php` + +- [ ] **Step 1: Failing feature-тест** — удаление помечает архивом, не стирает. + +`app/tests/Feature/Autopodbor/CompetitorArchiveTest.php`: +```php +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $tenant->id]); + + return [$tenant, $user]; +} + +it('удаление конкурента прячет его в архив (box=archived), строка жива', function () { + [$tenant, $user] = actingTenantUser(); + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'name' => 'Яричъ', 'origin' => 'auto', + 'box' => 'proposal', 'site_url' => 'yarich.ru', 'dedup_key' => 'site:yarich.ru', + ]); + + $this->actingAs($user)->deleteJson("/api/autopodbor/competitors/{$comp->id}") + ->assertNoContent(); + + $fresh = AutopodborCompetitor::withoutGlobalScopes()->find($comp->id); + expect($fresh)->not->toBeNull() + ->and($fresh->box)->toBe('archived'); +}); +``` + +- [ ] **Step 2: Прогнать — падает** (пока удаление стирает + CHECK не пускает 'archived'). + +Run: `cd app && c:/tools/php83/php vendor/bin/pest tests/Feature/Autopodbor/CompetitorArchiveTest.php` +Expected: FAIL (строка удалена / CHECK violation). + +- [ ] **Step 3: Миграция — расширить CHECK box у ОБЕИХ таблиц.** + +Расширяем `box IN ('archived')` и у `autopodbor_competitors`, и у `autopodbor_sources` — их CHECK добавляли вместе (миграция `2026_06_29_120000_add_box_to_autopodbor`), а Стадия 2 (Task 8 «Заменить») архивирует старый сайт-**источник**. + +`app/database/migrations/2026_07_01_100000_add_archived_box_to_autopodbor_competitors.php`: +```php +json(['error' => 'has_active_projects'], 409); + } + + // Мягкое удаление: прячем в архив (для группы «ранее удалённые» при повторном подборе), + // источники и опознавалки сохраняем. §14: не стираем историю. + $comp->update(['box' => 'archived']); + + return response()->json(null, 204); +``` +(Убрать строки `$comp->sources()->delete(); $comp->delete();`.) + +- [ ] **Step 6: Прогон миграций стенда + тест — зелёный.** + +Run: `cd app && c:/tools/php83/php artisan migrate --database=pgsql && c:/tools/php83/php vendor/bin/pest tests/Feature/Autopodbor/CompetitorArchiveTest.php` +Expected: PASS. (Если стенд поднимается иначе — см. память `feedback-prod-full-test-isolated-db`.) + +- [ ] **Step 7: Скрыть архив из поля/предложений.** + +Убедиться, что выборки `field()` и `proposals()` уже фильтруют по `box` (`'field'`/`'proposal'`) — да, фильтруют, архив не попадёт. Доп. правок не нужно; зафиксировать проверкой существующих тестов автоподбора: `cd app && c:/tools/php83/php vendor/bin/pest tests/Feature/Autopodbor`. + +- [ ] **Step 8: Pint + commit.** + +```bash +cd app && c:/tools/php83/php vendor/bin/pint app/Http/Controllers/Api/AutopodborController.php +git add app/database/migrations/2026_07_01_100000_add_archived_box_to_autopodbor_competitors.php app/app/Http/Controllers/Api/AutopodborController.php db/schema.sql db/CHANGELOG_schema.md app/tests/Feature/Autopodbor/CompetitorArchiveTest.php +git commit -m "feat(автоподбор): мягкое удаление конкурента (box=archived) вместо стирания" +``` + +--- + +### Task 2: `CompetitorIdentity` — опознавалки фирмы (сайт + карточки, без имени) + +**Files:** +- Create: `app/app/Services/Autopodbor/CompetitorIdentity.php` +- Test: `app/tests/Unit/Autopodbor/CompetitorIdentityTest.php` + +- [ ] **Step 1: Failing unit-тест.** + +`app/tests/Unit/Autopodbor/CompetitorIdentityTest.php`: +```php +keys(['site_url' => 'https://www.Yarich.RU/contacts', 'directory_urls' => []])) + ->toBe(['s:yarich.ru']); +}); + +it('ключ карточки 2ГИС — firm id; Яндекс — slug', function () { + $keys = identity()->keys([ + 'site_url' => null, + 'directory_urls' => [ + 'https://2gis.ru/krasnoyarsk/firm/985690700540003', + 'https://yandex.ru/maps/org/yarich/175852236692', + ], + ]); + expect($keys)->toContain('d:2gis:985690700540003')->toContain('d:ya:yarich'); +}); + +it('имя НЕ участвует; без опознавалок — пусто', function () { + expect(identity()->keys(['name' => 'Любое Имя', 'site_url' => null, 'directory_urls' => []])) + ->toBe([]); +}); + +it('пересечение опознавалок = та же фирма', function () { + $a = identity()->keys(['site_url' => 'yarich.ru', 'directory_urls' => ['https://2gis.ru/x/firm/111']]); + $b = identity()->keys(['site_url' => 'yarich-new.ru', 'directory_urls' => ['https://2gis.ru/y/firm/111']]); + expect(array_intersect($a, $b))->toBe(['d:2gis:111']); // сайт сменился, карточка та же +}); +``` + +- [ ] **Step 2: Прогнать — падает** (класса нет). + +Run: `cd app && c:/tools/php83/php vendor/bin/pest tests/Unit/Autopodbor/CompetitorIdentityTest.php` +Expected: FAIL «Class CompetitorIdentity not found». + +- [ ] **Step 3: Реализация.** + +`app/app/Services/Autopodbor/CompetitorIdentity.php`: +```php +` / `d:ya:`). ИМЯ НЕ участвует — клиент называет + * фирму как хочет, совпадение ищем только по сайту/карточкам (спека §3). + */ +final class CompetitorIdentity +{ + public function __construct(private readonly AutopodborNormalizer $norm) {} + + /** + * @param array{site_url?:?string,directory_urls?:array} $competitor + * @return list уникальные ключи-опознавалки (пусто, если ни сайта, ни карточек) + */ + public function keys(array $competitor): array + { + $keys = []; + + $site = $competitor['site_url'] ?? null; + if (is_string($site) && trim($site) !== '') { + $head = $this->norm->domainHead($site); + if ($head !== '') { + $keys[] = 's:'.$head; + } + } + + foreach ($competitor['directory_urls'] ?? [] as $url) { + $k = $this->directoryKey((string) $url); + if ($k !== null) { + $keys[] = $k; + } + } + + return array_values(array_unique($keys)); + } + + /** Ссылка справочника → устойчивый ключ фирмы (id 2ГИС / slug Яндекса) или null. */ + private function directoryKey(string $url): ?string + { + if (preg_match('#/firm/(\d+)#', $url, $m) === 1) { + return 'd:2gis:'.$m[1]; + } + if (preg_match('#/maps/org/([a-z0-9_-]+)#i', $url, $m) === 1) { + return 'd:ya:'.mb_strtolower($m[1]); + } + + return null; + } +} +``` + +- [ ] **Step 4: Прогнать — зелёный.** + +Run: `cd app && c:/tools/php83/php vendor/bin/pest tests/Unit/Autopodbor/CompetitorIdentityTest.php` +Expected: PASS (4 теста). + +- [ ] **Step 5: Pint + commit.** + +```bash +cd app && c:/tools/php83/php vendor/bin/pint app/Services/Autopodbor/CompetitorIdentity.php +git add app/Services/Autopodbor/CompetitorIdentity.php app/tests/Unit/Autopodbor/CompetitorIdentityTest.php +git commit -m "feat(автоподбор): CompetitorIdentity — опознавалки фирмы (сайт+карточки, без имени)" +``` + +--- + +### Task 3: `ProposalClassifier` — деление на группы + дельта + +**Files:** +- Create: `app/app/Services/Autopodbor/ProposalClassifier.php` +- Test: `app/tests/Unit/Autopodbor/ProposalClassifierTest.php` + +Контракт: `classify(array $finds, array $existing): array`. +- `$finds` — находки-предложения: `['id'=>int,'name'=>string,'site_url'=>?string,'directory_urls'=>array]`. +- `$existing` — уже известные: то же + `'box'=>'field'|'proposal'|'archived'`. +- Возвращает по каждой находке `['find'=>find, 'group'=>'new'|'actualize'|'archived'|'hidden', 'matched_id'=>?int, 'delta_keys'=>list]`. +- Приоритет: активное совпадение (field/proposal) важнее архивного. Дельта = ключи находки, которых нет у совпавшей активной фирмы; пусто → hidden, иначе actualize. + +- [ ] **Step 1: Failing unit-тест.** + +`app/tests/Unit/Autopodbor/ProposalClassifierTest.php`: +```php +classify( + finds: [['id' => 1, 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => []]], + existing: [['id' => 9, 'box' => 'field', 'name' => 'B', 'site_url' => 'b.ru', 'directory_urls' => []]], + ); + expect(grp($out, 1)['group'])->toBe('new'); +}); + +it('скрытая — полный дубль активной (те же опознавалки)', function () { + $out = classifier()->classify( + finds: [['id' => 1, 'name' => 'A новое имя', 'site_url' => 'a.ru', 'directory_urls' => []]], + existing: [['id' => 9, 'box' => 'field', 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => []]], + ); + expect(grp($out, 1)['group'])->toBe('hidden'); +}); + +it('на актуализацию — совпала карточка, но новый сайт (дельта не пуста)', function () { + $out = classifier()->classify( + finds: [['id' => 1, 'name' => 'A', 'site_url' => 'a-new.ru', 'directory_urls' => ['https://2gis.ru/x/firm/111']]], + existing: [['id' => 9, 'box' => 'field', 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => ['https://2gis.ru/x/firm/111']]], + ); + $r = grp($out, 1); + expect($r['group'])->toBe('actualize') + ->and($r['matched_id'])->toBe(9) + ->and($r['delta_keys'])->toBe(['s:a-new.ru']); // новый сайт — дельта +}); + +it('ранее удалённая — совпала только с архивом', function () { + $out = classifier()->classify( + finds: [['id' => 1, 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => []]], + existing: [['id' => 9, 'box' => 'archived', 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => []]], + ); + $r = grp($out, 1); + expect($r['group'])->toBe('archived')->and($r['matched_id'])->toBe(9); +}); + +it('приоритет активного над архивом', function () { + $out = classifier()->classify( + finds: [['id' => 1, 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => []]], + existing: [ + ['id' => 8, 'box' => 'archived', 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => []], + ['id' => 9, 'box' => 'field', 'name' => 'A', 'site_url' => 'a.ru', 'directory_urls' => []], + ], + ); + $r = grp($out, 1); + expect($r['group'])->toBe('hidden')->and($r['matched_id'])->toBe(9); // активное перевесило +}); +``` + +- [ ] **Step 2: Прогнать — падает.** + +Run: `cd app && c:/tools/php83/php vendor/bin/pest tests/Unit/Autopodbor/ProposalClassifierTest.php` +Expected: FAIL «Class ProposalClassifier not found». + +- [ ] **Step 3: Реализация.** + +`app/app/Services/Autopodbor/ProposalClassifier.php`: +```php + $finds + * @param array $existing + * @return list}> + */ + public function classify(array $finds, array $existing): array + { + // Индекс: ключ-опознавалка → список [existing_id, активна ли] (для приоритета активного). + $index = []; + $keysById = []; + foreach ($existing as $e) { + $keys = $this->identity->keys($e); + $keysById[$e['id']] = $keys; + $active = $e['box'] !== 'archived'; + foreach ($keys as $k) { + $index[$k][] = ['id' => $e['id'], 'active' => $active]; + } + } + + $out = []; + foreach ($finds as $find) { + $findKeys = $this->identity->keys($find); + + // Собираем совпавших: отдельно активных и архивных. + $matchActive = null; + $matchArchived = null; + foreach ($findKeys as $k) { + foreach ($index[$k] ?? [] as $hit) { + if ($hit['active'] && $matchActive === null) { + $matchActive = $hit['id']; + } + if (! $hit['active'] && $matchArchived === null) { + $matchArchived = $hit['id']; + } + } + } + + if ($matchActive !== null) { + // Дельта = ключи находки, которых нет у совпавшей активной фирмы. + $have = array_flip($keysById[$matchActive] ?? []); + $delta = array_values(array_filter($findKeys, static fn (string $k): bool => ! isset($have[$k]))); + $out[] = [ + 'find' => $find, + 'group' => $delta === [] ? 'hidden' : 'actualize', + 'matched_id' => $matchActive, + 'delta_keys' => $delta, + ]; + } elseif ($matchArchived !== null) { + $out[] = ['find' => $find, 'group' => 'archived', 'matched_id' => $matchArchived, 'delta_keys' => []]; + } else { + $out[] = ['find' => $find, 'group' => 'new', 'matched_id' => null, 'delta_keys' => []]; + } + } + + return $out; + } +} +``` + +- [ ] **Step 4: Прогнать — зелёный.** + +Run: `cd app && c:/tools/php83/php vendor/bin/pest tests/Unit/Autopodbor/ProposalClassifierTest.php` +Expected: PASS (5 тестов). + +- [ ] **Step 5: Pint + commit.** + +```bash +cd app && c:/tools/php83/php vendor/bin/pint app/Services/Autopodbor/ProposalClassifier.php +git add app/Services/Autopodbor/ProposalClassifier.php app/tests/Unit/Autopodbor/ProposalClassifierTest.php +git commit -m "feat(автоподбор): ProposalClassifier — 3 группы + скрытые дубли по опознавалкам" +``` + +--- + +### Task 4: Прогон на записи — выкидывать только полный дубль активной фирмы + +**Files:** +- Modify: `app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php:88-97` +- Test: `app/tests/Feature/Autopodbor/ProposalGroupsTest.php` (часть A) + +- [ ] **Step 1: Failing feature-тест** — находка, совпавшая с архивом, СОХРАНЯЕТСЯ (раньше молча выкидывалась). + +`app/tests/Feature/Autopodbor/ProposalGroupsTest.php`: +```php +c); + } + }; +} + +it('находка, совпавшая с АРХИВОМ, сохраняется в предложения (не прячется на записи)', function () { + $tenant = Tenant::factory()->create(); + DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $tenant->id]); + + // В архиве — ранее удалённый «Яричъ» с сайтом yarich.ru + AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'name' => 'Яричъ', 'origin' => 'auto', 'box' => 'archived', + 'site_url' => 'yarich.ru', 'dedup_key' => 'site:yarich.ru', + ]); + + $run = AutopodborRun::create([ + 'tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'queued', 'region_code' => 29, + 'params' => ['examples' => [], 'about_self' => ['ломбард'], 'include_federal' => false], + ]); + + app()->instance(CompetitorAgent::class, fakeAgentReturning([ + ['name' => 'Яричъ', 'site_url' => 'yarich.ru', 'description' => 'ломбард', 'is_federal' => false, + 'directory_urls' => [], 'relevance_pct' => 90, 'dedup_key' => 'site:yarich.ru', 'provenance' => []], + ])); + + (new RunAutopodborSearchJob($run->id))->handle( + app(CompetitorAgent::class), app(\App\Services\Autopodbor\AutopodborDedup::class), + app(\App\Services\Autopodbor\AutopodborChargeService::class), + ); + + // «Яричъ» сохранён как предложение (совпал с архивом → это «ранее удалён», показываем) + expect(AutopodborCompetitor::where('tenant_id', $tenant->id)->where('box', 'proposal')->where('name', 'Яричъ')->exists()) + ->toBeTrue(); +}); + +it('находка — полный дубль АКТИВНОЙ фирмы поля — на записи выкидывается', function () { + $tenant = Tenant::factory()->create(); + DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $tenant->id]); + + AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'name' => 'Яричъ', 'origin' => 'auto', 'box' => 'field', + 'site_url' => 'yarich.ru', 'dedup_key' => 'site:yarich.ru', + ]); + + $run = AutopodborRun::create([ + 'tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'queued', 'region_code' => 29, + 'params' => ['examples' => [], 'about_self' => ['ломбард'], 'include_federal' => false], + ]); + + app()->instance(CompetitorAgent::class, fakeAgentReturning([ + ['name' => 'Яричъ', 'site_url' => 'yarich.ru', 'description' => 'ломбард', 'is_federal' => false, + 'directory_urls' => [], 'relevance_pct' => 90, 'dedup_key' => 'site:yarich.ru', 'provenance' => []], + ])); + + (new RunAutopodborSearchJob($run->id))->handle( + app(CompetitorAgent::class), app(\App\Services\Autopodbor\AutopodborDedup::class), + app(\App\Services\Autopodbor\AutopodborChargeService::class), + ); + + // полный дубль активной — новых предложений нет, прогон пустой + expect(AutopodborCompetitor::where('tenant_id', $tenant->id)->where('box', 'proposal')->exists())->toBeFalse(); +}); +``` + +- [ ] **Step 2: Прогнать — первый падает** (сейчас архив-совпадение молча выкидывается тоже). + +Run: `cd app && c:/tools/php83/php vendor/bin/pest tests/Feature/Autopodbor/ProposalGroupsTest.php` +Expected: FAIL на первом тесте (Яричъ не сохранён). + +- [ ] **Step 3: Заменить фильтр в джобе.** + +`app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php` — блок «Сквозной дедуп» (строки ~83-97) заменить на фильтр только против АКТИВНЫХ (не archived) фирм с ПОЛНЫМ совпадением опознавалок. Внедрить `CompetitorIdentity` и сравнивать по её ключам: +```php + // Выкидываем только ПОЛНЫЕ дубли АКТИВНЫХ фирм (поле/предложения) — та же опознавалка и ни одной + // новой (это «скрытая» группа, спека §8). Совпавших с архивом НЕ трогаем — это «ранее удалён», + // их показываем. Исключаем свой же прогон (ретрай не должен схлопнуть свои результаты). + $identity = app(\App\Services\Autopodbor\CompetitorIdentity::class); + $activeKeySets = AutopodborCompetitor::where('tenant_id', $run->tenant_id) + ->where('box', '!=', 'archived') + ->where(function ($q) use ($run) { + $q->where('search_run_id', '!=', $run->id)->orWhereNull('search_run_id'); + }) + ->get(['site_url', 'directory_urls']) + ->map(fn ($c) => array_flip($identity->keys([ + 'site_url' => $c->site_url, + 'directory_urls' => $c->directory_urls ?? [], + ]))) + ->all(); + + $unique = array_values(array_filter($unique, function (array $c) use ($identity, $activeKeySets): bool { + $keys = $identity->keys(['site_url' => $c['site_url'] ?? null, 'directory_urls' => $c['directory_urls'] ?? []]); + if ($keys === []) { + return true; // нет опознавалок — не с чем сверять, оставляем + } + foreach ($activeKeySets as $have) { + $delta = array_filter($keys, static fn (string $k): bool => ! isset($have[$k])); + $shares = count($delta) < count($keys); // есть общий ключ + if ($shares && $delta === []) { + return false; // полный дубль активной фирмы → выкинуть + } + } + + return true; + })); +``` +(Строку `$existingKeys = ...pluck('dedup_key')...` и старый `array_filter` по `dedup_key` — удалить.) + +- [ ] **Step 4: Прогнать — оба зелёные.** + +Run: `cd app && c:/tools/php83/php vendor/bin/pest tests/Feature/Autopodbor/ProposalGroupsTest.php` +Expected: PASS (2 теста). + +- [ ] **Step 5: Регресс автоподбора.** + +Run: `cd app && c:/tools/php83/php vendor/bin/pest tests/Unit/Autopodbor tests/Feature/Autopodbor` +Expected: всё зелёное (существующий тест «сквозной дедуп прячет известных» — переписать под новое: полный дубль активной прячется, архив — нет; если такой тест есть — обновить его в этом же шаге). + +- [ ] **Step 6: Pint + commit.** + +```bash +cd app && c:/tools/php83/php vendor/bin/pint app/Jobs/Autopodbor/RunAutopodborSearchJob.php +git add app/Jobs/Autopodbor/RunAutopodborSearchJob.php app/tests/Feature/Autopodbor/ProposalGroupsTest.php +git commit -m "feat(автоподбор): на записи прятать только полный дубль активной фирмы (архив сохраняем)" +``` + +--- + +### Task 5: `/proposals` отдаёт группы; bulk «удалить всех» архивной группы + +**Files:** +- Modify: `app/app/Http/Controllers/Api/AutopodborController.php` (метод `proposals`; новый `dismissArchivedGroup`) +- Modify: `app/routes/api.php` (маршрут bulk) +- Test: `app/tests/Feature/Autopodbor/ProposalGroupsTest.php` (часть B) + +- [ ] **Step 1: Failing feature-тест** — `/proposals` возвращает группы, а bulk убирает всю архивную группу. + +Добавить в `ProposalGroupsTest.php`: +```php +it('GET /proposals делит на группы; bulk убирает всю группу «ранее удалённые»', function () { + $tenant = Tenant::factory()->create(); + $user = App\Models\User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $tenant->id]); + + // в поле — Alpha (a.ru); в архиве — Beta (b.ru) + AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'Alpha', 'origin' => 'auto', 'box' => 'field', 'site_url' => 'a.ru', 'dedup_key' => 'site:a.ru']); + AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'Beta', 'origin' => 'auto', 'box' => 'archived', 'site_url' => 'b.ru', 'dedup_key' => 'site:b.ru']); + // предложения: Gamma (новый), Alpha-new (актуализация a.ru→нет, но новый dir), Beta (ранее удалён) + $new = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'Gamma', 'origin' => 'auto', 'box' => 'proposal', 'site_url' => 'g.ru', 'dedup_key' => 'site:g.ru']); + $del = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'Beta заново', 'origin' => 'auto', 'box' => 'proposal', 'site_url' => 'b.ru', 'dedup_key' => 'site:b.ru']); + + $resp = $this->actingAs($user)->getJson('/api/autopodbor/proposals')->assertOk(); + $resp->assertJsonPath('groups.new.0.name', 'Gamma'); + $resp->assertJsonPath('groups.archived.0.name', 'Beta заново'); + + // bulk: убрать всю группу «ранее удалённые» + $this->actingAs($user)->postJson('/api/autopodbor/proposals/dismiss-archived')->assertOk(); + expect(AutopodborCompetitor::withoutGlobalScopes()->find($del->id)->box)->toBe('archived'); + expect(AutopodborCompetitor::withoutGlobalScopes()->find($new->id)->box)->toBe('proposal'); // новых не трогаем +}); +``` + +- [ ] **Step 2: Прогнать — падает** (нет группировки/маршрута). + +Run: `cd app && c:/tools/php83/php vendor/bin/pest tests/Feature/Autopodbor/ProposalGroupsTest.php` +Expected: FAIL (`groups` отсутствует / 404 на dismiss). + +- [ ] **Step 3: Переписать `proposals()` на группы.** + +`AutopodborController::proposals` заменить телом: +```php + public function proposals(Request $request, ProposalClassifier $classifier): JsonResponse + { + $tenantId = $request->user()->tenant_id; + + $proposals = AutopodborCompetitor::where('tenant_id', $tenantId)->where('box', 'proposal') + ->orderByDesc('relevance_pct')->orderBy('id')->get(); + $existing = AutopodborCompetitor::where('tenant_id', $tenantId) + ->whereIn('box', ['field', 'archived'])->get(); + + $finds = $proposals->map(fn ($c) => $this->toClassifyRow($c))->all(); + $stateExisting = $existing->map(fn ($c) => $this->toClassifyRow($c) + ['box' => $c->box])->all(); + + $classified = $classifier->classify($finds, $stateExisting); + + $groups = ['new' => [], 'actualize' => [], 'archived' => []]; + $byId = $proposals->keyBy('id'); + foreach ($classified as $row) { + if ($row['group'] === 'hidden') { + continue; // скрытые дубли не показываем + } + $comp = $byId->get($row['find']['id']); + $card = (new CompetitorResource($comp))->resolve(); + if ($row['group'] === 'actualize') { + $card['matched_id'] = $row['matched_id']; + $card['delta_keys'] = $row['delta_keys']; + } + $groups[$row['group']][] = $card; + } + + return response()->json(['groups' => $groups]); + } + + /** @return array{id:int,name:string,site_url:?string,directory_urls:array} строка для классификатора */ + private function toClassifyRow(AutopodborCompetitor $c): array + { + return ['id' => $c->id, 'name' => $c->name, 'site_url' => $c->site_url, 'directory_urls' => $c->directory_urls ?? []]; + } +``` +Добавить в начало файла `use App\Services\Autopodbor\ProposalClassifier;`. + +- [ ] **Step 4: Bulk-метод + маршрут.** + +В `AutopodborController` добавить: +```php + /** POST /api/autopodbor/proposals/dismiss-archived — убрать всю группу «ранее удалённые» разом. */ + public function dismissArchivedGroup(Request $request, ProposalClassifier $classifier): JsonResponse + { + $tenantId = $request->user()->tenant_id; + $proposals = AutopodborCompetitor::where('tenant_id', $tenantId)->where('box', 'proposal')->get(); + $existing = AutopodborCompetitor::where('tenant_id', $tenantId)->whereIn('box', ['field', 'archived'])->get(); + + $finds = $proposals->map(fn ($c) => $this->toClassifyRow($c))->all(); + $stateExisting = $existing->map(fn ($c) => $this->toClassifyRow($c) + ['box' => $c->box])->all(); + + $ids = collect($classifier->classify($finds, $stateExisting)) + ->where('group', 'archived')->pluck('find.id')->all(); + + AutopodborCompetitor::where('tenant_id', $tenantId)->whereIn('id', $ids)->update(['box' => 'archived']); + + return response()->json(['dismissed' => count($ids)]); + } +``` +В `app/routes/api.php` рядом с прочими autopodbor-маршрутами добавить: +```php + Route::post('autopodbor/proposals/dismiss-archived', [AutopodborController::class, 'dismissArchivedGroup']); +``` + +- [ ] **Step 5: Прогнать — зелёный.** + +Run: `cd app && c:/tools/php83/php vendor/bin/pest tests/Feature/Autopodbor/ProposalGroupsTest.php` +Expected: PASS. + +- [ ] **Step 6: Pint + commit.** + +```bash +cd app && c:/tools/php83/php vendor/bin/pint app/Http/Controllers/Api/AutopodborController.php +git add app/Http/Controllers/Api/AutopodborController.php app/routes/api.php app/tests/Feature/Autopodbor/ProposalGroupsTest.php +git commit -m "feat(автоподбор): /proposals делит на группы + bulk «удалить всех» ранее удалённых" +``` + +--- + +### Task 6: Убрать «вычет себя» в сборщике + +**Files:** +- Modify: `app/app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php` +- Modify: `app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php` +- Test: `app/tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php` (обновить) + +- [ ] **Step 1: Обновить тест** — клиент НЕ вычитается. + +В `FindCompetitorsAssemblerTest.php` в первом тесте убрать ожидание `->not->toContain('Мой Бизнес')` и заменить на утверждение, что клиент ОСТАЁТСЯ: +```php + // «вычет себя» убран (спека §7): свой сайт/пример клиент может вернуть — не выкидываем + expect($names)->toContain('Мой Бизнес'); +``` +(Соответственно поправить ожидаемый порядок/состав в `toBe([...])`, добавив 'Мой Бизнес' на его место по похожести; для нулевого эмбеддера порядок стабильно-исходный.) + +- [ ] **Step 2: Прогнать — падает** (сейчас вычитает). + +Run: `cd app && c:/tools/php83/php vendor/bin/pest tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php` +Expected: FAIL. + +- [ ] **Step 3: Убрать вычет.** + +`FindCompetitorsAssembler::assemble` — `mergeCompetitors($candidates, $clientKeys)` → `mergeCompetitors($candidates)` (без clientKeys). Параметр `$clientKeys` в сигнатуре оставить (совместимость вызова), но не передавать в merge; пометить `@deprecated не используется — вычет себя убран (спека §7)`. +`LiveFindCompetitors::find` — можно оставить передачу `$clientKeys` (игнорируется), либо передавать `[]`. Оставить как есть — сборщик их игнорирует. + +- [ ] **Step 4: Прогнать — зелёный + регресс.** + +Run: `cd app && c:/tools/php83/php vendor/bin/pest tests/Unit/Autopodbor tests/Feature/Autopodbor` +Expected: всё зелёное. + +- [ ] **Step 5: Pint + commit.** + +```bash +cd app && c:/tools/php83/php vendor/bin/pint app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php +git add app/Services/Autopodbor/Agent/FindCompetitorsAssembler.php app/tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php +git commit -m "feat(автоподбор): убрать вычет себя — свой сайт/пример тоже можно вернуть" +``` + +--- + +### Task 7: Фронт — 3 секции на «Предложениях» + bulk «Удалить всех» + +**Files:** +- Modify: `app/resources/js/api/autopodbor.ts` (тип ответа `proposalsGrouped`, метод `dismissArchived`) +- Modify: `app/resources/js/stores/autopodborStore.ts` (`proposalGroups`, `loadProposals` под группы, `dismissArchivedGroup`) +- Modify: `app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue` (3 секции) +- Test: ручная проверка через `npm run test:vue` для стора (если есть спеки) + сборка. + +- [ ] **Step 1: API-клиент.** + +В `app/resources/js/api/autopodbor.ts` добавить тип и методы: +```ts +export interface ProposalGroups { + new: CompetitorDto[]; + actualize: (CompetitorDto & { matched_id: number; delta_keys: string[] })[]; + archived: CompetitorDto[]; +} + +export async function fetchProposalGroups(): Promise { + const { data } = await http.get('/autopodbor/proposals'); + return data.groups as ProposalGroups; +} + +export async function dismissArchivedProposals(): Promise { + await http.post('/autopodbor/proposals/dismiss-archived'); +} +``` +(Использовать существующий http-инстанс модуля — как в соседних экспортах файла.) + +- [ ] **Step 2: Стор.** + +В `autopodborStore.ts` добавить состояние `proposalGroups` и действия: +```ts +proposalGroups: { new: [], actualize: [], archived: [] } as ProposalGroups, +async loadProposalGroups() { + this.proposalGroups = await fetchProposalGroups(); +}, +async dismissArchivedGroup() { + await dismissArchivedProposals(); + await this.loadProposalGroups(); +}, +``` +(Импортировать `fetchProposalGroups`, `dismissArchivedProposals`, тип `ProposalGroups`.) + +- [ ] **Step 3: Экран — 3 секции.** + +В `FieldProposalsScreen.vue`: `reload()` вызывает `store.loadProposalGroups()`. Вместо одной сетки — три секции, каждая скрыта если пуста. «Новые» и «Ранее удалённые» — существующая карточка (вынести карточку в под-компонент `ProposalCard` или v-for по группе). У секции «Ранее удалённые» — заголовок с кнопкой: +```html +
+
♻️ Ранее удалённые {{ groups.archived.length }} + +
+
+
+``` +```ts +const groups = computed(() => store.proposalGroups); +async function dismissArchived() { + busy.value = true; + try { await store.dismissArchivedGroup(); flash('Ранее удалённые убраны'); } + finally { busy.value = false; } +} +``` +Секцию «На актуализацию» на этой стадии показать заголовком со счётчиком и плейсхолдером «details в стадии 2» НЕ надо — actualize-карточка приедет в Task 8; пока выводить её как обычную карточку без кнопок add/replace (только «В поле →» скрыть, показать пометку «изменения»), либо не выводить группу actualize вовсе до Task 8. **Решение:** до Task 8 группу `actualize` не рендерим (данные приходят, но секция скрыта) — чтобы не выпускать полу-функцию. + +- [ ] **Step 4: Сборка/линт фронта.** + +Run: `cd app && npm run lint:vue && npm run type-check` +Expected: без ошибок (или как принято в проекте — CI-only type-check). + +- [ ] **Step 5: Commit.** + +```bash +git add app/resources/js/api/autopodbor.ts app/resources/js/stores/autopodborStore.ts app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue +git commit -m "feat(автоподбор): страница предложений — 3 секции + «Удалить всех» ранее удалённых" +``` + +--- + +# СТАДИЯ 2 — актуализация (Добавить/Заменить → проект) + +### Task 8: Дельта-карточка + endpoint актуализации + +**Files:** +- Modify: `app/app/Http/Controllers/Api/AutopodborController.php` (`actualize`) +- Modify: `app/routes/api.php` +- Modify: `app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue` (секция «На актуализацию») +- Modify: `app/resources/js/api/autopodbor.ts`, `autopodborStore.ts` +- Test: `app/tests/Feature/Autopodbor/CompetitorActualizeTest.php` + +- [ ] **Step 1: Failing feature-тест** — add заводит источник; replace при активном проекте → 409. + +`app/tests/Feature/Autopodbor/CompetitorActualizeTest.php`: +```php +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $tenant->id]); + + $field = AutopodborCompetitor::create(['tenant_id' => $tenant->id, 'name' => 'Яричъ', 'origin' => 'auto', + 'box' => 'field', 'site_url' => 'yarich.ru', 'study_run_id' => null, 'dedup_key' => 'site:yarich.ru']); + + $resp = $this->actingAs($user)->postJson("/api/autopodbor/competitors/{$field->id}/actualize", [ + 'action' => 'add', 'new_site' => 'yarich-new.ru', + ])->assertOk(); + + $srcId = $resp->json('source_id'); + expect(AutopodborSource::withoutGlobalScopes()->find($srcId))->not->toBeNull() + ->and(AutopodborSource::withoutGlobalScopes()->find($srcId)->signal_type)->toBe('site') + ->and(AutopodborSource::withoutGlobalScopes()->find($srcId)->identifier)->toBe('yarich-new.ru'); + // старый сайт-карточка не тронут + expect($field->fresh()->site_url)->toBe('yarich.ru'); +}); + +it('actualize replace при активном проекте по старому — 409 manage_via_project', function () { + // setup: фирма поля со старым источником-сайтом, у которого активный проект + // (использовать фабрики Project/AutopodborSource как в существующих feature-тестах автоподбора) + // ... создать $field, старый source 'yarich.ru' с активным project ... + // $this->actingAs($user)->postJson(".../actualize", ['action'=>'replace','new_site'=>'yarich-new.ru']) + // ->assertStatus(409)->assertJsonPath('error', 'manage_via_project'); +})->todo('дописать по образцу существующих feature-тестов с активным проектом'); +``` + +- [ ] **Step 2: Прогнать — падает.** + +Run: `cd app && c:/tools/php83/php vendor/bin/pest tests/Feature/Autopodbor/CompetitorActualizeTest.php` +Expected: FAIL (404 на actualize). + +- [ ] **Step 3: Endpoint `actualize`.** + +В `AutopodborController`: +```php + /** POST /api/autopodbor/competitors/{competitor}/actualize — добавить/заменить сайт фирмы поля. */ + public function actualize(Request $request, int $competitor, AutopodborNormalizer $norm): JsonResponse + { + $v = $request->validate([ + 'action' => ['required', 'in:add,replace'], + 'new_site' => ['required', 'string', 'max:500'], + ]); + $tenantId = $request->user()->tenant_id; + + $comp = AutopodborCompetitor::where('tenant_id', $tenantId)->with('sources.project')->findOrFail($competitor); + $site = $norm->domainHead($v['new_site']); + + if ($v['action'] === 'replace') { + $hasActive = $comp->sources->contains(fn (AutopodborSource $s) => $s->signal_type === 'site' && $s->project && $s->project->is_active); + if ($hasActive) { + return response()->json(['error' => 'manage_via_project'], 409); + } + // нет активного — старые сайт-источники в архив (мягко): помечаем box='archived' + $comp->sources()->where('signal_type', 'site')->update(['box' => 'archived']); + } + + $source = AutopodborSource::updateOrCreate( + ['competitor_id' => $comp->id, 'dedup_key' => $norm->sourceKey('site', $site)], + [ + 'tenant_id' => $tenantId, + 'study_run_id' => $comp->study_run_id, + 'signal_type' => 'site', + 'identifier' => $site, + 'box' => 'field', + 'provenance_label' => 'Актуализация: новый сайт', + ], + ); + + return response()->json(['source_id' => $source->id], 200); + } +``` +Маршрут в `app/routes/api.php`: +```php + Route::post('autopodbor/competitors/{competitor}/actualize', [AutopodborController::class, 'actualize']); +``` +(CHECK `autopodbor_sources.box` уже допускает 'archived' — расширен в миграции Task 1.) + +- [ ] **Step 4: Прогнать — add-тест зелёный.** + +Run: `cd app && c:/tools/php83/php vendor/bin/pest tests/Feature/Autopodbor/CompetitorActualizeTest.php` +Expected: add-тест PASS; replace-тест — `todo` (не падает прогон). + +- [ ] **Step 5: Фронт — секция «На актуализацию».** + +В `FieldProposalsScreen.vue` рендерить группу `actualize`. Карточка-дельта: заголовок «В поле: {{ matched.name }}», список текущих сайтов/карточек и новых — ссылками (``), плашка «⚠️ Проверьте перед решением», две кнопки: +```html + + +``` +```ts +async function actualize(c, action) { + if (!confirm('Создать проект по этому сайту?')) return; + const { source_id } = await store.actualizeCompetitor(c.matched_id, action, c.site_url); + // перейти в форму создания проекта с выбранным источником + nav.ctx.competitorId = c.matched_id; + nav.ctx.selectedSourceIds = [source_id]; + nav.go('create'); +} +``` +Добавить в api/store метод `actualizeCompetitor(matchedId, action, newSite)` → POST endpoint. Для показа «текущих сайтов» фирмы поля — подтянуть её источники (загрузить matched competitor через `store.loadCompetitor` при раскрытии карточки, либо отдавать `matched` с сайтами прямо в `/proposals` — расширить `actualize`-элемент полем `matched: {name, sites: [...], directory_urls: [...]}`). **Решение:** отдавать `matched` c сайтами в ответе `/proposals` (обогатить Task 5 маппинг: для actualize подложить `matched` из совпавшей фирмы — её `site_url` + сайт-источники + `directory_urls`). + +- [ ] **Step 6: Обогатить `/proposals` полем `matched` (доработка Task 5 под Stage 2).** + +В `AutopodborController::proposals`, ветка `actualize`: добавить +```php + $m = AutopodborCompetitor::where('tenant_id', $tenantId)->with('sources')->find($row['matched_id']); + $card['matched'] = [ + 'id' => $m->id, 'name' => $m->name, + 'sites' => collect([$m->site_url])->filter() + ->merge($m->sources->where('signal_type', 'site')->pluck('identifier'))->unique()->values(), + 'directory_urls' => $m->directory_urls ?? [], + ]; +``` + +- [ ] **Step 7: Прогон + линт + сборка.** + +Run: `cd app && c:/tools/php83/php vendor/bin/pest tests/Unit/Autopodbor tests/Feature/Autopodbor && npm run lint:vue` +Expected: зелёное. + +- [ ] **Step 8: Pint + commit.** + +```bash +cd app && c:/tools/php83/php vendor/bin/pint app/Http/Controllers/Api/AutopodborController.php +git add app/Http/Controllers/Api/AutopodborController.php app/routes/api.php app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue app/resources/js/api/autopodbor.ts app/resources/js/stores/autopodborStore.ts app/tests/Feature/Autopodbor/CompetitorActualizeTest.php +git commit -m "feat(автоподбор): актуализация — дельта-карточка + Добавить/Заменить → проект" +``` + +--- + +### Task 9: Дописать replace-с-активным-проектом + завершение + +**Files:** +- Modify: `app/tests/Feature/Autopodbor/CompetitorActualizeTest.php` (снять `todo`, дописать replace-кейс по образцу существующих feature-тестов с активным проектом) + +- [ ] **Step 1** Дописать replace-тест (создать активный Project на старом сайт-источнике; ассертить 409 `manage_via_project`). +- [ ] **Step 2** Прогнать `CompetitorActualizeTest.php` — оба зелёные. +- [ ] **Step 3** Финальный полный прогон: `cd app && c:/tools/php83/php vendor/bin/pest tests/Unit/Autopodbor tests/Feature/Autopodbor` — всё зелёное. +- [ ] **Step 4** Commit: `git commit -m "test(автоподбор): replace при активном проекте → 409"`. + +--- + +## Финал (после всех задач) + +- **REQUIRED SUB-SKILL:** Use superpowers:finishing-a-development-branch — проверить тесты, показать варианты, выполнить выбор. +- Обновить снимок состояния фичи (файл `2026-07-01-AVTOPODBOR-*` / память проекта): группы + архив + актуализация готовы; xfetch-параллель (№4 общего списка) — следующая. diff --git a/docs/superpowers/specs/2026-07-01-avtopodbor-sverka-nakhodok-gruppy-design.md b/docs/superpowers/specs/2026-07-01-avtopodbor-sverka-nakhodok-gruppy-design.md new file mode 100644 index 00000000..8b5c0f22 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-avtopodbor-sverka-nakhodok-gruppy-design.md @@ -0,0 +1,186 @@ +# Автоподбор — сверка находок со состоянием клиента (3 группы + скрытые дубли) + +**Дата:** 2026-07-01 +**Воркстри:** `worktree-avtopodbor` (код под `app/`). НЕ прод. +**Статус:** дизайн согласован с владельцем (части 1–3 + уточнения), готов к плану. + +--- + +## 1. Цель + +Перестать **молча выбрасывать** уже известных конкурентов при повторном подборе. Вместо этого +перед показом сверять каждую находку со **состоянием клиента** и раскладывать предложения на +понятные группы, чтобы клиент видел, что нового, что изменилось и что он когда-то удалил. + +Мотив: повторные подборы (клиент расширил условия / вернулся через полгода / запустил второй сбор, +не разобрав первый) сейчас либо плодят дубли, либо тихо прячут полезное. Владелец хочет прозрачность +и ручной контроль. + +## 2. Что есть сейчас (факт по коду) + +- Таблица `autopodbor_competitors`: `box` = `proposal` | `field`, `origin` = auto|manual|resolve, + `dedup_key` (`site:домен` либо `name:имя`), `directory_urls` (jsonb), `search_run_id`, `study_run_id`. +- **Поисковый прогон** (`RunAutopodborSearchJob`) после дедупа **фильтрует находки против всех** + существующих конкурентов тенанта по `dedup_key` и **молча выкидывает** совпавших. Клиент не видит. +- **Сборщик** (`FindCompetitorsAssembler` + `AutopodborDedup::mergeCompetitors`) **вычитает самого + клиента** по `clientKeys` (имя/сайт из «о себе»). +- **Удаление** (`AutopodborController::destroyCompetitor`) — **жёсткое** (`$comp->delete()`), следа нет. + Блокируется, если у источника есть активный проект (`has_active_projects`, 409). +- Страница **«Предложения»** (`FieldProposalsScreen.vue`) — плоская сетка карточек `box=proposal`, + на каждой «✎ Изменить · ✕ Удалить · В поле →». +- Форма **создания проекта** (`CreateScreen.vue`) — работает по выбранным `autopodbor_sources` + (регион / лимит лидов в день / дни приёма / «создать» / «создать и запустить»). +- Механика сверки «та же фирма» — `AutopodborDedup::candidateKeys` (union-find по корню имени, + корню домена, телефонам, кодам справочников `d:`). + +## 3. Модель 4 групп + правила сверки + +Перед показом «Предложений» каждую находку сверяем с **состоянием клиента**: +**поле** (`box=field`) + **текущие предложения** (`box=proposal`) + **архив удалённых** (новое состояние). + +**«Та же фирма» = пересеклись опознавалки:** голова домена сайта **ИЛИ** ключ карточки справочника +(id фирмы 2ГИС `/firm/` / slug Яндекса `/maps/org/`). **Имя НЕ участвует** — клиент называет +фирму как хочет. + +Раскладка (по тому, с чем и как пересеклась находка): + +| Группа | Когда | Карточка | +|---|---|---| +| 🆕 **Новые** | ни с чем не пересеклась | обычная, кнопка «В поле →» | +| ♻️ **Ранее удалённые** | пересеклась с архивом (**он удалял — мы нашли снова**) | обычная, «В поле →» (вернуть) + групповая «Удалить всех» | +| 🔁 **На актуализацию** | пересеклась с фирмой в поле/предложениях **и принесла новое** (новый сайт/карточка) | дельта-карточка (см. §5), кнопки «Добавить»/«Заменить» | +| 🙈 **Скрытая** | пересеклась и **ничего нового** (сайт и карточки те же) | не показываем | + +**Имя при актуализации:** сохраняется **то имя, что уже стоит в поле** — свежее название из подбора +НЕ перезатирает клиентское. + +## 4. Изменения данных + +1. **Архив удалённых.** `box` получает третье значение `archived` (миграция: снять/пересоздать + CHECK `box IN ('proposal','field','archived')`; обновить `db/schema.sql` + `db/CHANGELOG_schema.md`). + «Удалить» теперь ставит `box='archived'` вместо `$comp->delete()`. Источники не стираем — они + остаются на архивной карточке (для будущей сверки и восстановления). Гард «активный проект + удалять нельзя» сохраняется. Архивные конкуренты не показываются в поле/предложениях и не + участвуют в счётчиках. +2. **Новый сайт → источник.** При «Добавить/Заменить» найденный сайт заводится как + `autopodbor_sources` (signal_type=`site`) у конкурента-в-поле и уходит в форму создания проекта. + +## 5. UX + +### 5.1. Страница «Предложения» — 3 секции + +Вместо плоской сетки — три подзаголовка по порядку: **🆕 Новые находки**, **🔁 На актуализацию**, +**♻️ Ранее удалённые** (пустые секции скрыты). Скрытая 4-я группа не выводится совсем. Массовый +перенос «в поле» остаётся для «Новых» и «Ранее удалённых». + +**Группа «Ранее удалённые»** — это те, кого клиент раньше удалил, а мы **нашли снова**. У группы — +**кнопка «Удалить всех»** (одним махом снова спрятать всю группу в архив: раз уже удалял, скорее всего +хочет убрать опять). Плюс по каждому — «В поле →» на случай «удалил по ошибке / передумал». +Действие «Удалить всех» дублирует старые опознавалки к архивным строкам и убирает всплывшие +предложения из показа (идемпотентно). + +### 5.2. Карточка «На актуализацию» — дельта + +Показывает **не всю фирму, а что изменилось**, например: + +> В поле конкурент **«Яричъ»**. Нашли **новый сайт**. +> Сейчас в поле: `yarich.ru`, `yarich-2.ru` (все — ссылками, кликабельно) +> Новое: `yarich-new.ru` (ссылкой) +> **⚠️ Проверьте ссылки перед решением.** + +Сайтов у фирмы может быть несколько (1, 3, …) — выводим **все** текущие и **все** новые, ссылками. + +Кнопки: +- **Добавить** — новый сайт **в довесок** к старым (старый живой, ловит лиды). +- **Заменить** — новый **вместо** старого. + +Обе после нажатия спрашивают **«Создать проект? Да / Нет»**; на «Да» открывают **существующую форму +`CreateScreen`** с новым сайтом-источником, уже выбранным. + +## 6. «Добавить» vs «Заменить» + +- **Добавить:** старые сайты/проекты не трогаем. Новый сайт → новый источник → (по желанию) новый + отдельный проект. У фирмы становится больше прослушек. +- **Заменить:** новый источник заводим так же, но **старый гасим/переключаем**. Если по старому сайту + **есть активный проект** — **не гасим автоматически**: показываем предупреждение и отправляем в + раздел «Проекты» (штатная защита §14.10 «управлять через проект», как сейчас у источников). + Если активного проекта нет — старый источник переносим в архив. + +После обработки актуализации (Добавить/Заменить применены) **предложение-дельта убирается** из группы +«На актуализацию» (переносим в архив/удаляем строку-предложение) — работа по нему закрыта, новый +источник уже на фирме-в-поле. + +## 7. Убрать «вычет себя» + +`FindCompetitorsAssembler::assemble` перестаёт вычитать клиента: `mergeCompetitors` вызывается без +`clientKeys` (или с пустым). Свой сайт/свой пример-конкурент теперь могут попасть в выдачу — +клиент вправе поставить и их на прослушку. Тесты сборщика, проверяющие вычет клиента, переписываются +под новое поведение. + +## 8. Архитектура + +- **Два уровня, чтобы и группы были живыми, и мусор не копился:** + - **На записи прогона** (`RunAutopodborSearchJob`) выкидываем находку **только если она полный + дубль АКТИВНОЙ фирмы** (поле/предложения) — та же опознавалка и **ни одной новой** (это «скрытая» + группа: показывать и хранить нечего). Находку, совпавшую с **архивом** удалённых, **сохраняем** + (это «ранее удалён»). Всё остальное сохраняем как `box=proposal`. Т.е. старый молчаливый фильтр + «против ВСЕГО известного» заменяется на узкий «только полные дубли активных». + - **На показе** (`/proposals`) классификатор делит сохранённые предложения относительно **свежего** + состояния (поле/предложения/архив) на new/actualize/archived и **повторно прячет** те, что с + момента записи стали полными дублями (клиент мог что-то перенести/удалить). Так группы всегда + актуальны, а скрытые дубли не накапливаются строками. +- **Компоненты (изоляция, тестируемость офлайн):** + 1. `CompetitorIdentity` — из карточки конкурента (site_url + directory_urls) достаёт **устойчивые + опознавалки**: `s:<домен-корень>` + `d:<ключ-карточки>` (2ГИС firm id / Яндекс slug). **Имя не + включает.** Разбор ссылки справочника → ключ переиспользует существующие парсеры + (`CategoryListingParser`/`YandexListParser` шаблоны `firm/`, `maps/org/`). + 2. `ProposalClassifier` — на вход: находки-предложения + существующие (поле/предложения/архив); + на выход: для каждой находки `{group: new|actualize|archived|hidden, matched_id, delta}`, + где `delta` = новые сайты/карточки, которых нет у совпавшей фирмы. Чистая функция над данными. + 3. API: `GET /api/autopodbor/proposals` отдаёт находки, сгруппированные классификатором + (или отдельный эндпоинт `/proposals/grouped`); actualize-элементы несут `matched` фирму и `delta`. + 4. Действия актуализации: `POST /api/autopodbor/competitors/{field}/actualize` + `{action: add|replace, new_site}` → заводит источник, возвращает его id для формы проекта; + при `replace` с активным проектом — 409 `manage_via_project`. + 5. Архив: `destroyCompetitor` → `box='archived'`; (опц.) восстановление через перенос + `archived → proposal|field`. + +## 9. Крайние случаи + +- Находка пересеклась и с полем, и с архивом — приоритет: **актуализация поля** важнее «ранее удалён» + (фирма уже в работе). Пересеклась только с архивом → «Ранее удалённые». +- Находка без сайта и без карточек (нет опознавалок) — считается **новой** (сверять не по чему); имя + не используем. +- Дельта пустая (совпали все опознавалки) → «Скрытая», не показываем. +- Несколько находок одного прогона указывают на одну фирму поля — схлопываем в одну актуализацию + (union-find внутри находок, как сейчас). +- Восстановление из архива и повторное удаление — идемпотентны (повторный `archived` без эффекта). + +## 10. Тестирование (TDD) + +- **Unit `CompetitorIdentity`**: домен-корень из разных написаний сайта; ключ 2ГИС из `/firm/`; + slug Яндекса из `/maps/org//`; имя не влияет; пустые опознавалки → пустой набор. +- **Unit `ProposalClassifier`**: 4 группы на модельных наборах; дельта = только новые опознавалки; + приоритет поле>архив; находка без опознавалок → new; полное совпадение → hidden; имя из поля + сохраняется. +- **Feature (БД, RLS)**: удаление → `box='archived'` (строка жива); архивный не виден в поле/ + предложениях; повторный подбор раскладывает по группам; actualize add/replace создаёт источник; + replace с активным проектом → 409 `manage_via_project`. +- **Сборщик**: тесты «вычет клиента» переписаны — клиент/примеры больше НЕ вычитаются. +- Прогон: `cd app && ./vendor/bin/pest tests/Unit/Autopodbor tests/Feature/Autopodbor`; Pint чист. + +## 11. Стадии (для плана) + +- **Стадия 1** — данные + классификация + показ: архив-вместо-стирания, `CompetitorIdentity`, + `ProposalClassifier`, 3 секции на странице, поисковый прогон перестаёт молча прятать, «вычет себя» + убран. Даёт видимую ценность (группы) без потока актуализации. +- **Стадия 2** — актуализация: дельта-карточка со ссылками, «Добавить/Заменить» → источник → форма + проекта, защита активного проекта при «Заменить». + +## 12. Вне рамок + +- Не трогаем движок каналов А/В, модель канала В (sonar-reasoning-pro), рубрики, порядок дедупа. +- Не трогаем биллинг подбора/изучения. +- Центр похожести из описания ниши — отдельная уже готовая правка (ждёт эскейпа), к этой фиче не + относится. +- Замер лимита xfetch и распараллеливание 2ГИС/шага 2 — отдельная задача (следующая после этой). From 49695cee8ccb6447980e20b69b70482899e8ea8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 1 Jul 2026 18:27:00 +0300 Subject: [PATCH 111/417] =?UTF-8?q?feat=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20Stage=202=20=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D1=83=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=E2=80=94=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20=D1=81=D0=B0=D0=B9?= =?UTF-8?q?=D1=82=20=D1=84=D0=B8=D1=80=D0=BC=D1=8B=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D0=94=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C/=D0=97=D0=B0=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B2=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Секция «На актуализацию»: дельта-карточка показывает текущие сайты и карточки фирмы поля + новый сайт кликабельными ссылками, кнопки Добавить и Заменить ведут через подтверждение «Создать проект?» в существующую форму CreateScreen с подставленным новым источником. Имя фирмы берётся из поля, не перезатирается. Бэкенд: endpoint POST /competitors/{id}/actualize action=add|replace new_site. add заводит сайт-источник рядом со старым, replace архивирует старые сайт-источники и заводит новый, при активном проекте по старому сайту возвращает 409 manage_via_project. /proposals обогащён полем matched с текущими сайтами и карточками совпавшей фирмы. По TDD. Бэкенд автоподбора 254/254, фронт автоподбора 64/64, ESLint чисто, Pint чисто. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/Api/AutopodborController.php | 78 +++++++++- app/resources/js/api/autopodbor.ts | 17 +++ app/resources/js/stores/autopodborStore.ts | 11 ++ .../screens/FieldProposalsScreen.vue | 141 +++++++++++++++++- app/routes/web.php | 1 + .../Autopodbor/CompetitorActualizeTest.php | 114 ++++++++++++++ .../AutopodborFieldProposalsScreen.spec.ts | 92 +++++++++++- 7 files changed, 446 insertions(+), 8 deletions(-) create mode 100644 app/tests/Feature/Autopodbor/CompetitorActualizeTest.php diff --git a/app/app/Http/Controllers/Api/AutopodborController.php b/app/app/Http/Controllers/Api/AutopodborController.php index 86f60bd9..e447fa43 100644 --- a/app/app/Http/Controllers/Api/AutopodborController.php +++ b/app/app/Http/Controllers/Api/AutopodborController.php @@ -345,6 +345,57 @@ class AutopodborController extends Controller return response()->json(null, 204); } + /** + * POST /api/autopodbor/competitors/{competitor}/actualize — актуализация сайта фирмы поля (спека §6). + * action=add — добавить новый сайт-источник (старый продолжает работать). action=replace — заменить: + * если по старому сайту есть активный проект → 409 manage_via_project (менять только через раздел + * проектов, §14.10); иначе прежние сайт-источники в архив, новый заводим. Имя фирмы берём из поля — + * не перезатираем. Возвращает id заведённого источника — фронт ведёт клиента в форму создания проекта. + */ + public function actualize(Request $request, int $competitor, AutopodborNormalizer $norm): JsonResponse + { + $v = $request->validate([ + 'action' => ['required', 'in:add,replace'], + 'new_site' => ['required', 'string', 'max:500'], + ]); + $tenantId = $request->user()->tenant_id; + + $comp = AutopodborCompetitor::where('tenant_id', $tenantId) + ->with('sources.project') + ->findOrFail($competitor); + + if ($comp->study_run_id === null) { + return response()->json(['error' => 'not_studied'], 422); + } + + $site = $norm->domainHead($v['new_site']); + + if ($v['action'] === 'replace') { + $hasActive = $comp->sources->contains( + fn (AutopodborSource $s) => $s->signal_type === 'site' && $s->project && $s->project->is_active + ); + if ($hasActive) { + return response()->json(['error' => 'manage_via_project'], 409); + } + // старый сайт умер: прежние сайт-источники в архив (мягко), новый заведём ниже. + $comp->sources()->where('signal_type', 'site')->update(['box' => 'archived']); + } + + $source = AutopodborSource::updateOrCreate( + ['competitor_id' => $comp->id, 'dedup_key' => $norm->sourceKey('site', $site)], + [ + 'tenant_id' => $tenantId, + 'study_run_id' => $comp->study_run_id, + 'signal_type' => 'site', + 'identifier' => $site, + 'box' => 'field', + 'provenance_label' => 'Актуализация: новый сайт', + ], + ); + + return response()->json(['source_id' => $source->id], 200); + } + /** * GET /api/autopodbor/proposals — предложения, разложенные по группам (спека §3, §5.1): * new (новые) / actualize (на актуализацию) / archived (ранее удалённые). Скрытые полные дубли @@ -357,13 +408,14 @@ class AutopodborController extends Controller $proposals = AutopodborCompetitor::where('tenant_id', $tenantId)->where('box', 'proposal') ->orderByDesc('relevance_pct')->orderBy('id')->get(); $existing = AutopodborCompetitor::where('tenant_id', $tenantId) - ->whereIn('box', ['field', 'archived'])->get(); + ->whereIn('box', ['field', 'archived'])->with('sources')->get(); $finds = $proposals->map(fn (AutopodborCompetitor $c) => $this->toClassifyRow($c))->all(); $state = $existing->map(fn (AutopodborCompetitor $c) => $this->toClassifyRow($c) + ['box' => $c->box])->all(); $groups = ['new' => [], 'actualize' => [], 'archived' => []]; $byId = $proposals->keyBy('id'); + $existingById = $existing->keyBy('id'); foreach ($classifier->classify($finds, $state) as $row) { if ($row['group'] === 'hidden') { continue; // скрытые дубли не показываем @@ -372,6 +424,7 @@ class AutopodborController extends Controller if ($row['group'] === 'actualize') { $card['matched_id'] = $row['matched_id']; $card['delta_keys'] = $row['delta_keys']; + $card['matched'] = $this->matchedSummary($existingById->get($row['matched_id'])); } $groups[$row['group']][] = $card; } @@ -411,6 +464,29 @@ class AutopodborController extends Controller return ['id' => $c->id, 'name' => $c->name, 'site_url' => $c->site_url, 'directory_urls' => $c->directory_urls ?? []]; } + /** + * Краткая карточка совпавшей фирмы поля для группы «на актуализацию»: имя (из поля — не трогаем) + + * все её текущие сайты (site_url + сайт-источники) и карточки справочника — фронт показывает их + * ссылками «проверьте перед решением». null — если фирма вдруг не найдена. + * + * @return array{id:int,name:string,sites:list,directory_urls:array}|null + */ + private function matchedSummary(?AutopodborCompetitor $m): ?array + { + if ($m === null) { + return null; + } + + return [ + 'id' => $m->id, + 'name' => $m->name, + 'sites' => collect([$m->site_url]) + ->merge($m->sources->where('signal_type', 'site')->pluck('identifier')) + ->filter()->unique()->values()->all(), + 'directory_urls' => $m->directory_urls ?? [], + ]; + } + /** PATCH /api/autopodbor/competitors/{id}/box — перенос конкурента предложение↔поле */ public function competitorBox(Request $request, int $competitor): JsonResponse { diff --git a/app/resources/js/api/autopodbor.ts b/app/resources/js/api/autopodbor.ts index e5c86650..6f0139df 100644 --- a/app/resources/js/api/autopodbor.ts +++ b/app/resources/js/api/autopodbor.ts @@ -239,6 +239,23 @@ export async function dismissArchivedProposals(): Promise { 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 { const { data } = await apiClient.patch<{ data: CompetitorDto }>(`/api/autopodbor/competitors/${id}/box`, { box }); return data.data; diff --git a/app/resources/js/stores/autopodborStore.ts b/app/resources/js/stores/autopodborStore.ts index 3c3d74e7..a87c5f9a 100644 --- a/app/resources/js/stores/autopodborStore.ts +++ b/app/resources/js/stores/autopodborStore.ts @@ -14,6 +14,7 @@ import { fetchField, fetchProposalGroups, dismissArchivedProposals, + actualizeCompetitor as apiActualizeCompetitor, setCompetitorBox, setSourceBox, updateCompetitor, @@ -195,6 +196,15 @@ export const useAutopodborStore = defineStore('autopodbor', () => { await loadProposalGroups(); } + /** Актуализация сайта фирмы поля (add/replace) — заводит источник, возвращает его id. */ + async function actualizeCompetitor( + competitorId: number, + action: 'add' | 'replace', + newSite: string, + ): Promise<{ source_id: number }> { + return await apiActualizeCompetitor(competitorId, action, newSite); + } + /** Перенос конкурента предложение↔поле; уход из поля убирает карточку из списка. */ async function moveCompetitorToBox(id: number, box: Box): Promise { await setCompetitorBox(id, box); @@ -306,6 +316,7 @@ export const useAutopodborStore = defineStore('autopodbor', () => { loadField, loadProposalGroups, dismissArchivedGroup, + actualizeCompetitor, moveCompetitorToBox, editCompetitor, removeCompetitor, diff --git a/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue b/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue index 391a0236..4155deb3 100644 --- a/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue +++ b/app/resources/js/views/autopodbor/screens/FieldProposalsScreen.vue @@ -1,12 +1,13 @@ From 8bff267c111a61c09f0c46469fcc7c35f6e36a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 2 Jul 2026 13:22:53 +0300 Subject: [PATCH 130/417] =?UTF-8?q?docs:=20=D1=81=D0=BD=D0=B8=D0=BC=D0=BE?= =?UTF-8?q?=D0=BA=2002.07=20=E2=80=94=20launch-gate=20=D0=AF=D0=94=D0=A0?= =?UTF-8?q?=D0=9E=20=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B5=20+=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D0=B2=20=D0=B2=D0=BE=D1=80=D0=BA=D1=81=D1=82=D1=80=D0=B8=20+?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=BC=D1=82=20=D0=BF=D1=80=D0=BE=D0=B4?= =?UTF-8?q?=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Консолидированное состояние двух треков (launch-gate выкачен на liderra.ru core-only; автоподбор работает только в воркстри) + квирки окружения/тестов + промт для восстановления контекста после компакта. Co-Authored-By: Claude Opus 4.8 --- ...-07-02-SOSTOYANIE-i-promt-prodolzheniya.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-02-SOSTOYANIE-i-promt-prodolzheniya.md diff --git a/docs/superpowers/plans/2026-07-02-SOSTOYANIE-i-promt-prodolzheniya.md b/docs/superpowers/plans/2026-07-02-SOSTOYANIE-i-promt-prodolzheniya.md new file mode 100644 index 00000000..62832c20 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-SOSTOYANIE-i-promt-prodolzheniya.md @@ -0,0 +1,46 @@ +# СОСТОЯНИЕ 02.07.2026 — снимок + промт продолжения + +Два параллельных трека. Читать этот файл + память `project-launch-gate-balance-2026-07-02` и `project-autopodbor-engine-live-working-2026-07-02`. + +--- + +## ТРЕК 1 — Launch-gate «баланс блокирует ЗАПУСК, а не создание» — 🚀 ЯДРО НА БОЕВОМ liderra.ru + +**Правило (утверждено владельцем):** создавать проект можно ВСЕГДА; баланс проверяется в момент ЗАПУСКА (создать-и-запустить / запустить / возобновить одиночно и пачкой / повышение лимита). Не хватает → проект создан, но на паузе с меткой `preflight_blocked_at`, сообщение клиенту В РУБЛЯХ («пополните ~X ₽ или уменьшите объём»). Группа — «сколько влезло». Нет тарифа → запуск запрещён (fail-closed). Действует для обычных проектов И конкурентного поля. + +**Статус выката:** +- ✅ **ЯДРО (обычные проекты) — НА ПРОДЕ** (02.07). gitea **main** продвинут `609c7ae9..2384e3fb` (cherry-pick ТОЛЬКО ядра 13 файлов поверх боевого main через worktree `deploy-main`; сборка фронта из main+ядро без автоподбора). Флаг `BILLING_LAUNCH_REQUIRES_ACTIVE_TIERS=true` в прод `.env`. Проверено: 200 / config.php читаем / pgsql / тарифы 7 / миграций 0 / логи чистые. **Тенантов/проектов на проде 0** → правило для новых. Живьём «создать→запустить» ещё НЕ проверялось (нет тенантов). Откат: бэкап прод `/var/www/liderra/_pre-launchgate-20260702-101135` + git revert. Ранбук: `docs/superpowers/runbooks/2026-07-02-launch-gate-prod-deploy.md`. +- ✅ **ПОЛНАЯ фича (ядро + автоподбор-части)** — в ветке `worktree-avtopodbor` (gitea), коммиты `f78ab4af`(бэк)+`9ff7bc8f`(фронт)+`8a8783df`(атомарность update)+`4c05af7f`(docs). НЕ на проде целиком (автоподбор-части на прод не поехали). +- Финальное ревью (opus): критичных денежных дыр нет. Тесты фичи 40/40 бэк + фронт зелёные. + +**Follow-up:** +- DRY: свести 2 копии правила (`ProjectService::applyBalancePreflightToBulkLimit`, `Tenant::requiredLeadsForTomorrow`) к единому `LaunchBalanceGate`. +- Наблюдение: `SyncSupplierProjectJob::handleOnline` группирует по `is_active` без `whereNull('preflight_blocked_at')` — рассинхрон, если active+blocked возникнет (новые пути не создают). +- При будущем выкате АВТОПОДБОРА не забыть его launch-gate-части (AutopodborController::createProjects + AutopodborProjectCreator + автоподбор-фронт + идемпотентные box/phone миграции) — на проде их нет. +- Обновить `ПИЛОТ.md` записью о выкате (не сделано). + +--- + +## ТРЕК 2 — Автоподбор конкурентов — собран + живой движок РАБОТАЕТ, только в воркстри (НЕ прод) + +Ветка `worktree-avtopodbor`. Stage 1 (сверка находок) + Stage 2 (актуализация) + живой движок сбора (analyze→2ГИС→Яндекс→канал B sonar→EXA→сборка), прогон ~8 мин, 14 конкурентов, 300₽ верно. Коммит движка `b3f5158e`. **НЕ на проде.** Детали и квирки — память `project-autopodbor-engine-live-working-2026-07-02`. Как гнать живой сбор локально: `AUTOPODBOR_REAL_FIND=1`, дать xfetch остыть ~15 мин, `env -u EXA_API_KEY c:/tools/php83/php scripts/run-collect-live.php ` (env -u обязателен), демо `omega-demo@liderra.local`/`omega12345`. + +--- + +## ОКРУЖЕНИЕ / ПРОД / ТЕСТЫ (квирки) +- **Прод liderra.ru:** НЕ git-репо; код заливают tar/scp с dev + `bash /var/www/liderra/redeploy.sh` (composer+migrate+кэши+рестарт). Доступ `ssh liderra-prod` (GitHub suspended → только gitea/ssh). Прод код-файлы root:root (после tar с Windows → `sudo chown root:root`). config.php периодически пропадает (квирк 107) → `config:cache`. БД по умолчанию только чтение; изменения прода — только с разрешения владельца. Живая боевая БД = Yandex Managed PG (через app/.env), НЕ `sudo -u postgres` на VM. +- **Тесты бэк:** `DB_DATABASE=liderra_testing c:/tools/php83/php artisan test --filter=<Класс>` (УЗКО! широкий фильтр/`migrate` → 600с watchdog у субагентов). Тесты с записью через роль поставщика: `uses(Tests\Concerns\SharesSupplierPdo::class)`; PricingTier с `effective_from`. Пред-сущ красный baseline: партиция-флаки в больших прогонах (drop-expired vs create-months; хук `tests/TestCase.php::afterRefreshingDatabase` создаёт -3..+5 мес), `SchemaDeltaTest` (v8.55 vs v8.60). Фича зелёная в узких/изолированных прогонах. +- **Тесты фронт:** `npx vitest run tests/Frontend/<файл>.spec.ts` (тесты в `tests/Frontend/**`, НЕ рядом; полный `npm run test:vue` — долго, у субагентов столл). Пред-сущ красный baseline ~10 несвязанных фронт-файлов. +- **Субагенты:** реализацию кода делает АГЕНТ (Sonnet), контроллер оркестрирует+проверяет сам (не верить на слово — перепроверять диском/прогоном). Коммит — по «эскейп». Пуш — только gitea. Простой русский (владелец не программист). + +--- + +## 🧭 ПРОМТ ПРОДОЛЖЕНИЯ (скопировать в новый чат после компакта) + +> Продолжаем Лидерру. Два трека: (1) **launch-gate «баланс блокирует запуск, не создание»** — ЯДРО уже НА БОЕВОМ liderra.ru (gitea main `2384e3fb`, флаг `BILLING_LAUNCH_REQUIRES_ACTIVE_TIERS=true`); (2) **автоподбор конкурентов** — собран, живой движок работает, ТОЛЬКО в воркстри `worktree-avtopodbor`, НЕ на проде. +> +> 🔴 ПРОЧИТАЙ ПЕРВЫМ (durable, не по памяти): память `project-launch-gate-balance-2026-07-02` + `project-autopodbor-engine-live-working-2026-07-02`, и файл `docs/superpowers/plans/2026-07-02-SOSTOYANIE-i-promt-prodolzheniya.md` (этот). Спека launch-gate: `docs/superpowers/specs/2026-07-02-launch-gate-balance-design.md`; ранбук выката: `docs/superpowers/runbooks/2026-07-02-launch-gate-prod-deploy.md`. +> +> ПРАВИЛА: 🔴 боевой прод liderra.ru — доступ/изменения только с явного разрешения владельца, БД по умолчанию чтение. Реализацию кода делает АГЕНТ (Sonnet), я оркестрирую и сам перепроверяю. Коммит — спросив «эскейп». Пуш — только gitea (GitHub suspended). Простой русский. Тесты — узкими фильтрами (иначе субагенты виснут на 600с). +> +> ВОЗМОЖНЫЕ СЛЕДУЮЩИЕ ШАГИ (по решению владельца): (а) живьём проверить launch-gate на проде, когда менеджер заведёт первого клиента; (б) выкат автоподбора на бой (отдельная спланированная операция, не забыть его launch-gate-части); (в) DRY-сведение 2 копий правила гейта; (г) уборка пред-существующего красного baseline тестов; (д) обновить ПИЛОТ.md. Ничего необратимого/боевого — без явного слова владельца. From b2699f0d841427fb8c4bef5b5742f73214d84c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 2 Jul 2026 13:34:13 +0300 Subject: [PATCH 131/417] =?UTF-8?q?feat(y360):=20=D0=B1=D0=B0=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=D1=81=20=D0=BF=D0=BE=D1=87=D1=82=D1=8B=20=D0=AF=D0=BD?= =?UTF-8?q?=D0=B4=D0=B5=D0=BA=D1=81=20360=20=E2=80=94=20=D1=80=D1=83=D1=87?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=B2=D0=B2=D0=BE=D0=B4=20+=20=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=D0=9F=D0=BE=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit email — денежный сервис; сумма вписывается в админке «Система» (Yandex360BalanceStore), светофор по порогам, кнопка «Открыть оплату»/«Пополнить» → admin.yandex.ru/products. Робот-скрейпер отклонён (SPA Яндекса враждебен ботам + автопополнение защищает баланс). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Api/AdminDashboardController.php | 1 + .../Api/AdminSystemSettingsController.php | 15 +- .../Api/AdminYandex360BalanceController.php | 42 + .../External/RefreshExternalBalancesJob.php | 8 +- .../Services/External/SmtpLivenessProbe.php | 65 -- .../External/Yandex360BalanceProvider.php | 31 + .../External/Yandex360BalanceStore.php | 50 ++ app/config/services.php | 16 +- app/resources/js/api/admin.ts | 18 + .../js/views/admin/AdminDashboardView.vue | 2 +- .../js/views/admin/AdminSystemView.vue | 70 +- app/routes/web.php | 4 + .../Admin/AdminYandex360BalanceTest.php | 37 + .../RefreshExternalBalancesJobTest.php | 23 +- .../Unit/External/SmtpLivenessProbeTest.php | 26 - .../External/Yandex360BalanceProviderTest.php | 38 + .../External/Yandex360BalanceStoreTest.php | 36 + .../2026-07-02-yandex360-balance-cookie.md | 846 ++++++++++++++++++ ...6-07-02-yandex360-balance-cookie-design.md | 122 +++ 19 files changed, 1338 insertions(+), 112 deletions(-) create mode 100644 app/app/Http/Controllers/Api/AdminYandex360BalanceController.php delete mode 100644 app/app/Services/External/SmtpLivenessProbe.php create mode 100644 app/app/Services/External/Yandex360BalanceProvider.php create mode 100644 app/app/Services/External/Yandex360BalanceStore.php create mode 100644 app/tests/Feature/Admin/AdminYandex360BalanceTest.php delete mode 100644 app/tests/Unit/External/SmtpLivenessProbeTest.php create mode 100644 app/tests/Unit/External/Yandex360BalanceProviderTest.php create mode 100644 app/tests/Unit/External/Yandex360BalanceStoreTest.php create mode 100644 docs/superpowers/plans/2026-07-02-yandex360-balance-cookie.md create mode 100644 docs/superpowers/specs/2026-07-02-yandex360-balance-cookie-design.md diff --git a/app/app/Http/Controllers/Api/AdminDashboardController.php b/app/app/Http/Controllers/Api/AdminDashboardController.php index 93e84ca7..a4c54b2b 100644 --- a/app/app/Http/Controllers/Api/AdminDashboardController.php +++ b/app/app/Http/Controllers/Api/AdminDashboardController.php @@ -411,6 +411,7 @@ class AdminDashboardController extends Controller 'dadata' => (string) config('services.dadata.topup_url') ?: null, 'supplier' => (string) config('services.supplier.topup_url') ?: null, 'yandex_cloud' => $this->ycTopupUrl(), + 'email' => (string) config('services.yandex360.topup_url') ?: null, default => null, }; } diff --git a/app/app/Http/Controllers/Api/AdminSystemSettingsController.php b/app/app/Http/Controllers/Api/AdminSystemSettingsController.php index c031a259..d248487c 100644 --- a/app/app/Http/Controllers/Api/AdminSystemSettingsController.php +++ b/app/app/Http/Controllers/Api/AdminSystemSettingsController.php @@ -31,12 +31,21 @@ use Illuminate\Support\Facades\DB; */ class AdminSystemSettingsController extends Controller { + /** Ключи-секреты: значение не отдаём в общий список (хранится зашифрованно). */ + private const SENSITIVE_KEYS = ['supplier_webhook_secret']; + /** GET /api/admin/system-settings */ public function index(): JsonResponse { - return response()->json([ - 'settings' => SystemSetting::orderBy('key')->get(), - ]); + $settings = SystemSetting::orderBy('key')->get()->map(function (SystemSetting $s) { + if (in_array($s->key, self::SENSITIVE_KEYS, true)) { + $s->value = $s->value === '' ? '' : '••• (скрыто)'; + } + + return $s; + }); + + return response()->json(['settings' => $settings]); } /** PUT /api/admin/system-settings/{key} */ diff --git a/app/app/Http/Controllers/Api/AdminYandex360BalanceController.php b/app/app/Http/Controllers/Api/AdminYandex360BalanceController.php new file mode 100644 index 00000000..21842f0f --- /dev/null +++ b/app/app/Http/Controllers/Api/AdminYandex360BalanceController.php @@ -0,0 +1,42 @@ +json($this->store->status()); + } + + /** PUT /api/admin/settings/yandex360-balance — {balance:number|null} */ + public function update(Request $request): JsonResponse + { + $raw = $request->input('balance'); + $balance = ($raw === null || $raw === '') ? null : (float) $raw; + if ($balance !== null && $balance < 0) { + return response()->json([ + 'message' => 'Баланс не может быть отрицательным.', + 'errors' => ['balance' => ['Введите сумму ≥ 0 или очистите поле.']], + ], 422); + } + + $this->store->set($balance); + + return response()->json($this->store->status()); + } +} diff --git a/app/app/Jobs/External/RefreshExternalBalancesJob.php b/app/app/Jobs/External/RefreshExternalBalancesJob.php index beb1d387..5487b6ac 100644 --- a/app/app/Jobs/External/RefreshExternalBalancesJob.php +++ b/app/app/Jobs/External/RefreshExternalBalancesJob.php @@ -11,8 +11,8 @@ use App\Services\External\CaptchaLivenessProbe; use App\Services\External\DadataBalanceProvider; use App\Services\External\JivoLivenessProbe; use App\Services\External\LivenessProbe; -use App\Services\External\SmtpLivenessProbe; use App\Services\External\SupplierBalanceProvider; +use App\Services\External\Yandex360BalanceProvider; use App\Services\External\YandexCloudBalanceProvider; use App\Services\External\YooKassaLivenessProbe; use Illuminate\Bus\Queueable; @@ -44,6 +44,7 @@ class RefreshExternalBalancesJob implements ShouldQueue DadataBalanceProvider::class, SupplierBalanceProvider::class, YandexCloudBalanceProvider::class, + Yandex360BalanceProvider::class, ]; } @@ -69,7 +70,6 @@ class RefreshExternalBalancesJob implements ShouldQueue } return [ - app(SmtpLivenessProbe::class), app(YooKassaLivenessProbe::class), app(JivoLivenessProbe::class), app(CaptchaLivenessProbe::class), @@ -189,6 +189,10 @@ class RefreshExternalBalancesJob implements ShouldQueue (float) config('services.supplier.red_floor_rub', 5000), (float) config('services.supplier.amber_floor_rub', 15000), ], + 'email' => [ + (float) config('services.yandex360.red_floor_rub', 500), + (float) config('services.yandex360.amber_floor_rub', 2000), + ], default => [0.0, 0.0], }; } diff --git a/app/app/Services/External/SmtpLivenessProbe.php b/app/app/Services/External/SmtpLivenessProbe.php deleted file mode 100644 index aebcd5c8..00000000 --- a/app/app/Services/External/SmtpLivenessProbe.php +++ /dev/null @@ -1,65 +0,0 @@ -connector = $connector; - } - - public function serviceKey(): string - { - return 'email'; - } - - public function check(): LivenessReading - { - try { - $banner = ($this->connector ?? $this->defaultConnector())(); - if (! str_starts_with(ltrim($banner), '220')) { - return LivenessReading::down('email', 'SMTP-баннер не 220: '.mb_substr(trim($banner), 0, 120)); - } - - return LivenessReading::alive('email', 'SMTP отвечает'); - } catch (\Throwable $e) { - return LivenessReading::down('email', $e->getMessage()); - } - } - - /** @return callable():string */ - private function defaultConnector(): callable - { - return function (): string { - $host = (string) config('services.smtp_probe.host'); - $port = (int) config('services.smtp_probe.port'); - $timeout = (int) config('services.smtp_probe.timeout', 5); - // 465 — implicit TLS; ssl:// нужен на connect. - $scheme = $port === 465 ? 'ssl://' : 'tcp://'; - $fp = @stream_socket_client($scheme.$host.':'.$port, $errno, $errstr, $timeout); - if ($fp === false) { - throw new \RuntimeException($errstr !== '' ? $errstr : 'Connection refused (errno '.$errno.')'); - } - try { - stream_set_timeout($fp, $timeout); - $line = fgets($fp, 512); - - return $line === false ? '' : $line; - } finally { - fclose($fp); - } - }; - } -} diff --git a/app/app/Services/External/Yandex360BalanceProvider.php b/app/app/Services/External/Yandex360BalanceProvider.php new file mode 100644 index 00000000..9b4dc7e9 --- /dev/null +++ b/app/app/Services/External/Yandex360BalanceProvider.php @@ -0,0 +1,31 @@ +store->get(); + if ($balance === null) { + return BalanceReading::fail('email', 'Баланс почты не задан — введите вручную в админке (Система)'); + } + + return BalanceReading::ok('email', $balance, 'RUB', null); + } +} diff --git a/app/app/Services/External/Yandex360BalanceStore.php b/app/app/Services/External/Yandex360BalanceStore.php new file mode 100644 index 00000000..19556300 --- /dev/null +++ b/app/app/Services/External/Yandex360BalanceStore.php @@ -0,0 +1,50 @@ +value === '') { + return null; + } + + return is_numeric($row->value) ? (float) $row->value : null; + } + + /** Сохранить сумму. null или пусто → очистить. */ + public function set(?float $balance): void + { + $value = $balance === null ? '' : (string) $balance; + SystemSetting::updateOrCreate( + ['key' => self::KEY], + ['value' => $value, 'type' => 'decimal', 'updated_at' => now()], + ); + } + + /** @return array{balance:?float,updated_at:?string} */ + public function status(): array + { + $row = SystemSetting::find(self::KEY); + $set = $row !== null && (string) $row->value !== '' && is_numeric($row->value); + + return [ + 'balance' => $set ? (float) $row->value : null, + 'updated_at' => $set && $row->updated_at ? $row->updated_at->toIso8601String() : null, + ]; + } +} diff --git a/app/config/services.php b/app/config/services.php index bb93530b..0f7557f1 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -95,14 +95,6 @@ return [ 'amber_floor_rub' => (int) env('YC_AMBER_FLOOR_RUB', 5000), ], - // Healthcheck доступности SMTP (Yandex 360) для плитки внешних сервисов. - // Только connect+баннер, без логина/отправки. Дефолты — под Yandex 360. - 'smtp_probe' => [ - 'host' => env('SMTP_PROBE_HOST', env('MAIL_HOST', 'smtp.yandex.ru')), - 'port' => (int) env('SMTP_PROBE_PORT', 465), - 'timeout' => (int) env('SMTP_PROBE_TIMEOUT', 5), - ], - // G7-A: клиентская «Помощь». 'support' => [ 'email' => env('SUPPORT_EMAIL', 'support@liderra.ru'), @@ -131,4 +123,12 @@ return [ 'alert_email' => env('MONITORING_ALERT_EMAIL', env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru')), ], + // Баланс Яндекс 360 (почта) — РУЧНОЙ ввод (см. Yandex360BalanceProvider). Пороги + // светофора — как у dadata; topup_url — страница «Оплата и тариф» в кабинете Яндекс 360. + 'yandex360' => [ + 'red_floor_rub' => (int) env('YANDEX360_RED_FLOOR_RUB', 500), + 'amber_floor_rub' => (int) env('YANDEX360_AMBER_FLOOR_RUB', 2000), + 'topup_url' => env('YANDEX360_TOPUP_URL', 'https://admin.yandex.ru/products'), + ], + ]; diff --git a/app/resources/js/api/admin.ts b/app/resources/js/api/admin.ts index c6e109f5..086fd4a9 100644 --- a/app/resources/js/api/admin.ts +++ b/app/resources/js/api/admin.ts @@ -312,6 +312,24 @@ export async function listSystemSettings(): Promise { return data.settings; } +export interface Yandex360BalanceStatus { + balance: number | null; + updated_at: string | null; +} + +/** Ручной баланс Яндекс 360 (почта): текущее значение + когда обновляли. */ +export async function getYandex360Balance(): Promise { + const { data } = await apiClient.get('/api/admin/settings/yandex360-balance'); + return data; +} + +/** Сохранить ручной баланс Яндекс 360 (null — очистить). Возвращает новый статус. */ +export async function setYandex360Balance(balance: number | null): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.put('/api/admin/settings/yandex360-balance', { balance }); + return data; +} + export interface UpdateSystemSettingPayload { value: string; reason: string; // ≥30 chars diff --git a/app/resources/js/views/admin/AdminDashboardView.vue b/app/resources/js/views/admin/AdminDashboardView.vue index 00993904..33d49375 100644 --- a/app/resources/js/views/admin/AdminDashboardView.vue +++ b/app/resources/js/views/admin/AdminDashboardView.vue @@ -131,7 +131,7 @@ function daysLeftLabel(days: number | null): string { } /** Сервисы БЕЗ денежного баланса — следим только за живостью (не за деньгами). */ -const LIVENESS_ONLY_KEYS = new Set(['email', 'yookassa', 'jivosite', 'captcha']); +const LIVENESS_ONLY_KEYS = new Set(['yookassa', 'jivosite', 'captcha']); function isLivenessOnly(key: string): boolean { return LIVENESS_ONLY_KEYS.has(key); } diff --git a/app/resources/js/views/admin/AdminSystemView.vue b/app/resources/js/views/admin/AdminSystemView.vue index 73b2ad40..0ff873a9 100644 --- a/app/resources/js/views/admin/AdminSystemView.vue +++ b/app/resources/js/views/admin/AdminSystemView.vue @@ -88,7 +88,40 @@ function onSettingUpdated(payload: { key: string; value: string; updated_at: str } } -defineExpose({ settingsState, editOpen, editSetting, openEdit, onSettingUpdated, loadSettings, loading, fetchError }); +// ─── Ручной баланс Яндекс 360 (почта) ─────────────────────────────────────── +const y360Input = ref(''); +const y360Status = ref({ balance: null, updated_at: null }); +const y360Saving = ref(false); +const y360Error = ref(null); +const Y360_TOPUP_URL = 'https://admin.yandex.ru/products'; + +async function loadY360Status() { + try { + y360Status.value = await adminApi.getYandex360Balance(); + y360Input.value = y360Status.value.balance !== null ? String(y360Status.value.balance) : ''; + } catch { + // тихо: статус не критичен для экрана + } +} + +async function saveY360Balance() { + y360Saving.value = true; + y360Error.value = null; + try { + const val = y360Input.value.trim() === '' ? null : Number(y360Input.value.replace(',', '.')); + y360Status.value = await adminApi.setYandex360Balance(val); + } catch (err) { + y360Error.value = extractErrorMessage(err, 'Не удалось сохранить баланс.'); + } finally { + y360Saving.value = false; + } +} + +onMounted(() => { + loadY360Status(); +}); + +defineExpose({ settingsState, editOpen, editSetting, openEdit, onSettingUpdated, loadSettings, loading, fetchError, y360Input, y360Status, saveY360Balance });