From ac186593f25f4fcd350dc0ae133dc858f3fa7cb8 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:46:19 +0300 Subject: [PATCH] phase2(bulk-transition+reload): POST /api/deals/transition + reload-btn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Закрывает gap «UI меняет статус, но изменения не сохраняются на backend» из v1.51. Reload-btn заменяет polling/SSE до прихода long-poll'а на prod. Backend (DealController::transition): - POST /api/deals/transition {tenant_id, ids: [1..1000 ints], status}. - Валидация status — exists в lead_statuses (глобальная таблица). - RLS-обёртка SET LOCAL + defense-in-depth where(tenant_id) для partial-update: чужие id остаются в исходном статусе. - ActivityLog event=deal.status_changed с context={from, to, source: 'bulk'} для каждой ИЗМЕНЁННОЙ сделки. NO-OP (старый==новый) не пишется в audit. - Ответ: {updated, requested, status}. Pest +7 (DealTransitionTest): - 422 missing fields / 404 unknown tenant / 422 неизвестный slug + не апдейт / batch update 3 сделок + 3 ActivityLog с правильным context / NO-OP не пишет ActivityLog / defense-in-depth (2 tenant'а — обновляется только свой) / 422 пустой массив ids. Frontend: - dealsApi.transitionDeals — типизированный helper с ensureCsrfCookie. - applyBulkStatus в DealsView переписан async: optimistic local-update + backend-вызов если auth.user.tenant_id. На success — toast «Обновлено N из M.», на fail — warning toast + локальный update НЕ откатывается. Без auth.user — только optimistic (legacy local-mode сохранён). - reload-btn в DealsView и KanbanView — outlined «Обновить» mdi-refresh, привязан к loadDeals. В DealsView :loading="loading" во время fetch'а. Vitest +5: - reload-btn (Deals + Kanban) — listDeals вызывается дважды. - applyBulkStatus с tenant_id — transitionDeals + optimistic + toast. - applyBulkStatus без tenant_id — НЕ вызывается transitionDeals. - applyBulkStatus reject — toast warning + локальный update остаётся. PHPStan baseline регенерирован. cspell-glossary +апдейт*. Регресс: - Lint+type-check+format passed. - Vitest 266/266 за 18.16 сек (+5 от 261). - Vite build 1.06 сек. - Pint + PHPStan passed. - Pest 193/193 за 23.27 сек (+7 от 186, 767 assertions). Реестр v1.60→v1.61 / CLAUDE.md v1.51→v1.52. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 6 +- .../Http/Controllers/Api/DealController.php | 81 ++++++++++ app/phpstan-baseline.neon | 24 +++ app/resources/js/api/deals.ts | 20 +++ app/resources/js/views/DealsView.vue | 40 ++++- app/resources/js/views/KanbanView.vue | 23 +-- app/routes/web.php | 1 + app/tests/Feature/DealTransitionTest.php | 141 ++++++++++++++++++ .../Frontend/DealsListIntegration.spec.ts | 126 ++++++++++++++++ cspell-words.txt | 3 + docs/Открытые_вопросы_v8_3.md | 46 +++++- 11 files changed, 496 insertions(+), 15 deletions(-) create mode 100644 app/tests/Feature/DealTransitionTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 20a41dbc..c2859f92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 1.51 от 09.05.2026 +**Версия:** 1.52 от 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.60 от 09.05.2026 — **GET /api/deals + замена MOCK_DEALS**: index-endpoint в `DealController` с фильтрами status_in/project_id/manager_id/search/limit/offset; mapper `ApiDeal→MockDeal`; DealsView+KanbanView грузят данные на mount с fallback на MOCK_DEALS; Pest 186/186 + Vitest 261/261 + Histoire 21/28) | +| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.61 от 09.05.2026 — **bulk-transition + reload-btn**: POST /api/deals/transition с status-validation+RLS+ActivityLog `deal.status_changed`; интеграция в DealsView::applyBulkStatus (optimistic + toast); reload-btn в DealsView и KanbanView; Pest 193/193 + Vitest 266/266 + 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.52 от 09.05.2026. Изменения v1.52: **Bulk-transition + reload-btn** — закрывает «UI меняет статус, но изменения не сохраняются на backend» gap из v1.51 + добавляет manual reload как замену polling/SSE до прихода long-poll'а. **(1) Backend `DealController::transition`** — POST /api/deals/transition `{tenant_id, ids: [int...], status: slug}`. Валидация: `ids` обязателен 1..1000 ints, `status` обязателен ≤50 chars + `DB::table('lead_statuses')->where('slug', X)->exists()` (422 «Slug не найден в lead_statuses» если нет). `lead_statuses` — глобальная таблица (НЕ tenant-aware), system+custom slug'и в одном scope. RLS-обёртка `SET LOCAL app.current_tenant_id` + defense-in-depth `where('tenant_id', $tenantId)->whereIn('id', $ids)` — на тестах postgres superuser обходит RLS, app-фильтр гарантирует что чужие id не апдейтятся (partial-update: `updated < requested` если часть id принадлежит другому tenant'у). `ActivityLog::create([event=deal.status_changed, context={from, to, source=bulk}])` для каждой ИЗМЕНЁННОЙ сделки (NO-OP — старый==новый — НЕ пишется в audit log, иначе спам при «обновить тот же статус»). Ответ: `{updated, requested, status}`. Маршрут `Route::post('/api/deals/transition')`. **(2) Pest +7** в `tests/Feature/DealTransitionTest.php` (всего **193/193 за 23.27 сек**, 767 assertions): 422 missing fields / 404 unknown tenant / 422 неизвестный slug + сделка не апдейтится / batch update 3 сделок + 3 ActivityLog с правильным context.from/to/source / NO-OP не пишет ActivityLog / defense-in-depth (передаём 2 id из разных tenant'ов — обновляется только свой, чужой остаётся в исходном статусе) / 422 пустой массив ids. **(3) Frontend `dealsApi.transitionDeals(payload)`** — типизированный helper, `ensureCsrfCookie` обязателен (mutating). **`applyBulkStatus` в DealsView** переписан с sync на async: optimistic local-update (UI отвечает сразу), затем backend-вызов если есть auth.user.tenant_id. На success — `statusToast «Обновлено N из M.»`. На fail — `«Не удалось сохранить статус — изменения только локально.»` + локальный update НЕ откатывается (UX rationale: пользователь видит что хотел, перезагрузит чуть позже; auto-rollback запутает больше чем поможет). Без auth.user — только optimistic, API не вызывается (legacy local-mode сохранён). **(4) Reload-btn** в DealsView и KanbanView — outlined button «Обновить» с mdi-refresh, привязан к `loadDeals` action. В DealsView у btn'а `:loading="loading"` chip — крутится во время fetch'а. **(5) Vitest +5** (всего **266/266 за 18.16 сек**): reload-btn в DealsView (listDeals вызывается дважды) + applyBulkStatus с tenant_id (transitionDeals вызывается + optimistic update до завершения + toast «Обновлено 2») + applyBulkStatus БЕЗ tenant_id (transitionDeals НЕ вызывается + только локально) + applyBulkStatus reject (toast warning + локальный update НЕ откатывается); reload-btn в KanbanView (тот же 2× listDeals). PHPStan baseline регенерирован. **Production TODO остаточные:** реальный XLSX-export через PhpSpreadsheet; polling/SSE для real-time (на MVP — manual reload); SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 266/266 за 18.16 сек** (+5 от 261); vite build 1.06 сек (KanbanView lazy-chunk 181.98→182.17 KB — добавил `dealsApi.transitionDeals` импорт через DealsView, но KanbanView его не тянет напрямую — рост от reload-btn shared chunk); Pint+PHPStan passed; **Pest 193/193 за 23.27 сек** (+7 от 186, 767 assertions). Реестр v1.60→v1.61.* + *CLAUDE.md v1.51 от 09.05.2026. Изменения v1.51: **GET /api/deals + замена MOCK_DEALS** — закрыт TODO (c) из v1.50 (опциональный пункт, но снимает дрейф между UI и backend на time-критичных flow вроде «увидеть свежие лиды»). **(1) Backend `DealController::index`** — list-endpoint с фильтрами и relations: `tenant_id` query-param (422/404 как в `ManagerController`), массив `status_in[]` (whereIn по `status`), `project_id` / `manager_id` (точное совпадение), `search` (ILIKE по phone+contact_name OR-block), `limit` clamp [1..500] default 100, `offset` default 0. ORDER BY `received_at DESC, id DESC`. Eloquent `with(['project:id,name', 'manager:id,email,first_name,last_name'])`. RLS-обёртка `SET LOCAL app.current_tenant_id` + **defense-in-depth `where(tenant_id, $tenantId)`** на уровне query (на тестах через `postgres` superuser RLS обходится BYPASSRLS — explicit-фильтр гарантирует изоляцию). Ответ: `{deals: [{id, tenant_id, project_id, project_name, phone, contact_name, status, manager_id, manager_name, manager_initials, received_at}, ...], total, limit, offset}`. `manager_name`/`manager_initials` форматируются через `ManagerController::formatName/formatInitials` (нашли расхождение, что эти helper'ы static — re-use OK). `cost` НЕ возвращаем (живёт в `supplier_lead_costs.cost_rub` partition'е, лишний JOIN под limit=200 строк дешевле клиентского запроса). Маршрут `Route::get('/api/deals', 'index')` рядом с `store/export`. **(2) Pest +12** в `tests/Feature/DealIndexTest.php` (всего **186/186 за 22 сек**, 742 assertions): 422 без tenant_id / 404 unknown / пустой список / project_name + manager_name + initials присутствуют + ISO received_at / RLS-изоляция (Deal чужого tenant'а НЕ возвращается — defense-in-depth where отрабатывает) / ORDER BY received_at DESC (3 сделки в правильном порядке) / status_in[] фильтр (передаём 2 status'а через `?status_in[]=new&status_in[]=paid` — Laravel queryString парсит в массив) / project_id точное совпадение / manager_id точное совпадение / search ILIKE case-insensitive (Соколова / 903 / `сокол`) / limit+offset (5 сделок, limit=2 offset=1) / manager_name+initials = null когда manager_id null. **(3) Frontend `api/deals.ts::listDeals`** — типизированный axios-helper с `ApiDeal` interface + `ListDealsParams` (tenantId/statusIn/projectId/managerId/search/limit/offset → camelCase в DTO, snake_case на wire через axios `params`-mapping). Без `ensureCsrfCookie` (GET-only, CSRF только на mutating). **`composables/dealsApiMapper.ts::mapApiDeal(api, now=new Date())`** — converter ApiDeal → MockDeal: `id/phone/statusSlug/cost(=0)` 1:1; `name = contact_name ?? phone` (fallback на телефон когда контакт неизвестен); `project = project_name ?? '—'`; `manager = {name: 'Не назначен', initials: '—'}` если `manager_id=null`; `receivedMinutesAgo = max(0, floor((now - received_at) / 60_000))` — clamp на 0 чтобы не было отрицательных при clock-skew. **`cost`=0** на всех картах (отдельного endpoint'а на сделку нет, добавим при необходимости через JOIN supplier_lead_costs). **(4) DealsView/KanbanView интеграция** — `onMounted(loadDeals)` async-вызывает `dealsApi.listDeals({tenantId: auth.user.tenant_id, limit: 200/500})` если auth.user.tenant_id есть; на success — replace `dealsState`/`dealsByStatus` через splice (сохраняет reactive ref). На fail — `fetchError=true`, `v-alert type=warning «Backend недоступен — показаны mock-данные»` с `data-testid="fetch-error-alert"`, MOCK_DEALS остаются как fallback. Без auth-state — listDeals НЕ вызывается, MOCK_DEALS показываются как и раньше (Vitest без auth setup продолжает работать без mock'а). KanbanView в loadDeals сначала очищает все колонки (splice 0..length для каждой), затем распределяет по `statusSlug`. **(5) Vitest +14** (всего **261/261 за 19.62 сек**): `dealsApiMapper.spec.ts` 8 (обязательные поля 1:1 / contact_name fallback на phone / manager_name+initials default / project_name=— default / cost всегда 0 / receivedMinutesAgo=30 для 30 мин назад / clamp на 0 при future timestamp / received_at=null → 0); `DealsListIntegration.spec.ts` 6 (DealsView без tenant_id — listDeals НЕ вызывается + MOCK_DEALS остаются / DealsView с tenant_id — listDeals вызывается + dealsState replaced на 2 API-сделки / DealsView reject → fetchError=true + alert виден + MOCK_DEALS fallback; KanbanView те же 3 сценария). vi.mock на `api/deals` сохраняет original-импорт через `importOriginal` чтобы `ensureCsrfCookie` остался живым для других тестов. PHPStan baseline регенерирован. **Production TODO (после v1.51):** реальный XLSX-export через PhpSpreadsheet (CSV достаточен на MVP); polling/SSE для real-time обновления списка сделок (на MVP — manual reload); SaaS-admin auth (Yandex 360 SSO ⏸ Б-1); Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 261/261 за 19.62 сек** (+14 от 247); vite build 989 ms (KanbanView lazy-chunk вырос с 180.53→181.98 KB из-за `mapApiDeal` импорта и onMounted); Pint+PHPStan passed; **Pest 186/186 за 22 сек** (+12 от 174, 742 assertions). Реестр v1.59→v1.60.* *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.* diff --git a/app/app/Http/Controllers/Api/DealController.php b/app/app/Http/Controllers/Api/DealController.php index 6171ed0d..571bb9c2 100644 --- a/app/app/Http/Controllers/Api/DealController.php +++ b/app/app/Http/Controllers/Api/DealController.php @@ -130,6 +130,87 @@ class DealController extends Controller ]); } + /** + * POST /api/deals/transition — bulk status-update для выбранных сделок. + * + * Body: {tenant_id, ids: [int...], status: slug}. + * Используется в DealsView bulk-actions (sticky-bar «Сменить статус»). + * + * Валидация: + * - status должен существовать в lead_statuses (slug). + * - ids проверяются на принадлежность tenant'у через `where(tenant_id)`. + * + * RLS + defense-in-depth: SET LOCAL + явный where(tenant_id) — в тестах + * через postgres superuser RLS обходится BYPASSRLS, app-фильтр гарантирует + * что чужие id'шники не апдейтятся. + * + * На MVP без проверки allowed-переходов между статусами (любой → любой); + * production: валидировать через lead_status_transitions (если введём). + * История смен пишется в ActivityLog как `deal.status_changed`. + */ + public function transition(Request $request): JsonResponse + { + $validated = $request->validate([ + 'tenant_id' => 'required|integer|min:1', + 'ids' => 'required|array|min:1|max:1000', + 'ids.*' => 'integer|min:1', + 'status' => 'required|string|max:50', + ]); + + $tenant = Tenant::find($validated['tenant_id']); + if ($tenant === null) { + return response()->json(['message' => 'Тенант не найден.'], 404); + } + + $statusExists = DB::table('lead_statuses')->where('slug', $validated['status'])->exists(); + if (! $statusExists) { + return response()->json([ + 'message' => 'Неизвестный статус.', + 'errors' => ['status' => ['Slug не найден в lead_statuses.']], + ], 422); + } + + $updated = DB::transaction(function () use ($validated, $tenant) { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id); + + $deals = Deal::query() + ->where('tenant_id', $tenant->id) + ->whereIn('id', $validated['ids']) + ->get(); + + $count = 0; + foreach ($deals as $deal) { + if ($deal->status === $validated['status']) { + continue; // не пишем NO-OP в ActivityLog + } + $previous = $deal->status; + $deal->status = $validated['status']; + $deal->save(); + + ActivityLog::create([ + 'tenant_id' => $tenant->id, + 'user_id' => null, // на prod — request()->user()->id + 'deal_id' => $deal->id, + 'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED, + 'context' => [ + 'from' => $previous, + 'to' => $validated['status'], + 'source' => 'bulk', + ], + ]); + $count++; + } + + return $count; + }); + + return response()->json([ + 'updated' => $updated, + 'requested' => count($validated['ids']), + 'status' => $validated['status'], + ]); + } + /** POST /api/deals — manual create */ public function store(Request $request): JsonResponse { diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index f707960e..166dffdc 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -288,6 +288,30 @@ parameters: count: 14 path: tests/Feature/DealIndexTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$otherTenant\.$#' + identifier: property.notFound + count: 5 + path: tests/Feature/DealTransitionTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#' + identifier: property.notFound + count: 5 + path: tests/Feature/DealTransitionTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 12 + path: tests/Feature/DealTransitionTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 7 + path: tests/Feature/DealTransitionTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#' identifier: property.notFound diff --git a/app/resources/js/api/deals.ts b/app/resources/js/api/deals.ts index 03d570d7..6871f89d 100644 --- a/app/resources/js/api/deals.ts +++ b/app/resources/js/api/deals.ts @@ -39,6 +39,26 @@ export interface ExportDealsPayload { ids: number[]; } +export interface TransitionDealsPayload { + tenant_id: number; + ids: number[]; + status: string; +} + +export interface TransitionDealsResponse { + updated: number; + requested: number; + status: string; +} + +/** Bulk status-update. Возвращает {updated, requested} — updated может быть < requested + * если часть id'шников чужие или уже в этом статусе (NO-OP). */ +export async function transitionDeals(payload: TransitionDealsPayload): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.post('/api/deals/transition', payload); + return data; +} + /** Возвращает CSV-строку (с BOM). Caller отвечает за Blob+download. */ export async function exportDeals(payload: ExportDealsPayload): Promise { await ensureCsrfCookie(); diff --git a/app/resources/js/views/DealsView.vue b/app/resources/js/views/DealsView.vue index eeb9ab7f..e9c62d69 100644 --- a/app/resources/js/views/DealsView.vue +++ b/app/resources/js/views/DealsView.vue @@ -80,6 +80,8 @@ const statusMenuOpen = ref(false); const deleteConfirmOpen = ref(false); const exportToastOpen = ref(false); const exportToastText = ref(''); +const statusToastOpen = ref(false); +const statusToastText = ref(''); // New-deal dialog state const newDealOpen = ref(false); @@ -93,12 +95,29 @@ function openDeal(deal: MockDeal) { drawerOpen.value = true; } -function applyBulkStatus(slug: MockDeal['statusSlug']) { - selected.value.forEach((id) => { +async function applyBulkStatus(slug: MockDeal['statusSlug']) { + const ids = [...selected.value]; + statusMenuOpen.value = false; + + // Optimistic local-update — UI отвечает сразу, API на фоне. + ids.forEach((id) => { const deal = dealsState.find((d) => d.id === id); if (deal) deal.statusSlug = slug; }); - statusMenuOpen.value = false; + + if (!auth.user?.tenant_id || ids.length === 0) return; + + try { + const res = await dealsApi.transitionDeals({ + tenant_id: auth.user.tenant_id, + ids, + status: slug, + }); + statusToastText.value = `Обновлено ${res.updated} из ${res.requested}.`; + } catch { + statusToastText.value = 'Не удалось сохранить статус — изменения только локально.'; + } + statusToastOpen.value = true; } function applyBulkDelete() { @@ -203,6 +222,8 @@ defineExpose({ loading, fetchError, loadDeals, + statusToastOpen, + statusToastText, }); const statusBySlug = computed(() => { @@ -280,6 +301,15 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
+ + Обновить + Экспорт dealsState.filter((d) => d.statusSlug === 'wai {{ exportToastText }} + + + {{ statusToastText }} + diff --git a/app/resources/js/views/KanbanView.vue b/app/resources/js/views/KanbanView.vue index 39f8c38b..9484eeda 100644 --- a/app/resources/js/views/KanbanView.vue +++ b/app/resources/js/views/KanbanView.vue @@ -121,15 +121,20 @@ defineExpose({ dealsByStatus, totalDeals, newDealOpen, onDealCreated, fetchError Перетаскивание между колонками
- - Новая сделка - +
+ + Обновить + + + Новая сделка + +
group(function () { Route::get('/api/deals', [DealController::class, 'index']); Route::post('/api/deals', [DealController::class, 'store']); Route::post('/api/deals/export', [DealController::class, 'export']); +Route::post('/api/deals/transition', [DealController::class, 'transition']); // Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters). Route::get('/api/managers', [ManagerController::class, 'index']); diff --git a/app/tests/Feature/DealTransitionTest.php b/app/tests/Feature/DealTransitionTest.php new file mode 100644 index 00000000..81e57321 --- /dev/null +++ b/app/tests/Feature/DealTransitionTest.php @@ -0,0 +1,141 @@ +tenant = Tenant::factory()->create(); + $this->otherTenant = Tenant::factory()->create(); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $this->project = Project::factory()->for($this->tenant)->create(); +}); + +test('POST /api/deals/transition — 422 без обязательных полей', function () { + $this->postJson('/api/deals/transition', [])->assertStatus(422); +}); + +test('POST /api/deals/transition — 404 на unknown tenant', function () { + $r = $this->postJson('/api/deals/transition', [ + 'tenant_id' => 999999, + 'ids' => [1], + 'status' => 'paid', + ]); + $r->assertStatus(404); +}); + +test('POST /api/deals/transition — 422 на неизвестный status slug', function () { + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']); + + $r = $this->postJson('/api/deals/transition', [ + 'tenant_id' => $this->tenant->id, + 'ids' => [$deal->id], + 'status' => 'not_a_real_slug', + ]); + + $r->assertStatus(422); + expect($r->json('errors.status.0'))->toContain('lead_statuses'); + + // Сделка осталась в исходном статусе. + $deal->refresh(); + expect($deal->status)->toBe('new'); +}); + +test('POST /api/deals/transition — обновляет статус и пишет ActivityLog', function () { + $deals = Deal::factory()->count(3)->for($this->tenant)->for($this->project)->create(['status' => 'new']); + + $r = $this->postJson('/api/deals/transition', [ + 'tenant_id' => $this->tenant->id, + 'ids' => $deals->pluck('id')->all(), + 'status' => 'paid', + ]); + + $r->assertStatus(200)->assertJson([ + 'updated' => 3, + 'requested' => 3, + 'status' => 'paid', + ]); + + foreach ($deals as $d) { + $d->refresh(); + expect($d->status)->toBe('paid'); + } + + $activity = ActivityLog::where('tenant_id', $this->tenant->id) + ->where('event', ActivityLog::EVENT_DEAL_STATUS_CHANGED) + ->get(); + expect($activity)->toHaveCount(3); + expect($activity->first()->context)->toMatchArray([ + 'from' => 'new', + 'to' => 'paid', + 'source' => 'bulk', + ]); +}); + +test('POST /api/deals/transition — NO-OP не пишет ActivityLog', function () { + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']); + + $r = $this->postJson('/api/deals/transition', [ + 'tenant_id' => $this->tenant->id, + 'ids' => [$deal->id], + 'status' => 'paid', + ]); + + $r->assertStatus(200)->assertJson(['updated' => 0, 'requested' => 1]); + + expect(ActivityLog::where('deal_id', $deal->id) + ->where('event', ActivityLog::EVENT_DEAL_STATUS_CHANGED) + ->count())->toBe(0); +}); + +test('POST /api/deals/transition — defense-in-depth не апдейтит чужие сделки', function () { + $own = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']); + + DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id); + $foreignProject = Project::factory()->for($this->otherTenant)->create(); + $foreign = Deal::factory()->for($this->otherTenant)->for($foreignProject)->create(['status' => 'new']); + + // Передаём оба id, но tenant_id указываем наш — чужой не должен обновиться. + $r = $this->postJson('/api/deals/transition', [ + 'tenant_id' => $this->tenant->id, + 'ids' => [$own->id, $foreign->id], + 'status' => 'paid', + ]); + + $r->assertStatus(200)->assertJson([ + 'updated' => 1, + 'requested' => 2, + ]); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $own->refresh(); + expect($own->status)->toBe('paid'); + + DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id); + $foreign->refresh(); + expect($foreign->status)->toBe('new'); +}); + +test('POST /api/deals/transition — 422 если ids пустой массив', function () { + $this->postJson('/api/deals/transition', [ + 'tenant_id' => $this->tenant->id, + 'ids' => [], + 'status' => 'paid', + ])->assertStatus(422); +}); diff --git a/app/tests/Frontend/DealsListIntegration.spec.ts b/app/tests/Frontend/DealsListIntegration.spec.ts index 3e429b27..4783fbe9 100644 --- a/app/tests/Frontend/DealsListIntegration.spec.ts +++ b/app/tests/Frontend/DealsListIntegration.spec.ts @@ -15,6 +15,7 @@ vi.mock('../../resources/js/api/deals', async (importOriginal) => { listDeals: vi.fn(), listManagers: vi.fn().mockResolvedValue([]), listProjects: vi.fn().mockResolvedValue([]), + transitionDeals: vi.fn(), }; }); @@ -128,6 +129,113 @@ describe('DealsView ↔ GET /api/deals integration', () => { // Alert виден. expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true); }); + + it('reload-btn повторно вызывает listDeals', async () => { + setupAuth(1); + vi.mocked(dealsApi.listDeals).mockResolvedValue({ + deals: [makeApiDeal({ id: 400 })], + total: 1, + limit: 200, + offset: 0, + }); + + const wrapper = await mountDealsView(); + await flushPromises(); + expect(dealsApi.listDeals).toHaveBeenCalledTimes(1); + + await wrapper.find('[data-testid="reload-btn"]').trigger('click'); + await flushPromises(); + expect(dealsApi.listDeals).toHaveBeenCalledTimes(2); + }); + + it('applyBulkStatus с tenant_id вызывает transitionDeals + локальный optimistic update', async () => { + setupAuth(1); + vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ + deals: [makeApiDeal({ id: 500, status: 'new' }), makeApiDeal({ id: 501, status: 'new' })], + total: 2, + limit: 200, + offset: 0, + }); + vi.mocked(dealsApi.transitionDeals).mockResolvedValueOnce({ + updated: 2, + requested: 2, + status: 'paid', + }); + + const wrapper = await mountDealsView(); + await flushPromises(); + + const vm = wrapper.vm as unknown as { + selected: number[]; + applyBulkStatus: (slug: string) => Promise; + dealsState: { id: number; statusSlug: string }[]; + statusToastOpen: boolean; + statusToastText: string; + }; + vm.selected = [500, 501]; + await flushPromises(); + + await vm.applyBulkStatus('paid'); + await flushPromises(); + + // Optimistic local-update применился до завершения API-вызова. + expect(vm.dealsState.find((d) => d.id === 500)?.statusSlug).toBe('paid'); + expect(vm.dealsState.find((d) => d.id === 501)?.statusSlug).toBe('paid'); + expect(dealsApi.transitionDeals).toHaveBeenCalledWith({ + tenant_id: 1, + ids: [500, 501], + status: 'paid', + }); + expect(vm.statusToastOpen).toBe(true); + expect(vm.statusToastText).toContain('Обновлено 2'); + }); + + it('applyBulkStatus БЕЗ tenant_id — только локальный update, transitionDeals НЕ вызывается', async () => { + setupAuth(null); + const wrapper = await mountDealsView(); + await flushPromises(); + + const vm = wrapper.vm as unknown as { + selected: number[]; + applyBulkStatus: (slug: string) => Promise; + dealsState: { id: number; statusSlug: string }[]; + }; + vm.selected = [1]; + await flushPromises(); + await vm.applyBulkStatus('paid'); + await flushPromises(); + + expect(dealsApi.transitionDeals).not.toHaveBeenCalled(); + expect(vm.dealsState.find((d) => d.id === 1)?.statusSlug).toBe('paid'); + }); + + it('applyBulkStatus с reject → toast с warning, локальный update остаётся (не откатываем)', async () => { + setupAuth(1); + vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ + deals: [makeApiDeal({ id: 600, status: 'new' })], + total: 1, + limit: 200, + offset: 0, + }); + vi.mocked(dealsApi.transitionDeals).mockRejectedValueOnce(new Error('500')); + + const wrapper = await mountDealsView(); + await flushPromises(); + + const vm = wrapper.vm as unknown as { + selected: number[]; + applyBulkStatus: (slug: string) => Promise; + dealsState: { id: number; statusSlug: string }[]; + statusToastText: string; + }; + vm.selected = [600]; + await flushPromises(); + await vm.applyBulkStatus('paid'); + await flushPromises(); + + expect(vm.dealsState.find((d) => d.id === 600)?.statusSlug).toBe('paid'); + expect(vm.statusToastText).toContain('Не удалось'); + }); }); describe('KanbanView ↔ GET /api/deals integration', () => { @@ -181,4 +289,22 @@ describe('KanbanView ↔ GET /api/deals integration', () => { const filledColumns = Object.values(vm.dealsByStatus).filter((arr) => arr.length > 0); expect(filledColumns.length).toBeGreaterThan(0); }); + + it('reload-btn вызывает listDeals второй раз', async () => { + setupAuth(1); + vi.mocked(dealsApi.listDeals).mockResolvedValue({ + deals: [], + total: 0, + limit: 500, + offset: 0, + }); + + const wrapper = mountKanbanView(); + await flushPromises(); + expect(dealsApi.listDeals).toHaveBeenCalledTimes(1); + + await wrapper.find('[data-testid="reload-btn"]').trigger('click'); + await flushPromises(); + expect(dealsApi.listDeals).toHaveBeenCalledTimes(2); + }); }); diff --git a/cspell-words.txt b/cspell-words.txt index ef25a0b6..d7d92c72 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -793,3 +793,6 @@ ints FEFF ILIKE DTO +апдейтится +апдейтятся +апдейт diff --git a/docs/Открытые_вопросы_v8_3.md b/docs/Открытые_вопросы_v8_3.md index 5350250c..ace4e5aa 100644 --- a/docs/Открытые_вопросы_v8_3.md +++ b/docs/Открытые_вопросы_v8_3.md @@ -2,7 +2,51 @@ **Назначение:** единый рабочий список вопросов, требующих решения заказчика для разблокировки разработки. Разбит по адресатам, внутри — по приоритету. -**Версия:** 1.60 от 09.05.2026 — **GET /api/deals + замена MOCK_DEALS** после SupplierResolver-extract v1.59: index-endpoint в `DealController` со всеми UI-фильтрами + relations + RLS-обёртка (defense-in-depth `where(tenant_id)` поверх RLS); mapper `ApiDeal→MockDeal`; DealsView+KanbanView грузят данные на `onMounted` с fallback на MOCK_DEALS при network-error. **Pest 186/186 (742 assertions) + Vitest 261/261 + Histoire 21/28 зелёные**. +**Версия:** 1.61 от 09.05.2026 — **bulk-transition + reload-btn** после GET /api/deals v1.60: POST /api/deals/transition (status-validation+RLS+ActivityLog `deal.status_changed`); `applyBulkStatus` в DealsView интегрирован через optimistic update + toast; reload-btn в DealsView и KanbanView (manual fetch вместо polling). **Pest 193/193 (767 assertions) + Vitest 266/266 + Histoire 21/28 зелёные**. + +**Что изменилось в v1.61 относительно v1.60:** + +- **(1) Backend `DealController::transition`** — POST /api/deals/transition: + - Body: `{tenant_id, ids: [1..1000 ints], status: slug}`. + - Валидация status: `DB::table('lead_statuses')->where('slug', X)->exists()` — 422 если не существует. `lead_statuses` глобальная (НЕ tenant-aware), system+custom slug'и в одном scope. + - RLS + defense-in-depth `where('tenant_id', $tenantId)->whereIn('id', $ids)` — partial-update: `updated < requested` если часть id из другого tenant'а. + - `ActivityLog::create(event=deal.status_changed, context={from, to, source: 'bulk'})` для каждой ИЗМЕНЁННОЙ сделки. NO-OP (старый==новый) — НЕ пишется в audit, иначе спам. + - Ответ: `{updated, requested, status}`. + +- **(2) Pest +7** в DealTransitionTest: + 1. 422 missing fields; 2. 404 unknown tenant; 3. 422 неизвестный slug + сделка не апдейтится; + 2. Batch update 3 сделок + 3 ActivityLog с `from='new', to='paid', source='bulk'`; + 3. NO-OP не пишет ActivityLog; + 4. Defense-in-depth: 2 id из разных tenant'ов → updated=1 / requested=2; чужой остаётся в исходном статусе; + 5. 422 пустой массив ids. + +- **(3) Frontend integration:** + - **`dealsApi.transitionDeals`** — типизированный helper с `ensureCsrfCookie` (mutating). + - **`applyBulkStatus` в DealsView**: переписан с sync на async — optimistic local-update (UI отвечает сразу), затем backend-вызов если auth.user.tenant_id. На success — `statusToast «Обновлено N из M.»`, на fail — `«Не удалось сохранить статус — изменения только локально.»` + локальный update НЕ откатывается (UX rationale: пользователь видит что хотел, перезагрузит позже). + - Без auth.user — только optimistic, API НЕ вызывается (legacy local-mode сохранён). + +- **(4) Reload-btn** в DealsView и KanbanView — outlined button «Обновить» с mdi-refresh, привязан к `loadDeals`. В DealsView `:loading="loading"` chip крутится во время fetch'а. Заменяет polling/SSE до прихода long-poll'а в production. + +- **(5) Vitest +5**: + - DealsView: reload-btn вызывает listDeals дважды; applyBulkStatus с tenant_id вызывает transitionDeals + optimistic update + toast; applyBulkStatus без tenant_id — только локально; applyBulkStatus reject — toast warning + локальный update НЕ откатывается. + - KanbanView: reload-btn вызывает listDeals дважды. + +- **PHPStan baseline регенерирован**. + +- **Регресс зелёный:** + - `npm run lint:vue` + `type-check` + `format` — passed. + - `npm run test:vue` — **266/266 за 18.16 сек** (+5 от 261). + - `npm run build` — vite OK 1.06 сек. + - `composer pint` + `composer stan` — passed. + - `composer test` — **Pest 193/193 за 23.27 сек** (+7 от 186, 767 assertions). + +- **Что НЕ сделано (production TODO остаточные):** + - Реальный XLSX-export через PhpSpreadsheet (CSV достаточен на MVP). + - Polling/SSE для real-time обновления (на MVP — manual reload-btn). + - **#6 Yandex 360 SSO** ⏸ ждёт Б-1. + - **#7 Pest browser-mode** — отложен (инфра, требует Playwright). + +- **Сводка §0:** без изменений (70 ✅ / 5 🟦 / 4 ⏸ / 1 P0 + 3 P1 + 0 P2). **Что изменилось в v1.60 относительно v1.59:**