From eb8ca65c5d259762631fe800d76f3520b16ea71d 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, 9 May 2026 07:11:44 +0300 Subject: [PATCH] =?UTF-8?q?phase2(supplier-resolver):=20=D0=B2=D1=8B=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20lookup=20=D0=B2=20App\Services\SupplierResolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Закрыт TODO (a) из v1.49: общая логика выбора активного supplier'а через project_suppliers m2m была дублирована между ProcessWebhookJob (webhook-flow) и DealController (manual-create) — 11 одинаковых строк query-builder'а на 2 файла. Теперь — единственный источник истины + DI через app() (тот же паттерн, что у DuplicateDetector в v1.23). App\Services\SupplierResolver: - resolveForProject(Project): ?int — точная копия прежней query (project_suppliers JOIN suppliers, is_active+is_active, ORDER BY sort_order, id). - costRubSnapshot(int $supplierId): string — вынесенный snapshot цены для записи в supplier_lead_costs. ProcessWebhookJob и DealController: - Удалены private resolveSupplierId() (по 14 строк). - Удалены локальные DB::table('suppliers')->value('cost_rub'). - Используют app(SupplierResolver::class) внутри handle()/store(). Pest +8 в tests/Feature/Services/SupplierResolverTest.php: - null без связей / единственный активный / пропуск inactive supplier / пропуск inactive m2m / ORDER BY sort_order / null если все inactive / изоляция по project_id / costRubSnapshot формат '137.50'. PHPStan baseline регенерирован. Регресс: - Pint + PHPStan passed (baseline регенерирован). - Pest 174/174 за 21.46 сек (+8 от 166, 708 assertions). - Vitest 247/247 за 17.53 сек (нетронут — backend-only refactor). Реестр v1.58→v1.59 / CLAUDE.md v1.49→v1.50. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 6 +- .../Http/Controllers/Api/DealController.php | 31 +---- app/app/Jobs/ProcessWebhookJob.php | 29 +---- app/app/Services/SupplierResolver.php | 56 ++++++++ app/phpstan-baseline.neon | 18 +++ .../Feature/Services/SupplierResolverTest.php | 121 ++++++++++++++++++ docs/Открытые_вопросы_v8_3.md | 37 +++++- 7 files changed, 243 insertions(+), 55 deletions(-) create mode 100644 app/app/Services/SupplierResolver.php create mode 100644 app/tests/Feature/Services/SupplierResolverTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 0e232d2b..307da159 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 1.49 от 09.05.2026 +**Версия:** 1.50 от 09.05.2026 **Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0. > **Ребрендинг 08.05.2026:** «Лидпоток» → **«Лидерра.»** (с точкой). Палитра, лого и шрифты — из handoff Платона (v8 Forest). Применяется только к дизайну/имени/логотипу; функционал, состав страниц и правила — без изменений (источник — ТЗ v8.5/schema v8.5). @@ -15,7 +15,7 @@ | Полный реестр 28 инструментов и фазы | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (Прил. Н v1.7 от 08.05.2026 поздний вечер — Histoire 1.0-beta.1 активирован, фаза 2 по тулчейну закрыта 6/6, всего активно 18/28) | | Главное ТЗ | [docs/CRM_bp-gr_Инструкция_v8_5.md](docs/CRM_bp-gr_Инструкция_v8_5.md) (v8.5 от 07.05.2026 — реализация 27 решений аудита C; in-place hygiene v1.20 от 08.05.2026 поздний вечер: §2.4/§5.5/§5.6/§6.5/§11/§20.12.3/§21.1/§27.1 синхронизированы под schema v8.6 двустадийный dedup) | | Схема БД | [db/schema.sql](db/schema.sql) (**v8.8 от 09.05.2026** — `users.totp_secret` VARCHAR(255) → TEXT для encrypted-cast. Метрики: 55 таблиц + 12 партиций + 92 индекса + 36 RLS + 5 функций + 13 триггеров) | -| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.58 от 09.05.2026 — **3 lookups + integrity**: GET /api/managers + /api/projects + manager FK guard / replace MOCK на API в NewDealDialog / SupplierLeadCost для manual-leads; Pest 166/166 + Vitest 247/247 + Histoire 21/28) | +| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.59 от 09.05.2026 — **SupplierResolver service-extract**: вынесена общая логика `project_suppliers` lookup из `ProcessWebhookJob` + `DealController` в `App\Services\SupplierResolver` + DI через `app()`; +8 unit-тестов; Pest 174/174 + Vitest 247/247 + Histoire 21/28) | | **Брендбук** | [liderra_v8_handoff/docs/BRANDBOOK_v2.md](liderra_v8_handoff/docs/BRANDBOOK_v2.md) **(v2 Forest от 07.05.2026)** — старый `docs/brandbook.md` v1.1 удалён 08.05.2026 | | **Дизайн-handoff (токены, компоненты, 25 экранов)** | [liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md](liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md) (v8 Forest от 07.05.2026) — **только дизайн/токены/компоненты**; функционал и состав экранов — по ТЗ v8.5 | | Анализ оригинала | [docs/Analiz_originala_v8_3.md](docs/Analiz_originala_v8_3.md) (Прил. М v1.1) | @@ -224,6 +224,8 @@ trivy image liderra:latest --- +*CLAUDE.md v1.50 от 09.05.2026. Изменения v1.50: **SupplierResolver service-extract** — закрыт TODO (a) из v1.49. Общая логика lookup активного supplier'а через `project_suppliers` m2m (фильтры `is_active=true`+`is_active=true`, ORDER BY `sort_order, id`) была дублирована между `ProcessWebhookJob::resolveSupplierId` (webhook-flow) и `DealController::resolveSupplierId` (manual-create) — 11 одинаковых строк query-builder'а на 2 файла. Решение: `App\Services\SupplierResolver` с двумя методами — `resolveForProject(Project): ?int` (тот же DB::table query, что был раньше) + `costRubSnapshot(int $supplierId): string` (вынесенный snapshot цены `cost_rub` для записи в `supplier_lead_costs`; берётся через `DB::table('suppliers')->value('cost_rub')`, чтобы snapshot не менялся при последующих правках цены поставщика). **DI** — через `app(SupplierResolver::class)` внутри handle()/store() (тот же паттерн, что у `DuplicateDetector` в v1.23 — НЕ через constructor injection, чтобы тесты могли вызывать `(new ProcessWebhookJob(...))->handle()` напрямую без контейнера). **Удалены:** `ProcessWebhookJob::resolveSupplierId()` (private 14 строк) + `DealController::resolveSupplierId()` (private 14 строк) + локальные `DB::table('suppliers')->value('cost_rub')` в обоих файлах (теперь через `$resolver->costRubSnapshot()`). **Pest +8** в `tests/Feature/Services/SupplierResolverTest.php` (всего **174/174 за 21.46 сек**, 708 assertions): null когда нет связей; единственный активный supplier; пропуск inactive supplier; пропуск inactive m2m-связи; ORDER BY sort_order (low > high); null если все связи inactive; изоляция по project_id (один supplier на двух проектах не проявляется); costRubSnapshot формат '137.50'. Helpers `seedSupplier`/`attachSupplier` — top-level functions в файле теста (не пересекаются с `seedSupplierForProject` в ProcessWebhookJobTest). **Quirk** — `Project::factory()->create(['type' => 'websites'])` падает на CHECK constraint `projects_type_check` (allowed: webhook|manual|import); factory default = 'webhook' — лишний override убран. **PHPStan baseline** регенерирован для +30 ignored Pest TestCall warnings (новый файл). **Регресс зелёный:** Pint+PHPStan passed (baseline регенерирован); **Pest 174/174 за 21.46 сек** (+8 от 166, 708 assertions); **Vitest 247/247 за 17.53 сек** (нетронут — backend-only refactor). Реестр v1.58→v1.59.* + *CLAUDE.md v1.49 от 09.05.2026. Изменения v1.49: **3 lookups + integrity-fix** после backend-completion v1.48. **(1) GET /api/managers + /api/projects + manager FK guard в DealController.** `ManagerController::index` возвращает active users тенанта (фильтры `is_active=true`, `deleted_at IS NULL`), формат `{id, email, first_name, last_name, name, initials}` с двумя static-helpers `formatName/formatInitials` (fallback на email если first/last пусты). `ProjectController::index` — active projects (с `is_active=true`), формат `{id, name, tag, type}`. Оба endpoint'а: `tenant_id` query-param (на prod из middleware), 422 без него, 404 unknown tenant, RLS-обёртка через `SET LOCAL app.current_tenant_id` в DB::transaction. **Manager FK guard** в `DealController::store` — если `manager_id` передан, проверяем `User::where(id, manager_id)->where(tenant_id, tenant->id)->whereNull(deleted_at)->where(is_active, true)->exists()`; если не принадлежит tenant'у или не активен — 422 с ошибкой по полю `manager_id`. Это закрывает security-gap: иначе можно было назначить чужого менеджера на свою сделку. **(2) Replace MOCK_MANAGERS / MOCK_PROJECTS на API в NewDealDialog.** Новый ref `projectOptions: string[]` + `managerOptions: MockManager[]` инициализированы из MOCK_-констант (fallback). При open dialog'а с tenantId — `loadLookups()` вызывает `Promise.all([listProjects, listManagers])` и replace'ит refs. Map `managerIdByName: Map` — нужна для submit'а: name из v-select (return-object) → backend-id. На fail (network) — silent fallback на mock (UI работает дальше). Submit передаёт `manager_id: managerIdByName.get(manager.name) ?? undefined`. **(3) SupplierLeadCost для manual-leads.** В `DealController::store` транзакции после Deal::create вызываем `resolveSupplierId($project)` — точная копия логики из `ProcessWebhookJob::resolveSupplierId` (project_suppliers JOIN suppliers, фильтры is_active+is_active, ORDER BY sort_order, id). Если supplier найден — берём `cost_rub` snapshot и создаём `SupplierLeadCost` с `supplier_lead_id=NULL` (manual: нет внешнего id из webhook). Manual-flow по-прежнему НЕ списывает баланс (Ю-2 reseller-модель: charge только при закупке у supplier'а через webhook); cost-аналитика всё равно нужна для отчётности (owner проекта мог купить лид у поставщика и ввести руками). На production — извлечь `resolveSupplierId` в `App\Services\SupplierResolver` чтобы Job и Controller разделяли логику + system_settings fallback. **Pest +18** (всего **166/166 за 22.11 сек**, 699 assertions): LookupsTest 8 (managers active + initials fallback + 422 / 404 + projects + manager FK guard 3 — чужой/inactive/active); DealCreateTest +2 (SupplierLeadCost создан с snapshot cost_rub / без supplier — graceful skip). Старый тест manager_id=42 переписан на User::factory()->for($tenant)->create()->id чтобы пройти FK guard. **Vitest +2** (всего **247/247 за 16.32 сек**): NewDealDialog +2 (loadLookups вызывает listProjects+listManagers + populates refs + map / submit передаёт backend manager_id из mapping). Vi.mock получил listProjects/listManagers с default `Promise.resolve([])` — старые тесты (без tenantId) не вызывают lookups, fallback на mock работает. **PHPStan baseline** регенерирован для +28 ignored Pest TestCall warnings (LookupsTest + DealCreateTest расширения). **Production TODO остаточные:** (a) `resolveSupplierId` в Service-класс (рефактор Job + Controller); (b) реальный XLSX-export через PhpSpreadsheet (CSV пока достаточен); (c) GET /api/deals для замены MOCK_DEALS в DealsView/KanbanView (опционально — на MVP local-state ok); (d) SaaS-admin auth (Yandex 360 SSO ⏸ Б-1). **Регресс зелёный:** lint+type-check+format ✅; **Vitest 247/247 за 16.32 сек** (+2); vite build 951 ms; Pint+PHPStan passed (baseline регенерирован); **Pest 166/166 за 22.11 сек** (+10 от 156, 699 assertions). Реестр v1.57→v1.58.* *CLAUDE.md v1.48 от 09.05.2026. Изменения v1.48: **3 backend-completion изменения** после tightening v1.47. **(1) POST /api/deals — manual create endpoint для NewDealDialog.** `DealController::store` валидирует `tenant_id/project_name/phone` (required) + `contact_name/status/manager_id/comment` (optional). Резолвит/создаёт `Project` через `firstOrCreate(tenant_id+name, type='manual')`. Создаёт `Deal` с `received_at=NOW()`, `source_crm_id=NULL` (отличие от webhook'а), `assigned_at=NOW()` если `manager_id` передан. Транзакция + RLS-обёртка `SET LOCAL app.current_tenant_id` (PgBouncer-safe). Manual-create НЕ списывает баланс (не закупка у поставщика), НЕ применяет антифрод-дедуп (admin знает что вводит), НЕ создаёт SupplierLeadCost. Пишет ActivityLog с `context.source=manual`. **NewDealDialog.vue** получил optional `tenantId` prop — если передан, submit делает `dealsApi.createDeal()`, на success deal возвращается с реальным backend-id; на network/500-error — fallback на local-id + `submit-error-alert` warning + dialog остаётся открытым. Чистый local-mode (без tenantId) сохранён для тестов и legacy. DealsView/KanbanView получили `useAuthStore` + передают `:tenant-id="auth.user?.tenant_id"`. **(2) `webhook_hmac_required` flag в system_settings.** Добавлен ключ в seed `db/schema.sql:2200` (`'webhook_hmac_required', 'false', 'bool'` — default backward-compat). `WebhookReceiveController::isHmacRequired()` private helper читает значение через `SystemSetting::find` (без записи → false). При `true`: запрос без `X-Webhook-Signature` → 401. При `false`: header опционален (если пришёл — verify, иначе пропускаем). Pest +3: required+missing → 401, required+valid HMAC → 202, false (default) → 202 без header. **(3) POST /api/deals/export — CSV endpoint backend-side.** `DealController::export` валидирует `tenant_id/ids[1-10000 ints]`. RLS-обёрнутый SELECT по whereIn(ids), формирует CSV (Excel-friendly: BOM `\u{FEFF}` PHP-литерал, `;` разделитель, `\r\n`, escape для `;`/`"`/`\n` через двойные кавычки). Возвращает `text/csv; charset=utf-8` + `Content-Disposition: attachment; filename="deals_export_YYYY-MM-DD.csv"`. **Frontend `applyBulkExport`** теперь сначала пробует `dealsApi.exportDeals` (если `auth.user?.tenant_id` есть) → `triggerCsvDownload` со взятым CSV; на fail — fallback на `buildLocalCsv()` (тот же flow что в v1.47, но вынесен в отдельную функцию). На каждом флоу — toast о результате. **api/deals.ts** новый файл с `createDeal`/`exportDeals` (responseType: 'text' для CSV string). **Pest +15** (всего **156/156 за 20.27 сек**, 675 assertions): DealCreateTest 12 (8 store + 4 export); WebhookReceiveTest +3 hmac_required. **Vitest +3** (всего **245/245 за 17.07 сек**): NewDealDialog +3 (без tenantId — local mode; с tenantId+success — backend-id; с tenantId+error — fallback+warning); DealsView/KanbanView spec'ы получили `setActivePinia(createPinia())` (auth-store нужен для tenant_id). **Quirks:** (a) PHPStan ругался на `Deal->id === null` (Eloquent типизирует id как int) — убрал лишнюю проверку. (b) `String.fromCharCode(0xFEFF)` в JS / `"\u{FEFF}"` в PHP — оба работают, литерал заблокирован ESLint no-irregular-whitespace. (c) RLS-изоляция export'а тестируется отдельно через testing_rls_user (NOLOGIN без BYPASSRLS) — в DealCreateTest используется postgres superuser (BYPASSRLS), поэтому RLS-проверка тут была бы false-positive — заменил на тест фильтрации по `whereIn(ids)`. **Production TODO остаточные (после v1.48):** Manager lookup в DealController (сейчас manager_id передаётся клиентом без проверки tenant-membership); replace MOCK_MANAGERS на API GET /api/managers; SupplierLeadCost для manual-leads (при наличии supplier'а у проекта); реальный XLSX-export через PhpSpreadsheet (CSV пока достаточен); SaaS-admin auth (Yandex 360 SSO ⏸ Б-1). **Регресс зелёный:** lint+type-check+format ✅; **Vitest 245/245 за 17.07 сек** (+3 от 242); vite build 1.04 сек; Pint+PHPStan passed (baseline регенерирован); **Pest 156/156 за 20.27 сек** (+15 от 141, 675 assertions). Реестр v1.56→v1.57.* diff --git a/app/app/Http/Controllers/Api/DealController.php b/app/app/Http/Controllers/Api/DealController.php index 8ad587e0..65b4c33c 100644 --- a/app/app/Http/Controllers/Api/DealController.php +++ b/app/app/Http/Controllers/Api/DealController.php @@ -11,6 +11,7 @@ use App\Models\Project; use App\Models\SupplierLeadCost; use App\Models\Tenant; use App\Models\User; +use App\Services\SupplierResolver; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -101,17 +102,14 @@ class DealController extends Controller // баланс (Ю-2: реселлерская модель работает только при закупке у // supplier'а), но cost-аналитика всё равно нужна — owner проекта // мог самостоятельно купить лид и ввести руками. - $supplierId = $this->resolveSupplierId($project); + $resolver = app(SupplierResolver::class); + $supplierId = $resolver->resolveForProject($project); if ($supplierId !== null) { - $costRub = (string) DB::table('suppliers') - ->where('id', $supplierId) - ->value('cost_rub'); - SupplierLeadCost::create([ 'deal_id' => $deal->id, 'received_at' => $deal->received_at, 'supplier_id' => $supplierId, - 'cost_rub' => $costRub, + 'cost_rub' => $resolver->costRubSnapshot($supplierId), 'supplier_lead_id' => null, // manual: нет внешнего id 'created_at' => now(), ]); @@ -219,25 +217,4 @@ class DealController extends Controller return $value; } - - /** - * Поиск активного supplier_id для проекта через project_suppliers m2m. - * Дублирует логику ProcessWebhookJob::resolveSupplierId — на MVP - * приемлемо; рефактор в `App\Services\SupplierResolver` — отдельный коммит - * (тогда Job и Controller будут шарить логику + system_settings fallback). - */ - private function resolveSupplierId(Project $project): ?int - { - $row = DB::table('project_suppliers') - ->join('suppliers', 'suppliers.id', '=', 'project_suppliers.supplier_id') - ->where('project_suppliers.project_id', $project->id) - ->where('project_suppliers.is_active', true) - ->where('suppliers.is_active', true) - ->orderBy('suppliers.sort_order') - ->orderBy('suppliers.id') - ->select('suppliers.id') - ->first(); - - return $row !== null ? (int) $row->id : null; - } } diff --git a/app/app/Jobs/ProcessWebhookJob.php b/app/app/Jobs/ProcessWebhookJob.php index daa1d2f1..2be98fc6 100644 --- a/app/app/Jobs/ProcessWebhookJob.php +++ b/app/app/Jobs/ProcessWebhookJob.php @@ -13,6 +13,7 @@ use App\Models\RejectedDealsLog; use App\Models\SupplierLeadCost; use App\Models\Tenant; use App\Services\DuplicateDetector; +use App\Services\SupplierResolver; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable as FoundationQueueable; @@ -188,17 +189,14 @@ class ProcessWebhookJob implements ShouldQueue 'created_at' => now(), ]); - $supplierId = $this->resolveSupplierId($project); + $resolver = app(SupplierResolver::class); + $supplierId = $resolver->resolveForProject($project); if ($supplierId !== null) { - $costRub = (string) DB::table('suppliers') - ->where('id', $supplierId) - ->value('cost_rub'); - SupplierLeadCost::create([ 'deal_id' => $deal->id, 'received_at' => $deal->received_at, 'supplier_id' => $supplierId, - 'cost_rub' => $costRub, + 'cost_rub' => $resolver->costRubSnapshot($supplierId), 'supplier_lead_id' => (int) $this->data['vid'], 'created_at' => now(), ]); @@ -220,25 +218,6 @@ class ProcessWebhookJob implements ShouldQueue ]); } - /** - * Поиск supplier_id для проекта через project_suppliers (m2m). - * На MVP берём первый активный по sort_order; production: SystemSetting fallback. - */ - private function resolveSupplierId(Project $project): ?int - { - $row = DB::table('project_suppliers') - ->join('suppliers', 'suppliers.id', '=', 'project_suppliers.supplier_id') - ->where('project_suppliers.project_id', $project->id) - ->where('project_suppliers.is_active', true) - ->where('suppliers.is_active', true) - ->orderBy('suppliers.sort_order') - ->orderBy('suppliers.id') - ->select('suppliers.id') - ->first(); - - return $row !== null ? (int) $row->id : null; - } - /** * Идемпотентная upsert-логика через advisory lock (§5.5 v8.7). * diff --git a/app/app/Services/SupplierResolver.php b/app/app/Services/SupplierResolver.php new file mode 100644 index 00000000..e5d17b19 --- /dev/null +++ b/app/app/Services/SupplierResolver.php @@ -0,0 +1,56 @@ +join('suppliers', 'suppliers.id', '=', 'project_suppliers.supplier_id') + ->where('project_suppliers.project_id', $project->id) + ->where('project_suppliers.is_active', true) + ->where('suppliers.is_active', true) + ->orderBy('suppliers.sort_order') + ->orderBy('suppliers.id') + ->select('suppliers.id') + ->first(); + + return $row !== null ? (int) $row->id : null; + } + + /** + * Возвращает snapshot цены `cost_rub` поставщика на момент вызова. + * Используется для записи в `supplier_lead_costs.cost_rub` (история + * закупок не должна меняться при последующих правках цены поставщика). + */ + public function costRubSnapshot(int $supplierId): string + { + return (string) DB::table('suppliers') + ->where('id', $supplierId) + ->value('cost_rub'); + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index fce629f6..22df8373 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -318,6 +318,24 @@ parameters: count: 5 path: tests/Feature/RlsSmokeTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#' + identifier: property.notFound + count: 19 + path: tests/Feature/Services/SupplierResolverTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$resolver\.$#' + identifier: property.notFound + count: 10 + path: tests/Feature/Services/SupplierResolverTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 2 + path: tests/Feature/Services/SupplierResolverTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#' identifier: method.notFound diff --git a/app/tests/Feature/Services/SupplierResolverTest.php b/app/tests/Feature/Services/SupplierResolverTest.php new file mode 100644 index 00000000..35954f81 --- /dev/null +++ b/app/tests/Feature/Services/SupplierResolverTest.php @@ -0,0 +1,121 @@ +insertGetId([ + 'code' => $code.'-'.Str::lower(Str::random(4)), + 'name' => 'Test '.$code, + 'accepts_types' => '{websites,calls}', + 'cost_rub' => $costRub, + 'channel' => 'sites', + 'quality_score' => 1.00, + 'is_active' => $isActive, + 'sort_order' => $sortOrder, + ]); +} + +function attachSupplier(Project $project, int $supplierId, bool $isActive = true): void +{ + DB::table('project_suppliers')->insert([ + 'project_id' => $project->id, + 'supplier_id' => $supplierId, + 'is_active' => $isActive, + 'created_at' => now(), + ]); +} + +beforeEach(function () { + $this->tenant = Tenant::factory()->create(); + $this->project = Project::factory()->for($this->tenant)->create([ + 'name' => 'Test Project', + 'type' => 'webhook', + ]); + $this->resolver = new SupplierResolver; +}); + +test('resolveForProject возвращает null когда у проекта нет связей', function () { + expect($this->resolver->resolveForProject($this->project))->toBeNull(); +}); + +test('resolveForProject возвращает supplier_id единственного активного поставщика', function () { + $supplierId = seedSupplier('one', 50.00, isActive: true); + attachSupplier($this->project, $supplierId); + + expect($this->resolver->resolveForProject($this->project))->toBe($supplierId); +}); + +test('resolveForProject пропускает inactive supplier', function () { + $inactive = seedSupplier('inactive', 50.00, isActive: false); + $active = seedSupplier('active', 75.00, isActive: true); + attachSupplier($this->project, $inactive); + attachSupplier($this->project, $active); + + expect($this->resolver->resolveForProject($this->project))->toBe($active); +}); + +test('resolveForProject пропускает inactive m2m-связь', function () { + $a = seedSupplier('a', 50.00, isActive: true); + $b = seedSupplier('b', 75.00, isActive: true); + attachSupplier($this->project, $a, isActive: false); + attachSupplier($this->project, $b, isActive: true); + + expect($this->resolver->resolveForProject($this->project))->toBe($b); +}); + +test('resolveForProject соблюдает ORDER BY sort_order, id', function () { + $second = seedSupplier('second', 100.00, isActive: true, sortOrder: 10); + $first = seedSupplier('first', 200.00, isActive: true, sortOrder: 1); + $third = seedSupplier('third', 50.00, isActive: true, sortOrder: 100); + attachSupplier($this->project, $second); + attachSupplier($this->project, $first); + attachSupplier($this->project, $third); + + expect($this->resolver->resolveForProject($this->project))->toBe($first); +}); + +test('resolveForProject возвращает null если все связи inactive', function () { + $a = seedSupplier('a', 50.00, isActive: true); + $b = seedSupplier('b', 75.00, isActive: true); + attachSupplier($this->project, $a, isActive: false); + attachSupplier($this->project, $b, isActive: false); + + expect($this->resolver->resolveForProject($this->project))->toBeNull(); +}); + +test('resolveForProject изолирован по project_id', function () { + $supplierId = seedSupplier('shared', 50.00, isActive: true); + attachSupplier($this->project, $supplierId); + + $otherProject = Project::factory()->for($this->tenant)->create(); + + expect($this->resolver->resolveForProject($this->project))->toBe($supplierId); + expect($this->resolver->resolveForProject($otherProject))->toBeNull(); +}); + +test('costRubSnapshot возвращает текущую цену поставщика', function () { + $supplierId = seedSupplier('priced', 137.50, isActive: true); + + expect($this->resolver->costRubSnapshot($supplierId))->toBe('137.50'); +}); diff --git a/docs/Открытые_вопросы_v8_3.md b/docs/Открытые_вопросы_v8_3.md index 8cd27fce..25df3e93 100644 --- a/docs/Открытые_вопросы_v8_3.md +++ b/docs/Открытые_вопросы_v8_3.md @@ -2,7 +2,42 @@ **Назначение:** единый рабочий список вопросов, требующих решения заказчика для разблокировки разработки. Разбит по адресатам, внутри — по приоритету. -**Версия:** 1.58 от 09.05.2026 — **3 lookups + integrity** после backend-completion v1.57: GET /api/managers + /api/projects + manager FK guard в DealController / replace MOCK_MANAGERS+MOCK_PROJECTS на API в NewDealDialog / SupplierLeadCost для manual-leads. **Pest 166/166 (699 assertions) + Vitest 247/247 + Histoire 21/28 зелёные**. +**Версия:** 1.59 от 09.05.2026 — **SupplierResolver service-extract** после lookups+integrity v1.58: общая логика `project_suppliers`-lookup из `ProcessWebhookJob` + `DealController` вынесена в `App\Services\SupplierResolver` (+ `costRubSnapshot` snapshot цены), DI через `app()` (тот же паттерн, что у `DuplicateDetector`). **Pest 174/174 (708 assertions) + Vitest 247/247 + Histoire 21/28 зелёные**. + +**Что изменилось в v1.59 относительно v1.58:** + +- **(1) SupplierResolver service-extract** — закрыт TODO (a) из v1.58: + - `App\Services\SupplierResolver::resolveForProject(Project): ?int` — точная копия query, что была в `ProcessWebhookJob::resolveSupplierId` и `DealController::resolveSupplierId` (project_suppliers JOIN suppliers, фильтры is_active+is_active, ORDER BY sort_order, id). + - `App\Services\SupplierResolver::costRubSnapshot(int): string` — отдельный helper для snapshot цены при записи в `supplier_lead_costs` (вынесенный `DB::table('suppliers')->value('cost_rub')` из обоих мест, чтобы snapshot не менялся при последующих правках цены). + - **DI**: через `app(SupplierResolver::class)` внутри handle()/store() — тот же паттерн, что у `DuplicateDetector` в v1.23. НЕ через constructor injection — иначе тесты не смогли бы вызывать `(new ProcessWebhookJob(...))->handle()` напрямую без контейнера. + - **Удалены** в `ProcessWebhookJob` и `DealController`: private `resolveSupplierId()` (по 14 строк) + локальные `DB::table('suppliers')->value('cost_rub')`. Оба файла стали короче, ссылочная логика — в одном месте. + +- **(2) Pest +8** в `tests/Feature/Services/SupplierResolverTest.php`: + 1. null когда у проекта нет связей. + 2. Возвращает supplier_id единственного активного поставщика. + 3. Пропускает `suppliers.is_active=false`. + 4. Пропускает `project_suppliers.is_active=false`. + 5. Соблюдает ORDER BY `sort_order, id` (low first). + 6. null если все связи inactive. + 7. Изоляция по project_id (один supplier на двух проектах не проявляется на пустом проекте). + 8. costRubSnapshot возвращает `'137.50'` (как string). + +- **Quirk:** `Project::factory()->create(['type' => 'websites'])` падает на CHECK `projects_type_check` (allowed: webhook|manual|import) — factory default `'webhook'` достаточен, лишний override убран. + +- **PHPStan baseline регенерирован** для +30 ignored Pest TestCall warnings (новый файл). + +- **Регресс зелёный:** + - `composer pint` + `composer stan` — passed. + - `composer test` — **Pest 174/174 за 21.46 сек** (+8 от 166, 708 assertions). + - `npm run test:vue` — **247/247 за 17.53 сек** (нетронут — backend-only refactor). + +- **Что НЕ сделано (production TODO остаточные):** + - Реальный XLSX-export через PhpSpreadsheet (CSV достаточен на MVP). + - GET /api/deals для замены MOCK_DEALS в DealsView/KanbanView (опционально — local-state ok). + - **#6 Yandex 360 SSO** ⏸ ждёт Б-1. + - **#7 Pest browser-mode** — отложен (инфра, требует Playwright). + +- **Сводка §0:** без изменений (70 ✅ / 5 🟦 / 4 ⏸ / 1 P0 + 3 P1 + 0 P2) — service-extract не двигает счётчик продуктовых вопросов. **Что изменилось в v1.58 относительно v1.57:**