From 515114cff58b0b60200855dce3c1ba76fcee330a 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 06:58:49 +0300 Subject: [PATCH] =?UTF-8?q?phase2(lookups+integrity):=20GET=20/api/manager?= =?UTF-8?q?s+projects=20+=20manager=20FK=20guard=20+=20SupplierLeadCost=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20manual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 интеграционных доработки после backend-completion v1.57. (1) GET /api/managers + /api/projects + manager FK guard: - ManagerController::index — active users тенанта (is_active+deleted_at IS NULL). Формат {id, email, first_name, last_name, name, initials} с formatName/formatInitials helpers (fallback на email). - ProjectController::index — active projects (is_active=true). - Оба endpoint'а: tenant_id query-param, 422 без, 404 unknown, RLS-обёртка. - DealController::store FK guard: manager_id должен принадлежать tenant'у + is_active. Иначе 422 (закрывает security-gap чужого менеджера). - Pest +8 в LookupsTest. (2) Replace MOCK_MANAGERS / MOCK_PROJECTS на API в NewDealDialog: - projectOptions/managerOptions ref'ы с MOCK fallback. - loadLookups через Promise.all([listProjects, listManagers]) на open диалога с tenantId. - managerIdByName Map name→id для submit'а. - Silent fallback на mock при network-error. - Vitest +2. (3) SupplierLeadCost для manual-leads: - В DealController::store после Deal::create — resolveSupplierId (копия логики ProcessWebhookJob: project_suppliers JOIN suppliers + ORDER BY sort_order). Если supplier найден — SupplierLeadCost с snapshot cost_rub + supplier_lead_id=NULL (manual: нет внешнего id). - Manual по-прежнему НЕ списывает баланс (Ю-2 reseller-модель — charge только при webhook'е); cost-аналитика всё равно нужна. - Pest +2. - TODO: рефактор resolveSupplierId в App\Services\SupplierResolver чтобы Job + Controller разделяли логику. Старый тест manager_id=42 переписан под FK guard через User::factory. PHPStan baseline регенерирован (+28 ignored Pest TestCall warnings). Регресс: lint+type-check+format ✅; vitest 247/247 за 16.32 сек (+2); vite build 951 ms; Pint+PHPStan passed; Pest 166/166 за 22.11 сек (+10 от 156, 699 assertions). Реестр v1.57→v1.58, CLAUDE.md v1.48→v1.49. Production TODO остаточные: - resolveSupplierId → SupplierResolver service. - XLSX-export через PhpSpreadsheet. - GET /api/deals для replace MOCK_DEALS в DealsView/KanbanView. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 6 +- .../Http/Controllers/Api/DealController.php | 61 +++++++++ .../Controllers/Api/ManagerController.php | 82 ++++++++++++ .../Controllers/Api/ProjectController.php | 52 ++++++++ app/phpstan-baseline.neon | 22 +++- app/resources/js/api/deals.ts | 30 +++++ .../js/components/deals/NewDealDialog.vue | 46 ++++++- app/routes/web.php | 6 + app/tests/Feature/DealCreateTest.php | 69 +++++++++- app/tests/Feature/LookupsTest.php | 122 ++++++++++++++++++ app/tests/Frontend/NewDealDialog.spec.ts | 67 ++++++++++ docs/Открытые_вопросы_v8_3.md | 47 ++++++- 12 files changed, 599 insertions(+), 11 deletions(-) create mode 100644 app/app/Http/Controllers/Api/ManagerController.php create mode 100644 app/app/Http/Controllers/Api/ProjectController.php create mode 100644 app/tests/Feature/LookupsTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 720f8342..0e232d2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 1.48 от 09.05.2026 +**Версия:** 1.49 от 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.57 от 09.05.2026 — **3 backend-completion коммита**: POST /api/deals (DealController + manual create) / webhook_hmac_required flag / POST /api/deals/export CSV; Pest 156/156 + Vitest 245/245 + Histoire 21/28) | +| Открытые вопросы | [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) | | **Брендбук** | [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.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.* *CLAUDE.md v1.47 от 09.05.2026. Изменения v1.47: **3 production-tightening изменения** после 7-фичного пакета v1.46. **(1) HMAC + per-token rate-limit для webhook receive endpoint** — закрыты 2 production-TODO из v1.46. `WebhookReceiveController::receive` теперь делает 3 проверки в порядке: tenant lookup → rate-limit → HMAC → валидация payload. **HMAC**: опциональный header `X-Webhook-Signature: sha256=`, верификация через `hash_hmac('sha256', raw_body, webhook_token)` + `hash_equals` (constant-time compare против timing attacks). На MVP — backward-compat: header отсутствует → пропускаем (для prod через `system_settings.webhook_hmac_required` сделаем обязательным). Невалидная подпись → 401 (не 422 — это auth issue). **Per-token rate-limit**: `RateLimiter::tooManyAttempts("webhook:{tenant_id}", rps×60)` с decay 60 сек. Лимит читается из `system_settings.webhook_rate_limit_rps` (default 100 RPS из seed v8.7), приводится к per-minute через ×60 (Laravel RateLimiter работает per-decay-window). На превышении — 429 + `Retry-After` header + `retry_after` в JSON. Rate-limit ключ изолирован per-tenant, hit ставится ДО валидации payload (иначе можно обойти лимит спамом 422-ответов). Pest +5 в `WebhookReceiveTest`: HMAC valid (test через `$this->call('POST', ..., $rawBody)` чтобы передать сырой body) + invalid (401 + не диспатч) + missing (202 backward-compat); rate-limit с `SystemSetting::update(['value'=>'1'])` → 60 успешных + 61-й = 429 + `Retry-After`; ключ изолирован per-token (alice заблокирована, bob проходит). `RateLimiter::clear` в `beforeEach` чтобы не загрязнять следующий тест. **(2) Реальный fetch для system_settings** в AdminSystemView — закрыт TODO из v1.46. `onMounted(loadSettings)` вызывает `adminApi.listSystemSettings()` и replace'ит `settingsState.splice(0, length, ...fromApi)` (сохраняет reactive-ref). На fetch-error → fallback на mock-данные + warning v-alert (`fetch-error-alert` data-testid, closable). Кнопка `data-testid="reload-btn"` в header триггерит ручной reload. Mock-данные используются как fallback при сетевой ошибке (UI не пустеет). Type-shape совместим: `AdminSystemSetting` (mock) и `ApiSystemSetting` (backend) различаются только origin. Vitest +3: assert `listSystemSettings` called once on mount; reload-btn triggers manual fetch; on rejection → warning-alert visible + 7 mock rows preserved. **(3) Реальный CSV-export для bulk-actions** в DealsView — закрыт TODO из v1.46. `applyBulkExport()` теперь не просто toast'ит, а формирует CSV и триггерит download через Blob+``. Headers: ID/Имя/Телефон/Статус/Проект/Менеджер/Стоимость/Получено мин назад. CSV-escape (значение в кавычках если содержит `;`/`"`/`\n`; внутри двойные `""`). Разделитель `;` (Excel-friendly для русской локали). Line-endings `\r\n` (Windows). **BOM** через `String.fromCharCode(0xFEFF)` (литеральный U+FEFF блокируется ESLint `no-irregular-whitespace`) — Excel правильно распознаёт UTF-8 кириллицу. Filename `deals_export_YYYY-MM-DD.csv`. Toast «Экспортировано N сделок в CSV». Empty selection → toast «Нет выбранных» без download. Vitest +2: spy на `URL.createObjectURL`+`HTMLAnchorElement.prototype.click` — assert called once + correct toast text; empty selection → не вызываем URL.createObjectURL. **TODO (production):** webhook HMAC обязательным через flag в system_settings; реальный backend-export через POST /api/deals/export → ReportsView с XLSX (через xlsx-библиотеку или Excel-шаблон); система settings с filter+sort. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 242/242 за 15.82 сек** (+4 от 238); vite build 903 ms; Pint+PHPStan passed (baseline регенерирован для новых Pest TestCall ignored count'ов); **Pest 141/141 за 17.8 сек** (+5 от 136, 627 assertions). Реестр v1.55→v1.56.* diff --git a/app/app/Http/Controllers/Api/DealController.php b/app/app/Http/Controllers/Api/DealController.php index 6960df82..8ad587e0 100644 --- a/app/app/Http/Controllers/Api/DealController.php +++ b/app/app/Http/Controllers/Api/DealController.php @@ -8,7 +8,9 @@ use App\Http\Controllers\Controller; use App\Models\ActivityLog; use App\Models\Deal; use App\Models\Project; +use App\Models\SupplierLeadCost; use App\Models\Tenant; +use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -53,6 +55,23 @@ class DealController extends Controller return response()->json(['message' => 'Тенант не найден.'], 404); } + // Manager FK guard: если manager_id передан, он должен принадлежать + // этому tenant'у. Иначе можно назначить чужого менеджера на свою сделку. + if (isset($validated['manager_id'])) { + $managerExists = User::query() + ->where('id', $validated['manager_id']) + ->where('tenant_id', $tenant->id) + ->whereNull('deleted_at') + ->where('is_active', true) + ->exists(); + if (! $managerExists) { + return response()->json([ + 'message' => 'Менеджер не найден в этом тенанте.', + 'errors' => ['manager_id' => ['Не принадлежит вашему тенанту или не активен.']], + ], 422); + } + } + $statusSlug = $validated['status'] ?? 'new'; // Транзакция + RLS: SET LOCAL внутри (PgBouncer-safe). @@ -77,6 +96,27 @@ class DealController extends Controller 'received_at' => now(), ]); + // SupplierLeadCost для manual-leads — если у проекта есть активный + // supplier через project_suppliers (m2m). Manual НЕ списывает + // баланс (Ю-2: реселлерская модель работает только при закупке у + // supplier'а), но cost-аналитика всё равно нужна — owner проекта + // мог самостоятельно купить лид и ввести руками. + $supplierId = $this->resolveSupplierId($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, + 'supplier_lead_id' => null, // manual: нет внешнего id + 'created_at' => now(), + ]); + } + ActivityLog::create([ 'tenant_id' => $tenant->id, 'user_id' => null, // на prod — request()->user()->id @@ -179,4 +219,25 @@ 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/Http/Controllers/Api/ManagerController.php b/app/app/Http/Controllers/Api/ManagerController.php new file mode 100644 index 00000000..8bbb0467 --- /dev/null +++ b/app/app/Http/Controllers/Api/ManagerController.php @@ -0,0 +1,82 @@ +user()->tenant_id. + */ +class ManagerController extends Controller +{ + /** GET /api/managers?tenant_id={id} */ + public function index(Request $request): JsonResponse + { + $tenantId = (int) $request->query('tenant_id', '0'); + if ($tenantId < 1) { + return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422); + } + + $tenant = Tenant::find($tenantId); + if ($tenant === null) { + return response()->json(['message' => 'Тенант не найден.'], 404); + } + + $users = DB::transaction(function () use ($tenantId) { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + return User::query() + ->whereNull('deleted_at') + ->where('is_active', true) + ->orderBy('first_name') + ->orderBy('last_name') + ->get(['id', 'email', 'first_name', 'last_name']); + }); + + return response()->json([ + 'managers' => $users->map(fn (User $u) => [ + 'id' => $u->id, + 'email' => $u->email, + 'first_name' => $u->first_name, + 'last_name' => $u->last_name, + 'name' => self::formatName($u->first_name, $u->last_name, $u->email), + 'initials' => self::formatInitials($u->first_name, $u->last_name, $u->email), + ]), + ]); + } + + public static function formatName(?string $first, ?string $last, string $email): string + { + if ($first && $last) { + return $first.' '.mb_substr($last, 0, 1).'.'; + } + if ($first) { + return $first; + } + + return $email; + } + + public static function formatInitials(?string $first, ?string $last, string $email): string + { + $f = $first ? mb_substr($first, 0, 1) : ''; + $l = $last ? mb_substr($last, 0, 1) : ''; + $initials = mb_strtoupper($f.$l); + if ($initials !== '') { + return $initials; + } + + return mb_strtoupper(mb_substr($email, 0, 2)); + } +} diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php new file mode 100644 index 00000000..a3c88e5e --- /dev/null +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -0,0 +1,52 @@ +query('tenant_id', '0'); + if ($tenantId < 1) { + return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422); + } + + $tenant = Tenant::find($tenantId); + if ($tenant === null) { + return response()->json(['message' => 'Тенант не найден.'], 404); + } + + $projects = DB::transaction(function () use ($tenantId) { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + return Project::query() + ->where('is_active', true) + ->orderBy('name') + ->get(['id', 'name', 'tag', 'type']); + }); + + return response()->json([ + 'projects' => $projects->map(fn (Project $p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'tag' => $p->tag, + 'type' => $p->type, + ]), + ]); + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index acdbd0b0..fce629f6 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -237,13 +237,13 @@ parameters: - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound - count: 24 + count: 30 path: tests/Feature/DealCreateTest.php - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' identifier: method.notFound - count: 16 + count: 18 path: tests/Feature/DealCreateTest.php - @@ -270,6 +270,24 @@ parameters: count: 17 path: tests/Feature/ImpersonationTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 20 + path: tests/Feature/LookupsTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 5 + path: tests/Feature/LookupsTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/LookupsTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$partitionsBefore\.$#' identifier: property.notFound diff --git a/app/resources/js/api/deals.ts b/app/resources/js/api/deals.ts index b30a10a4..0cc3f54e 100644 --- a/app/resources/js/api/deals.ts +++ b/app/resources/js/api/deals.ts @@ -47,3 +47,33 @@ export async function exportDeals(payload: ExportDealsPayload): Promise }); return data; } + +export interface ApiManager { + id: number; + email: string; + first_name: string | null; + last_name: string | null; + name: string; + initials: string; +} + +export async function listManagers(tenantId: number): Promise { + const { data } = await apiClient.get<{ managers: ApiManager[] }>('/api/managers', { + params: { tenant_id: tenantId }, + }); + return data.managers; +} + +export interface ApiProject { + id: number; + name: string; + tag: string | null; + type: string; +} + +export async function listProjects(tenantId: number): Promise { + const { data } = await apiClient.get<{ projects: ApiProject[] }>('/api/projects', { + params: { tenant_id: tenantId }, + }); + return data.projects; +} diff --git a/app/resources/js/components/deals/NewDealDialog.vue b/app/resources/js/components/deals/NewDealDialog.vue index 1b7673b6..d96ee071 100644 --- a/app/resources/js/components/deals/NewDealDialog.vue +++ b/app/resources/js/components/deals/NewDealDialog.vue @@ -16,6 +16,36 @@ import { computed, ref, watch } from 'vue'; import { LEAD_STATUSES } from '../../composables/leadStatuses'; import { MOCK_MANAGERS, MOCK_PROJECTS, type MockDeal, type MockManager } from '../../composables/mockDeals'; +/** + * Управление source для проектов и менеджеров. Если tenantId передан, загружаем + * с backend через GET /api/projects, /api/managers. На fail (network) — + * fallback на MOCK_PROJECTS/MOCK_MANAGERS (UI всё равно работоспособен). + */ +const projectOptions = ref([...MOCK_PROJECTS]); +const managerOptions = ref([...MOCK_MANAGERS]); +// Map name → backend-id, нужен только когда manager_id отправляется на backend. +const managerIdByName = ref>(new Map()); + +async function loadLookups(tenantId: number) { + try { + const [projects, managers] = await Promise.all([ + dealsApi.listProjects(tenantId), + dealsApi.listManagers(tenantId), + ]); + if (projects.length > 0) { + projectOptions.value = projects.map((p) => p.name); + } + if (managers.length > 0) { + managerOptions.value = managers.map((m) => ({ initials: m.initials, name: m.name })); + const map = new Map(); + managers.forEach((m) => map.set(m.name, m.id)); + managerIdByName.value = map; + } + } catch { + // Молчаливый fallback на mock — UI пользователь всё равно увидит. + } +} + const props = defineProps<{ modelValue: boolean; /** Опциональный preset статуса (KanbanView передаёт slug колонки куда дропают). */ @@ -69,7 +99,14 @@ function reset() { watch( () => props.modelValue, (open) => { - if (open) reset(); + if (open) { + reset(); + // Подгружаем lookups при открытии (дешевле грузить on-demand чем + // на mount — диалог не открывается часто). + if (props.tenantId) { + loadLookups(props.tenantId); + } + } }, { immediate: true }, ); @@ -96,12 +133,15 @@ async function submit() { // (UI не блокируется при network-down, но warning показываем). if (props.tenantId) { try { + // Manager mapping name → id (если backend lookup сработал). + const managerId = manager.value ? managerIdByName.value.get(manager.value.name) : undefined; const created = await dealsApi.createDeal({ tenant_id: props.tenantId, project_name: project.value!, phone: phone.value.trim(), contact_name: name.value.trim(), status: statusSlug.value, + manager_id: managerId, }); dealId = created.id; } catch (err) { @@ -180,7 +220,7 @@ function close() { group(function () { Route::post('/api/deals', [DealController::class, 'store']); Route::post('/api/deals/export', [DealController::class, 'export']); +// Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters). +Route::get('/api/managers', [ManagerController::class, 'index']); +Route::get('/api/projects', [ProjectController::class, 'index']); + // Receive endpoint для входящих webhook'ов (narrative §5.5). // Auth — по `tenants.webhook_token` в URL (без middleware, проверка внутри controller). // На prod: + HMAC-валидация X-Webhook-Signature + per-token rate-limit. diff --git a/app/tests/Feature/DealCreateTest.php b/app/tests/Feature/DealCreateTest.php index fc2a2538..0b163e3d 100644 --- a/app/tests/Feature/DealCreateTest.php +++ b/app/tests/Feature/DealCreateTest.php @@ -5,7 +5,9 @@ declare(strict_types=1); use App\Models\ActivityLog; use App\Models\Deal; use App\Models\Project; +use App\Models\SupplierLeadCost; use App\Models\Tenant; +use App\Models\User; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; @@ -110,17 +112,19 @@ test('POST /api/deals дефолтный status = new если не переда }); test('POST /api/deals с manager_id → assigned_at = NOW()', function () { + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $manager = User::factory()->for($this->tenant)->create(['is_active' => true]); + $r = $this->postJson('/api/deals', [ 'tenant_id' => $this->tenant->id, 'project_name' => 'X', 'phone' => '+7 (999) 000-00-00', - 'manager_id' => 42, // FK не проверяется (manager_id без FK) + 'manager_id' => $manager->id, ]); $r->assertStatus(201); - DB::statement('SET app.current_tenant_id = '.$this->tenant->id); $deal = Deal::where('id', $r->json('deal.id'))->first(); - expect($deal->manager_id)->toBe(42); + expect($deal->manager_id)->toBe($manager->id); expect($deal->assigned_at)->not->toBeNull(); }); @@ -137,6 +141,65 @@ test('POST /api/deals manual НЕ списывает баланс tenant\'а', f expect($this->tenant->balance_leads)->toBe($balanceBefore); }); +test('POST /api/deals manual создаёт SupplierLeadCost если у проекта есть активный supplier', function () { + // Создаём supplier + проект + project_suppliers связку. + $supplierId = DB::table('suppliers')->insertGetId([ + 'code' => 'test_b1_'.bin2hex(random_bytes(3)), + 'name' => 'Test Supplier', + 'accepts_types' => '{"websites","calls"}', + 'cost_rub' => '15.00', + 'channel' => 'sites', + 'is_active' => true, + 'sort_order' => 1, + 'quality_score' => 1.00, + 'created_at' => now(), + ]); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $project = Project::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'WithSupplier', + 'type' => 'manual', + ]); + DB::table('project_suppliers')->insert([ + 'project_id' => $project->id, + 'supplier_id' => $supplierId, + 'is_active' => true, + 'created_at' => now(), + ]); + + $r = $this->postJson('/api/deals', [ + 'tenant_id' => $this->tenant->id, + 'project_name' => 'WithSupplier', + 'phone' => '+7 (999) 000-00-00', + ]); + $r->assertStatus(201); + + // SupplierLeadCost создан со snapshot cost_rub + $cost = SupplierLeadCost::query() + ->where('deal_id', $r->json('deal.id')) + ->first(); + expect($cost)->not->toBeNull(); + expect($cost->supplier_id)->toBe((int) $supplierId); + expect((string) $cost->cost_rub)->toBe('15.00'); + expect($cost->supplier_lead_id)->toBeNull(); // manual: нет внешнего id +}); + +test('POST /api/deals manual БЕЗ supplier'."'а у проекта — без SupplierLeadCost (graceful skip)", function () { + $r = $this->postJson('/api/deals', [ + 'tenant_id' => $this->tenant->id, + 'project_name' => 'NoSupplier', + 'phone' => '+7 (999) 000-00-00', + ]); + $r->assertStatus(201); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $cost = SupplierLeadCost::query() + ->where('deal_id', $r->json('deal.id')) + ->count(); + expect($cost)->toBe(0); +}); + test('POST /api/deals/export возвращает CSV с правильными headers + BOM', function () { // Создаём 2 сделки через store endpoint (получаем реальные id). $r1 = $this->postJson('/api/deals', [ diff --git a/app/tests/Feature/LookupsTest.php b/app/tests/Feature/LookupsTest.php new file mode 100644 index 00000000..522e6cf3 --- /dev/null +++ b/app/tests/Feature/LookupsTest.php @@ -0,0 +1,122 @@ +tenant = Tenant::factory()->create(); +}); + +test('GET /api/managers возвращает active users тенанта', function () { + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + User::factory()->for($this->tenant)->create([ + 'first_name' => 'Иван', 'last_name' => 'Петров', 'is_active' => true, + ]); + User::factory()->for($this->tenant)->create([ + 'first_name' => 'Ольга', 'last_name' => 'Романова', 'is_active' => true, + ]); + User::factory()->for($this->tenant)->create([ + 'first_name' => 'Удалённый', 'is_active' => false, + ]); + + $r = $this->getJson('/api/managers?tenant_id='.$this->tenant->id); + $r->assertStatus(200); + $managers = $r->json('managers'); + expect($managers)->toHaveCount(2); + $names = array_column($managers, 'name'); + expect($names)->toContain('Иван П.')->toContain('Ольга Р.'); +}); + +test('GET /api/managers возвращает initials с fallback на email', function () { + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + User::factory()->for($this->tenant)->create([ + 'email' => 'admin@example.ru', + 'first_name' => null, + 'last_name' => null, + 'is_active' => true, + ]); + + $r = $this->getJson('/api/managers?tenant_id='.$this->tenant->id); + $r->assertStatus(200); + $manager = $r->json('managers.0'); + expect($manager['name'])->toBe('admin@example.ru'); + expect($manager['initials'])->toBe('AD'); +}); + +test('GET /api/managers 422 без tenant_id', function () { + $r = $this->getJson('/api/managers'); + $r->assertStatus(422); +}); + +test('GET /api/managers 404 unknown tenant', function () { + $r = $this->getJson('/api/managers?tenant_id=999999'); + $r->assertStatus(404); +}); + +test('GET /api/projects возвращает active projects тенанта', function () { + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + Project::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Окна Москва', 'is_active' => true, + ]); + Project::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Архивный', 'is_active' => false, + ]); + + $r = $this->getJson('/api/projects?tenant_id='.$this->tenant->id); + $r->assertStatus(200); + $projects = $r->json('projects'); + expect($projects)->toHaveCount(1); + expect($projects[0]['name'])->toBe('Окна Москва'); +}); + +test('POST /api/deals 422 если manager_id не принадлежит tenant\'у', function () { + $otherTenant = Tenant::factory()->create(); + DB::statement('SET app.current_tenant_id = '.$otherTenant->id); + $otherManager = User::factory()->for($otherTenant)->create(['is_active' => true]); + + // Назначаем чужого менеджера на свою сделку — должен быть 422. + $r = $this->postJson('/api/deals', [ + 'tenant_id' => $this->tenant->id, + 'project_name' => 'X', + 'phone' => '+7 (999) 000-00-00', + 'manager_id' => $otherManager->id, + ]); + $r->assertStatus(422); + expect($r->json('errors'))->toHaveKey('manager_id'); +}); + +test('POST /api/deals 422 если manager_id не активен (is_active=false)', function () { + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $inactive = User::factory()->for($this->tenant)->create(['is_active' => false]); + + $r = $this->postJson('/api/deals', [ + 'tenant_id' => $this->tenant->id, + 'project_name' => 'X', + 'phone' => '+7 (999) 000-00-00', + 'manager_id' => $inactive->id, + ]); + $r->assertStatus(422); +}); + +test('POST /api/deals принимает manager_id из своего tenant\'а', function () { + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $manager = User::factory()->for($this->tenant)->create(['is_active' => true]); + + $r = $this->postJson('/api/deals', [ + 'tenant_id' => $this->tenant->id, + 'project_name' => 'X', + 'phone' => '+7 (999) 000-00-00', + 'manager_id' => $manager->id, + ]); + $r->assertStatus(201); + expect($r->json('deal.manager_id'))->toBe($manager->id); +}); diff --git a/app/tests/Frontend/NewDealDialog.spec.ts b/app/tests/Frontend/NewDealDialog.spec.ts index acd6312f..7a59c71f 100644 --- a/app/tests/Frontend/NewDealDialog.spec.ts +++ b/app/tests/Frontend/NewDealDialog.spec.ts @@ -4,6 +4,8 @@ import { createVuetify } from 'vuetify'; vi.mock('../../resources/js/api/deals', () => ({ createDeal: vi.fn(), + listProjects: vi.fn(() => Promise.resolve([])), + listManagers: vi.fn(() => Promise.resolve([])), })); vi.mock('../../resources/js/api/client', () => ({ extractErrorMessage: vi.fn((_e, fb?: string) => fb ?? 'err'), @@ -192,6 +194,71 @@ describe('NewDealDialog.vue', () => { expect(deal.id).toBe(4242); // backend-id, не local nextId() }); + it('с tenantId — loadLookups вызывает listManagers + listProjects на open', async () => { + vi.mocked(dealsApi.listProjects).mockResolvedValue([ + { id: 7, name: 'Окна Москва', tag: null, type: 'webhook' }, + ]); + vi.mocked(dealsApi.listManagers).mockResolvedValue([ + { id: 42, email: 'iv@ex.ru', first_name: 'Иван', last_name: 'Петров', name: 'Иван П.', initials: 'ИП' }, + ]); + + const wrapper = factory({ modelValue: true, tenantId: 1 }); + await flushPromises(); + + expect(dealsApi.listProjects).toHaveBeenCalledWith(1); + expect(dealsApi.listManagers).toHaveBeenCalledWith(1); + + // projectOptions / managerOptions заменены backend'ом + const vm = wrapper.vm as unknown as { + projectOptions: string[]; + managerOptions: Array<{ name: string; initials: string }>; + managerIdByName: Map; + }; + expect(vm.projectOptions).toEqual(['Окна Москва']); + expect(vm.managerOptions).toEqual([{ name: 'Иван П.', initials: 'ИП' }]); + expect(vm.managerIdByName.get('Иван П.')).toBe(42); + }); + + it('submit с manager → передаёт backend manager_id из mapping', async () => { + vi.mocked(dealsApi.listManagers).mockResolvedValue([ + { id: 99, email: 'x@y.ru', first_name: 'X', last_name: 'Y', name: 'X Y.', initials: 'XY' }, + ]); + vi.mocked(dealsApi.createDeal).mockResolvedValue({ + id: 1, + tenant_id: 1, + project_id: 1, + phone: '+7 (999) 000-00-00', + status: 'new', + contact_name: 'Z', + manager_id: 99, + received_at: '2026-05-09T12:00:00Z', + }); + + const wrapper = factory({ modelValue: true, tenantId: 1 }); + await flushPromises(); + const vm = wrapper.vm as unknown as { + name: string; + phone: string; + project: string; + manager: { initials: string; name: string }; + statusSlug: string; + }; + vm.name = 'Z'; + vm.phone = '+7 (999) 000-00-00'; + vm.project = 'Окна Москва'; + vm.manager = { initials: 'XY', name: 'X Y.' }; + vm.statusSlug = 'new'; + await flushPromises(); + await wrapper.find('[data-testid="submit-btn"]').trigger('click'); + await flushPromises(); + + expect(dealsApi.createDeal).toHaveBeenCalledWith( + expect.objectContaining({ + manager_id: 99, + }), + ); + }); + it('с tenantId + backend-error — fallback на local-id + warning + emit', async () => { vi.mocked(dealsApi.createDeal).mockRejectedValue(new Error('Network down')); diff --git a/docs/Открытые_вопросы_v8_3.md b/docs/Открытые_вопросы_v8_3.md index e9554075..8cd27fce 100644 --- a/docs/Открытые_вопросы_v8_3.md +++ b/docs/Открытые_вопросы_v8_3.md @@ -2,7 +2,52 @@ **Назначение:** единый рабочий список вопросов, требующих решения заказчика для разблокировки разработки. Разбит по адресатам, внутри — по приоритету. -**Версия:** 1.57 от 09.05.2026 — **3 backend-completion изменения** после tightening v1.56: POST /api/deals (DealController + manual create + frontend integration) / `webhook_hmac_required` flag в system_settings / POST /api/deals/export (CSV backend-side). **Pest 156/156 (675 assertions) + Vitest 245/245 + Histoire 21/28 зелёные**. +**Версия:** 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 зелёные**. + +**Что изменилось в v1.58 относительно v1.57:** + +- **(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, 422 без него, 404 unknown tenant, RLS-обёртка `SET LOCAL app.current_tenant_id` в DB::transaction. + - **Manager FK guard** в `DealController::store` — если `manager_id` передан, проверяем `User::where(id+tenant_id+is_active=true+deleted_at=null)->exists()`. Не принадлежит/не активен → 422. Закрывает security-gap: иначе можно было назначить чужого менеджера на свою сделку. + - **Pest +8** в LookupsTest: managers active filter / initials fallback на email / 422 без tenant_id / 404 unknown / projects active filter / manager FK guard 3 кейса (чужой → 422 / inactive → 422 / свой active → 201). + +- **(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'а. + - На fail (network) — silent fallback на mock. + - Submit передаёт `manager_id: managerIdByName.get(manager.name) ?? undefined`. + - **Vitest +2** в NewDealDialog: loadLookups вызывает оба + populates refs+map / submit передаёт backend manager_id из mapping. + +- **(3) SupplierLeadCost для manual-leads** — закрыт TODO из v1.57: + - В `DealController::store` транзакции после Deal::create вызываем `resolveSupplierId($project)` (точная копия логики из `ProcessWebhookJob`: 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 только при webhook'е); cost-аналитика всё равно нужна (owner мог купить лид и ввести руками). + - На production: `resolveSupplierId` в `App\Services\SupplierResolver` (Job+Controller разделяют логику + system_settings fallback). + - **Pest +2** в DealCreateTest: SupplierLeadCost создан с snapshot cost_rub / без supplier — graceful skip. + +- **Старый тест из v1.48** (`manager_id=42`) переписан под FK guard: создаётся `User::factory()->for($tenant)` чтобы 422 не сработал. + +- **PHPStan baseline регенерирован** для +28 ignored Pest TestCall warnings (расширение DealCreateTest + новый LookupsTest). + +- **Регресс зелёный:** + - `npm run lint:vue` + `type-check` + `format` — passed. + - `npm run test:vue` — **247/247 за 16.32 сек** (+2 от 245). + - `npm run build` — vite OK 951 ms. + - `composer pint` + `composer stan` — passed. + - `composer test` — **Pest 166/166 за 22.11 сек** (+10 от 156, 699 assertions). + +- **Что НЕ сделано (production TODO остаточные):** + - `resolveSupplierId` рефактор в `App\Services\SupplierResolver` — чтобы Job + Controller разделяли код. + - Реальный 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) — lookups+integrity не двигают счётчик продуктовых вопросов. + +**Что изменилось в v1.57 относительно v1.56:** **3 backend-completion изменения**: POST /api/deals (DealController + manual create + frontend integration) / `webhook_hmac_required` flag в system_settings / POST /api/deals/export (CSV backend-side). **Pest 156/156 (675 assertions) + Vitest 245/245 + Histoire 21/28 зелёные**. **Что изменилось в v1.57 относительно v1.56:**