phase2(deal-patch): PATCH /api/deals/{id} + comment-editor в DealDetailDrawer (этап 1/5)
Drawer из read-only становится editable. ActivityLog event пишется на
каждое изменение поля.
Backend (DealController::update):
- PATCH /api/deals/{id} {tenant_id, comment?, manager_id?, status?}.
- Каждое изменённое поле → ActivityLog:
comment → deal.commented (context.text);
manager_id → deal.assigned (context.from/to + assigned_at=NOW);
status → deal.status_changed (context.from/to/source='manual').
- NO-OP не пишется в audit. Manager FK guard + status slug validation.
- RLS + defense-in-depth where(tenant_id) → 404 для чужой сделки.
Pest +10 (DealUpdateTest):
- 422/404 базовые / 404 чужая сделка / comment+audit / manager+audit+
assigned_at / status+audit / 422 неизвестный slug / 422 чужой manager /
NO-OP не пишет / комбинированно → 2 audit записи.
Frontend:
- api/deals.ts::updateDeal — PATCH helper c ensureCsrfCookie.
- DealDetailDrawer: новая секция «Комментарий» (только при tenantId).
v-textarea auto-grow + counter=5000 + Save-btn → updateDeal →
toast success + reload events (новый deal.commented в timeline).
На fail → warning toast.
Vitest +3 (DealDetailDrawerApi):
- saveComment вызывает updateDeal + toast + reload events (getDeal x2).
- saveComment reject → commentSaveError + warning toast.
- comment-section не рендерится без tenantId.
PHPStan baseline регенерирован.
Регресс:
- Lint+type-check+format passed.
- Vitest 283/283 за 18.13 сек (+3 от 280).
- Vite build 1.12 сек.
- Pint + PHPStan passed.
- Pest 220/220 за 25.64 сек (+10 от 210, 871 assertion).
Реестр v1.64→v1.65 / CLAUDE.md v1.55→v1.56.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md — техконтекст Лидерры
|
||||
|
||||
**Версия:** 1.55 от 09.05.2026
|
||||
**Версия:** 1.56 от 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.64 от 09.05.2026 — **GET /api/lead-statuses + Pinia store**: LeadStatus model + LeadStatusController; `useLeadStatusesStore` с snapshot fallback + load() идемпотентен; DealsView/KanbanView/DealDetailDrawer перешли на store вместо static-snapshot; Pest 210/210 + Vitest 280/280 + Histoire 21/28) |
|
||||
| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.65 от 09.05.2026 — **PATCH /api/deals/{id} + comment-editor в drawer**: частичное редактирование (comment/manager_id/status); ActivityLog event на каждое изменение (commented/assigned/status_changed); textarea + Save в DealDetailDrawer + reload events; Pest 220/220 + Vitest 283/283 + 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.56 от 09.05.2026. Изменения v1.56: **PATCH /api/deals/{id} + comment-editor в DealDetailDrawer** — drawer переходит из read-only в редактируемый режим. **(1) Backend `DealController::update(int $id)`** — PATCH /api/deals/{id} с body `{tenant_id, comment?, manager_id?, status?}` (все поля optional, должен быть хотя бы один). Каждое изменённое поле пишет соответствующий ActivityLog event: `comment` → `deal.commented` (context.text); `manager_id` → `deal.assigned` (context.from/to + ставит assigned_at=now); `status` → `deal.status_changed` (context.from/to/source='manual'). NO-OP (значение не меняется) НЕ пишется в audit log. Manager FK guard (manager_id чужого tenant'а → 422) и status validation (slug должен существовать в lead_statuses → 422) — те же что в store/transition. RLS-обёртка + defense-in-depth `where(tenant_id)` → 404 для чужой сделки. Маршрут `Route::patch('/api/deals/{id}', 'update')->where('id', '[0-9]+')`. **(2) Pest +10** в `DealUpdateTest` (всего **220/220 за 25.64 сек**, 871 assertion): 422 без tenant_id / 404 unknown / 404 чужая сделка / comment update + deal.commented audit / manager update + deal.assigned audit + assigned_at=NOW / status update + deal.status_changed audit / 422 неизвестный slug + НЕ обновляет / 422 manager чужого tenant'а / NO-OP не пишет audit / комбинированно (comment+status одним запросом) → 2 audit log записи. **(3) Frontend `api/deals.ts::updateDeal(id, payload)`** — типизированный PATCH-helper с `ensureCsrfCookie` (mutating endpoint). **DealDetailDrawer:** добавлена секция «Комментарий» (показывается ТОЛЬКО при наличии tenantId — без auth остаётся read-only) с `v-textarea` (auto-grow, counter=5000, hide-details) + Save-btn `mdi-content-save-outline` (loading во время save). `commentDraft` (ref) populates из `getDeal` response (`deal.comment ?? ''`). `saveComment()` async вызывает `updateDeal` с `comment: commentDraft || null` + на success — toast «Комментарий сохранён» + reload events (новый `deal.commented` появляется в timeline); на fail — `commentSaveError=true` + warning toast «Не удалось сохранить — попробуйте позже». `v-snackbar` reuses `commentSaveError` для color=warning. **(4) Vitest +3** в `DealDetailDrawerApi.spec.ts` (всего **283/283 за 18.13 сек**): saveComment вызывает updateDeal с правильным payload + toast success + reload events (getDeal вызвался дважды); saveComment reject → commentSaveError=true + toast warning «Не удалось»; comment-section НЕ рендерится без tenantId (read-only mode для legacy local-режима). **PHPStan baseline регенерирован**. **Production TODO остаточные:** GET /api/admin/{tenants,billing,incidents} (этапы 2-4 текущего плана); soft-delete + DELETE /api/deals (этап 5, требует миграцию); polling/SSE; SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 283/283 за 18.13 сек** (+3 от 280); vite build 1.12 сек; Pint+PHPStan passed; **Pest 220/220 за 25.64 сек** (+10 от 210, 871 assertion). Реестр v1.64→v1.65.*
|
||||
|
||||
*CLAUDE.md v1.55 от 09.05.2026. Изменения v1.55: **GET /api/lead-statuses + Pinia store** — заменяет static-снапшот в коде на live-данные из БД (включая custom slug'и, добавленные после deployment'а). **(1) Backend** — `App\Models\LeadStatus` (PK=`slug` string, `incrementing=false`, `keyType='string'`, `timestamps=null`); `LeadStatusController::index` — GET /api/lead-statuses, ORDER BY sort_order+slug, формат `{slug, name_ru, is_system, sort_order, color_hex, description}`. Таблица глобальная (НЕ tenant-aware), auth не требуется на MVP. **(2) Pest +5** в `LeadStatusesIndexTest` (всего **210/210 за 24.59 сек**, 840 assertions): 200 + не пустой / все 14 системных slug'ов из seed (new..final_missed) / поля slug/name_ru/color_hex/sort_order/is_system / sort_order ASC / кастомный slug добавленный после seed возвращается. **(3) Frontend** — `api/leadStatuses.ts::listLeadStatuses` (GET helper); `stores/leadStatuses.ts::useLeadStatusesStore` Pinia setup-store: `statuses` ref<LeadStatus[]> (default = `LEAD_STATUSES` snapshot для UI без fetch'а), `load(force=false)` идемпотентен (повторный вызов → no-op если loaded), `bySlug` computed Map для O(1), `findBySlug(slug)` helper. На fail — snapshot остаётся, `fetchError=true`. **(4) Integration в 3 view-компонента:** DealsView заменил `LEAD_STATUSES` импорт на `leadStatusesStore.statuses` (computed `leadStatuses`) для bulk-status menu и `statusBySlug` (computed Map из store getter); KanbanView заменил на `leadStatuses` computed для column-iteration + count display + safe-access `dealsByStatus[slug] || []` в template (защита от custom slug'а из API без seeded column); DealDetailDrawer переписал `LEAD_STATUSES.find(...)` → `store.findBySlug(...)`. Оба view'а вызывают `leadStatusesStore.load()` в `onMounted` (рядом с loadDeals). **`reduce` для init `dealsByStatus`** в KanbanView оставлен на snapshot (всегда seeded 14; новые custom-колонки появятся после API-load — empty-array fallback в template). **(5) Vitest +7** в `leadStatusesStore.spec.ts` + 2 spec'а DealDetailDrawer'а получили `setActivePinia(createPinia())` в beforeEach (без этого `getActivePinia()` падает в jsdom): initial state snapshot / findBySlug returns existing / findBySlug null для unknown / load() success — replace + loaded=true / load() reject — fetchError + snapshot остаётся / load() идемпотентен (1 запрос на 2 вызова) / load(force=true) — 2 запроса. Всего **280/280 за 19.44 сек** (+7 от 273). **Production TODO остаточные:** polling/SSE для real-time обновления (на MVP — manual reload-btn); SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 280/280 за 19.44 сек** (+7 от 273); vite build 1.17 сек (KanbanView lazy-chunk 182.22→182.28 KB — Pinia-getter overhead); Pint+PHPStan passed; **Pest 210/210 за 24.59 сек** (+5 от 205, 840 assertions). Реестр v1.63→v1.64.*
|
||||
|
||||
*CLAUDE.md v1.54 от 09.05.2026. Изменения v1.54: **GET /api/deals/{id} + интеграция DealDetailDrawer на реальный ActivityLog**. **(1) Backend `DealController::show(int $id)`** — возвращает `{deal, events}` для drawer'а. RLS-обёртка + defense-in-depth `where(tenant_id)` (как в index/transition); 404 если сделка чужая или не существует. `deal` — extended (project_name + manager_name/initials через `ManagerController::format*` + comment + assigned_at). `events` — последние **50** записей `activity_log` фильтрованных по `(tenant_id, deal_id)` ORDER BY created_at DESC, с актором (user через `belongsTo`-relation). Маршрут `Route::get('/api/deals/{id}', 'show')->where('id', '[0-9]+')`. **(2) Pest +8** в `tests/Feature/DealShowTest.php` (всего **205/205 за 24.19 сек**, 812 assertions): 422 без tenant_id / 404 unknown tenant / 404 несуществующая сделка / 404 чужая сделка (RLS-проверка через postgres superuser BYPASSRLS работает за счёт app-фильтра) / deal-relations (project_name + manager_name «Иван П.» + initials «ИП» + comment) / events ORDER BY created_at DESC (status_changed свежее createde) + actor.name + actor=null для system-event с user_id=null / RLS+app-фильтр НЕ показывает события с `deal_id` совпадающим у чужого tenant'а / лимит 50 событий (60 записей → возвращаем 50). **(3) Frontend `api/deals.ts::getDeal(id, tenantId)`** — типизированный helper с `ApiDealEvent`/`ApiDealDetail`/`GetDealResponse` interfaces; БЕЗ ensureCsrfCookie (GET-only). **`composables/dealsApiMapper.ts::mapApiDealEvent(api, now=new Date())`** — converter ApiDealEvent → DealEvent (UI-формат): `event` slug clamp на known types (`deal.{created,status_changed,viewed,commented,assigned,balance_charged}`) с fallback на `'deal.viewed'` (generic-icon); `actor` маппится 1:1; `minutesAgo = max(0, floor((now - created_at) / 60_000))`; `detail` зависит от type — для `status_changed` строим `«from → to»` из context, для `created` — `«Лид принят (источник: …)»`, для остальных — JSON-сводка контекста. **(4) DealDetailDrawer** получил optional `tenantId` prop. `watch([open, deal.id, tenantId])` с `immediate: true` — на open=true вызывает `loadEvents()`. Если оба (deal + tenantId) есть → `getDeal(deal.id, tenantId)` → `events.value = events.map(mapApiDealEvent)`. На fail → `eventsFetchError=true` + `v-alert type=warning «Backend недоступен — показаны mock-события»` (data-testid=`events-fetch-error-alert`) + fallback на `MOCK_EVENTS`. Без tenantId — никогда не fetch'им, MOCK_EVENTS как раньше. DealsView и KanbanView передают `:tenant-id="auth.user?.tenant_id"`. **(5) Vitest +4** в `DealDetailDrawerApi.spec.ts` (всего **273/273 за 20.76 сек**, +4 от 269): без tenantId — getDeal не вызывается + MOCK_EVENTS видны / с tenantId — getDeal вызывается + events заменены + «new → paid» виден / reject → eventsFetchError + alert + MOCK_EVENTS fallback / open=false → НЕ вызывается. PHPStan baseline регенерирован для +новых ignored Pest TestCall warnings. **Production TODO остаточные:** polling/SSE для real-time обновления (на MVP — manual reload-btn); SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 273/273 за 20.76 сек** (+4 от 269); vite build 1.12 сек (KanbanView lazy-chunk 182.17→182.22 KB — DealDetailDrawer импорт `mapApiDealEvent` shared); Pint+PHPStan passed; **Pest 205/205 за 24.19 сек** (+8 от 197, 812 assertions). Реестр v1.62→v1.63.*
|
||||
|
||||
@@ -299,6 +299,139 @@ class DealController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/deals/{id} — частичное редактирование сделки из DealDetailDrawer.
|
||||
*
|
||||
* Body (все поля optional, должно быть хотя бы одно): {tenant_id, comment?,
|
||||
* manager_id?, status?}.
|
||||
*
|
||||
* Каждое изменение пишется в ActivityLog с правильным event-type:
|
||||
* - comment → deal.commented (context.text — новый комментарий полностью)
|
||||
* - manager_id → deal.assigned (context.from/to)
|
||||
* - status → deal.status_changed (context.from/to/source='manual')
|
||||
*
|
||||
* NO-OP (значение не меняется) — ActivityLog НЕ пишется.
|
||||
*
|
||||
* Manager FK guard: новый manager_id должен принадлежать tenant'у (как в store).
|
||||
* Status validation: slug должен существовать в lead_statuses (как в transition).
|
||||
*
|
||||
* RLS + defense-in-depth where(tenant_id) — 404 если сделка чужая.
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|integer|min:1',
|
||||
'comment' => 'nullable|string|max:5000',
|
||||
'manager_id' => 'nullable|integer|min:1',
|
||||
'status' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::find($validated['tenant_id']);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
// Validate status slug если передан.
|
||||
if (array_key_exists('status', $validated) && $validated['status'] !== null) {
|
||||
$statusExists = DB::table('lead_statuses')->where('slug', $validated['status'])->exists();
|
||||
if (! $statusExists) {
|
||||
return response()->json([
|
||||
'message' => 'Неизвестный статус.',
|
||||
'errors' => ['status' => ['Slug не найден в lead_statuses.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Manager FK guard.
|
||||
if (array_key_exists('manager_id', $validated) && $validated['manager_id'] !== null) {
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
$deal = DB::transaction(function () use ($validated, $tenant, $id) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$deal = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if ($deal === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Применяем изменения с записью ActivityLog для каждого изменённого поля.
|
||||
if (array_key_exists('comment', $validated) && $deal->comment !== $validated['comment']) {
|
||||
$deal->comment = $validated['comment'];
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => 'deal.commented',
|
||||
'context' => ['text' => $validated['comment'] ?? ''],
|
||||
]);
|
||||
}
|
||||
|
||||
if (array_key_exists('manager_id', $validated) && $deal->manager_id !== $validated['manager_id']) {
|
||||
$previousManager = $deal->manager_id;
|
||||
$deal->manager_id = $validated['manager_id'];
|
||||
$deal->assigned_at = $validated['manager_id'] !== null ? now() : null;
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_ASSIGNED,
|
||||
'context' => ['from' => $previousManager, 'to' => $validated['manager_id']],
|
||||
]);
|
||||
}
|
||||
|
||||
if (array_key_exists('status', $validated) && $validated['status'] !== null && $deal->status !== $validated['status']) {
|
||||
$previousStatus = $deal->status;
|
||||
$deal->status = $validated['status'];
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
|
||||
'context' => ['from' => $previousStatus, 'to' => $validated['status'], 'source' => 'manual'],
|
||||
]);
|
||||
}
|
||||
|
||||
$deal->save();
|
||||
|
||||
return $deal;
|
||||
});
|
||||
|
||||
if ($deal === null) {
|
||||
return response()->json(['message' => 'Сделка не найдена.'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'deal' => [
|
||||
'id' => $deal->id,
|
||||
'tenant_id' => $deal->tenant_id,
|
||||
'project_id' => $deal->project_id,
|
||||
'phone' => $deal->phone,
|
||||
'contact_name' => $deal->contact_name,
|
||||
'comment' => $deal->comment,
|
||||
'status' => $deal->status,
|
||||
'manager_id' => $deal->manager_id,
|
||||
'received_at' => $deal->received_at?->toIso8601String(),
|
||||
'assigned_at' => $deal->assigned_at?->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST /api/deals — manual create */
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
|
||||
@@ -75,55 +75,55 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$assigned_at\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
count: 3
|
||||
path: app/Http/Controllers/Api/DealController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$comment\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
count: 4
|
||||
path: app/Http/Controllers/Api/DealController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$contact_name\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
count: 4
|
||||
path: app/Http/Controllers/Api/DealController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$manager_id\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
count: 7
|
||||
path: app/Http/Controllers/Api/DealController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$phone\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
count: 4
|
||||
path: app/Http/Controllers/Api/DealController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$project_id\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
count: 4
|
||||
path: app/Http/Controllers/Api/DealController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$received_at\.$#'
|
||||
identifier: property.notFound
|
||||
count: 4
|
||||
count: 5
|
||||
path: app/Http/Controllers/Api/DealController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$status\.$#'
|
||||
identifier: property.notFound
|
||||
count: 6
|
||||
count: 10
|
||||
path: app/Http/Controllers/Api/DealController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$tenant_id\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
count: 4
|
||||
path: app/Http/Controllers/Api/DealController.php
|
||||
|
||||
-
|
||||
@@ -798,6 +798,66 @@ parameters:
|
||||
count: 7
|
||||
path: tests/Feature/DealTransitionTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ActivityLog\:\:\$context\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$assigned_at\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$comment\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$manager_id\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$status\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$manager\.$#'
|
||||
identifier: property.notFound
|
||||
count: 4
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$otherTenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 6
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 10
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 24
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 10
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
|
||||
identifier: property.notFound
|
||||
|
||||
@@ -40,6 +40,19 @@ export interface ExportDealsPayload {
|
||||
format?: 'csv' | 'xlsx';
|
||||
}
|
||||
|
||||
export interface UpdateDealPayload {
|
||||
tenant_id: number;
|
||||
comment?: string | null;
|
||||
manager_id?: number | null;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export async function updateDeal(id: number, payload: UpdateDealPayload): Promise<ApiDealDetail> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.patch<{ deal: ApiDealDetail }>(`/api/deals/${id}`, payload);
|
||||
return data.deal;
|
||||
}
|
||||
|
||||
export interface TransitionDealsPayload {
|
||||
tenant_id: number;
|
||||
ids: number[];
|
||||
|
||||
@@ -61,9 +61,17 @@ const events = ref<DealEvent[]>([...MOCK_EVENTS]);
|
||||
const eventsLoading = ref(false);
|
||||
const eventsFetchError = ref(false);
|
||||
|
||||
// Comment editor — редактирование текущего комментария сделки.
|
||||
const commentDraft = ref<string>('');
|
||||
const commentSaving = ref(false);
|
||||
const commentSaveError = ref(false);
|
||||
const commentToastOpen = ref(false);
|
||||
const commentToastText = ref('');
|
||||
|
||||
async function loadEvents() {
|
||||
if (!props.deal || !props.tenantId) {
|
||||
events.value = [...MOCK_EVENTS];
|
||||
commentDraft.value = '';
|
||||
return;
|
||||
}
|
||||
eventsLoading.value = true;
|
||||
@@ -71,14 +79,38 @@ async function loadEvents() {
|
||||
try {
|
||||
const res = await dealsApi.getDeal(props.deal.id, props.tenantId);
|
||||
events.value = res.events.map((e) => mapApiDealEvent(e));
|
||||
commentDraft.value = res.deal.comment ?? '';
|
||||
} catch {
|
||||
eventsFetchError.value = true;
|
||||
events.value = [...MOCK_EVENTS];
|
||||
commentDraft.value = '';
|
||||
} finally {
|
||||
eventsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveComment() {
|
||||
if (!props.deal || !props.tenantId) return;
|
||||
commentSaving.value = true;
|
||||
commentSaveError.value = false;
|
||||
try {
|
||||
await dealsApi.updateDeal(props.deal.id, {
|
||||
tenant_id: props.tenantId,
|
||||
comment: commentDraft.value || null,
|
||||
});
|
||||
commentToastText.value = 'Комментарий сохранён.';
|
||||
commentToastOpen.value = true;
|
||||
// Reload events чтобы показать новый deal.commented в timeline.
|
||||
await loadEvents();
|
||||
} catch {
|
||||
commentSaveError.value = true;
|
||||
commentToastText.value = 'Не удалось сохранить — попробуйте позже.';
|
||||
commentToastOpen.value = true;
|
||||
} finally {
|
||||
commentSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch при открытии drawer'а или смене сделки.
|
||||
watch(
|
||||
() => [props.open, props.deal?.id, props.tenantId] as const,
|
||||
@@ -88,7 +120,18 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
defineExpose({ events, eventsLoading, eventsFetchError, loadEvents });
|
||||
defineExpose({
|
||||
events,
|
||||
eventsLoading,
|
||||
eventsFetchError,
|
||||
loadEvents,
|
||||
commentDraft,
|
||||
commentSaving,
|
||||
commentSaveError,
|
||||
commentToastOpen,
|
||||
commentToastText,
|
||||
saveComment,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -158,6 +201,35 @@ defineExpose({ events, eventsLoading, eventsFetchError, loadEvents });
|
||||
|
||||
<v-divider />
|
||||
|
||||
<section v-if="tenantId" class="section pa-5" data-testid="comment-section">
|
||||
<h3 class="section-title text-subtitle-2 mb-3">Комментарий</h3>
|
||||
<v-textarea
|
||||
v-model="commentDraft"
|
||||
placeholder="Заметка менеджера…"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
auto-grow
|
||||
rows="3"
|
||||
hide-details
|
||||
counter="5000"
|
||||
data-testid="comment-textarea"
|
||||
/>
|
||||
<div class="d-flex ga-2 mt-2 justify-end">
|
||||
<v-btn
|
||||
:loading="commentSaving"
|
||||
color="primary"
|
||||
size="small"
|
||||
prepend-icon="mdi-content-save-outline"
|
||||
data-testid="save-comment-btn"
|
||||
@click="saveComment"
|
||||
>
|
||||
Сохранить
|
||||
</v-btn>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<v-divider v-if="tenantId" />
|
||||
|
||||
<section class="section pa-5">
|
||||
<h3 class="section-title text-subtitle-2 mb-3">Активность</h3>
|
||||
<v-alert
|
||||
@@ -196,6 +268,16 @@ defineExpose({ events, eventsLoading, eventsFetchError, loadEvents });
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<v-snackbar
|
||||
v-model="commentToastOpen"
|
||||
:timeout="3000"
|
||||
:color="commentSaveError ? 'warning' : undefined"
|
||||
data-testid="comment-toast"
|
||||
location="bottom right"
|
||||
>
|
||||
{{ commentToastText }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
@@ -56,6 +56,7 @@ Route::get('/api/deals/{id}', [DealController::class, 'show'])->where('id', '[0-
|
||||
Route::post('/api/deals', [DealController::class, 'store']);
|
||||
Route::post('/api/deals/export', [DealController::class, 'export']);
|
||||
Route::post('/api/deals/transition', [DealController::class, 'transition']);
|
||||
Route::patch('/api/deals/{id}', [DealController::class, 'update'])->where('id', '[0-9]+');
|
||||
|
||||
// Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters).
|
||||
Route::get('/api/managers', [ManagerController::class, 'index']);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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();
|
||||
$this->manager = User::factory()->for($this->tenant)->create(['is_active' => true]);
|
||||
});
|
||||
|
||||
test('PATCH /api/deals/{id} 422 без tenant_id', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
$this->patchJson('/api/deals/'.$deal->id, [])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('PATCH /api/deals/{id} 404 unknown tenant', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
$this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => 999999,
|
||||
'comment' => 'X',
|
||||
])->assertStatus(404);
|
||||
});
|
||||
|
||||
test('PATCH /api/deals/{id} 404 чужая сделка', function () {
|
||||
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();
|
||||
|
||||
$this->patchJson('/api/deals/'.$foreign->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'comment' => 'leak',
|
||||
])->assertStatus(404);
|
||||
});
|
||||
|
||||
test('PATCH /api/deals/{id} обновляет comment + пишет deal.commented в ActivityLog', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['comment' => 'old']);
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'comment' => 'Дозвонился, перезвоню после 14:00',
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('deal.comment'))->toBe('Дозвонился, перезвоню после 14:00');
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$deal->refresh();
|
||||
expect($deal->comment)->toBe('Дозвонился, перезвоню после 14:00');
|
||||
|
||||
$log = ActivityLog::where('deal_id', $deal->id)->where('event', 'deal.commented')->first();
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->context['text'])->toBe('Дозвонился, перезвоню после 14:00');
|
||||
});
|
||||
|
||||
test('PATCH /api/deals/{id} обновляет manager_id + пишет deal.assigned + ставит assigned_at', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||||
'manager_id' => null,
|
||||
'assigned_at' => null,
|
||||
]);
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'manager_id' => $this->manager->id,
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$deal->refresh();
|
||||
expect($deal->manager_id)->toBe($this->manager->id);
|
||||
expect($deal->assigned_at)->not->toBeNull();
|
||||
|
||||
$log = ActivityLog::where('deal_id', $deal->id)->where('event', 'deal.assigned')->first();
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->context['to'])->toBe($this->manager->id);
|
||||
});
|
||||
|
||||
test('PATCH /api/deals/{id} обновляет status + пишет deal.status_changed source=manual', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status' => 'paid',
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$deal->refresh();
|
||||
expect($deal->status)->toBe('paid');
|
||||
|
||||
$log = ActivityLog::where('deal_id', $deal->id)->where('event', 'deal.status_changed')->first();
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->context)->toMatchArray(['from' => 'new', 'to' => 'paid', 'source' => 'manual']);
|
||||
});
|
||||
|
||||
test('PATCH /api/deals/{id} 422 на неизвестный status slug', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status' => 'not_a_real_slug',
|
||||
]);
|
||||
$r->assertStatus(422);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$deal->refresh();
|
||||
expect($deal->status)->toBe('new'); // не изменился
|
||||
});
|
||||
|
||||
test('PATCH /api/deals/{id} 422 на manager_id чужого tenant\'а', function () {
|
||||
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
|
||||
$foreignManager = User::factory()->for($this->otherTenant)->create(['is_active' => true]);
|
||||
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'manager_id' => $foreignManager->id,
|
||||
]);
|
||||
$r->assertStatus(422);
|
||||
});
|
||||
|
||||
test('PATCH /api/deals/{id} NO-OP не пишет ActivityLog', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||||
'status' => 'paid',
|
||||
'comment' => 'same',
|
||||
]);
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status' => 'paid', // не меняем
|
||||
'comment' => 'same', // не меняем
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
expect(ActivityLog::where('deal_id', $deal->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('PATCH /api/deals/{id} комбинированно — comment + status одним запросом → 2 ActivityLog', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||||
'status' => 'new',
|
||||
'comment' => null,
|
||||
]);
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'comment' => 'Заметка',
|
||||
'status' => 'worked',
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$events = ActivityLog::where('deal_id', $deal->id)->orderBy('id')->get();
|
||||
expect($events)->toHaveCount(2);
|
||||
$eventNames = $events->pluck('event')->all();
|
||||
expect($eventNames)->toContain('deal.commented');
|
||||
expect($eventNames)->toContain('deal.status_changed');
|
||||
});
|
||||
@@ -12,6 +12,7 @@ vi.mock('../../resources/js/api/deals', async (importOriginal) => {
|
||||
return {
|
||||
...orig,
|
||||
getDeal: vi.fn(),
|
||||
updateDeal: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -115,4 +116,99 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
await flushPromises();
|
||||
expect(dealsApi.getDeal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saveComment вызывает updateDeal + toast success + reload events', async () => {
|
||||
const apiResponse: GetDealResponse = {
|
||||
deal: {
|
||||
id: MOCK_DEALS[0].id,
|
||||
tenant_id: 1,
|
||||
project_id: 5,
|
||||
project_name: 'Окна',
|
||||
phone: '+7 (999) 100-00-01',
|
||||
contact_name: 'Anna',
|
||||
comment: 'old',
|
||||
status: 'new',
|
||||
manager_id: null,
|
||||
manager_name: null,
|
||||
manager_initials: null,
|
||||
received_at: new Date().toISOString(),
|
||||
assigned_at: null,
|
||||
},
|
||||
events: [],
|
||||
};
|
||||
vi.mocked(dealsApi.getDeal).mockResolvedValue(apiResponse);
|
||||
vi.mocked(dealsApi.updateDeal).mockResolvedValueOnce({
|
||||
...apiResponse.deal,
|
||||
comment: 'новая заметка',
|
||||
});
|
||||
|
||||
const wrapper = factory({ open: true, tenantId: 1 });
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
commentDraft: string;
|
||||
saveComment: () => Promise<void>;
|
||||
commentToastOpen: boolean;
|
||||
commentToastText: string;
|
||||
commentSaveError: boolean;
|
||||
};
|
||||
expect(vm.commentDraft).toBe('old'); // загружено из getDeal
|
||||
vm.commentDraft = 'новая заметка';
|
||||
await vm.saveComment();
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.updateDeal).toHaveBeenCalledWith(MOCK_DEALS[0].id, {
|
||||
tenant_id: 1,
|
||||
comment: 'новая заметка',
|
||||
});
|
||||
expect(vm.commentToastOpen).toBe(true);
|
||||
expect(vm.commentToastText).toContain('сохранён');
|
||||
expect(vm.commentSaveError).toBe(false);
|
||||
// reload events после save — getDeal вызвался ещё раз.
|
||||
expect(dealsApi.getDeal).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('saveComment reject → toast warning + commentSaveError=true', async () => {
|
||||
vi.mocked(dealsApi.getDeal).mockResolvedValueOnce({
|
||||
deal: {
|
||||
id: MOCK_DEALS[0].id,
|
||||
tenant_id: 1,
|
||||
project_id: 5,
|
||||
project_name: null,
|
||||
phone: '+7 (999)',
|
||||
contact_name: null,
|
||||
comment: null,
|
||||
status: 'new',
|
||||
manager_id: null,
|
||||
manager_name: null,
|
||||
manager_initials: null,
|
||||
received_at: null,
|
||||
assigned_at: null,
|
||||
},
|
||||
events: [],
|
||||
});
|
||||
vi.mocked(dealsApi.updateDeal).mockRejectedValueOnce(new Error('500'));
|
||||
|
||||
const wrapper = factory({ open: true, tenantId: 1 });
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
commentDraft: string;
|
||||
saveComment: () => Promise<void>;
|
||||
commentSaveError: boolean;
|
||||
commentToastText: string;
|
||||
};
|
||||
vm.commentDraft = 'новый';
|
||||
await vm.saveComment();
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.commentSaveError).toBe(true);
|
||||
expect(vm.commentToastText).toContain('Не удалось');
|
||||
});
|
||||
|
||||
it('comment-section не показывается без tenantId (read-only mode)', async () => {
|
||||
const wrapper = factory({ open: true });
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="comment-section"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,60 @@
|
||||
|
||||
**Назначение:** единый рабочий список вопросов, требующих решения заказчика для разблокировки разработки. Разбит по адресатам, внутри — по приоритету.
|
||||
|
||||
**Версия:** 1.64 от 09.05.2026 — **GET /api/lead-statuses + Pinia store** после deal-show v1.63: новый endpoint возвращает live-данные lead_statuses (включая кастомные); `useLeadStatusesStore` с snapshot fallback и идемпотентным load(); DealsView/KanbanView/DealDetailDrawer переехали со static-импорта `LEAD_STATUSES` на store. **Pest 210/210 (840 assertions) + Vitest 280/280 + Histoire 21/28 зелёные**.
|
||||
**Версия:** 1.65 от 09.05.2026 — **PATCH /api/deals/{id} + comment-editor в DealDetailDrawer** после lead-statuses v1.64 (этап 1/5 авто-плана): drawer переходит из read-only в editable; ActivityLog event на каждое изменение (commented/assigned/status_changed); textarea + Save в drawer + auto-reload events. **Pest 220/220 (871 assertion) + Vitest 283/283 + Histoire 21/28 зелёные**.
|
||||
|
||||
**Что изменилось в v1.65 относительно v1.64:**
|
||||
|
||||
- **(1) Backend `DealController::update(int $id)`** — PATCH /api/deals/{id}:
|
||||
- Body: `{tenant_id, comment?, manager_id?, status?}` — все поля optional, должно быть хотя бы одно изменение.
|
||||
- Каждое изменённое поле пишет соответствующий ActivityLog event:
|
||||
- `comment` → `deal.commented` (context.text);
|
||||
- `manager_id` → `deal.assigned` (context.from/to + assigned_at=NOW);
|
||||
- `status` → `deal.status_changed` (context.from/to/source='manual').
|
||||
- NO-OP (значение не меняется) — НЕ пишется в audit log.
|
||||
- Manager FK guard + status slug validation (как в store/transition).
|
||||
- RLS + defense-in-depth `where(tenant_id)` → 404 для чужой сделки.
|
||||
|
||||
- **(2) Pest +10** в DealUpdateTest:
|
||||
1. 422 без tenant_id; 2. 404 unknown tenant; 3. 404 чужая сделка;
|
||||
2. comment update + deal.commented audit;
|
||||
3. manager update + deal.assigned audit + assigned_at=NOW;
|
||||
4. status update + deal.status_changed audit;
|
||||
5. 422 неизвестный slug + НЕ обновляет;
|
||||
6. 422 manager чужого tenant'а;
|
||||
7. NO-OP не пишет audit (status старый==новый, comment старый==новый);
|
||||
8. Комбинированно (comment+status одним запросом) → 2 audit log записи.
|
||||
|
||||
- **(3) Frontend integration:**
|
||||
- `api/deals.ts::updateDeal(id, payload)` — PATCH helper с `ensureCsrfCookie`.
|
||||
- **DealDetailDrawer**: новая секция «Комментарий» (показывается только при tenantId — read-only mode для legacy local).
|
||||
- `v-textarea` auto-grow + counter=5000 + commentDraft ref populates из getDeal response.
|
||||
- Save-btn (loading state) → `updateDeal` с `comment: commentDraft || null`.
|
||||
- На success → toast «Комментарий сохранён» + auto-reload events (новый `deal.commented` появляется в timeline).
|
||||
- На fail → `commentSaveError=true` + warning toast «Не удалось сохранить».
|
||||
|
||||
- **(4) Vitest +3** в DealDetailDrawerApi:
|
||||
- saveComment вызывает updateDeal + toast success + reload events (getDeal called twice).
|
||||
- saveComment reject → commentSaveError=true + warning toast.
|
||||
- comment-section НЕ рендерится без tenantId.
|
||||
|
||||
- **PHPStan baseline регенерирован**.
|
||||
|
||||
- **Регресс зелёный:**
|
||||
- `npm run lint:vue` + `type-check` + `format` — passed.
|
||||
- `npm run test:vue` — **283/283 за 18.13 сек** (+3 от 280).
|
||||
- `npm run build` — vite OK 1.12 сек.
|
||||
- `composer pint` + `composer stan` — passed.
|
||||
- `composer test` — **Pest 220/220 за 25.64 сек** (+10 от 210, 871 assertion).
|
||||
|
||||
- **Что НЕ сделано (production TODO остаточные после v1.65):**
|
||||
- **Этапы 2-4** текущего авто-плана: GET /api/admin/{tenants,billing,incidents} + интеграция в Admin*View.
|
||||
- **Этап 5** авто-плана: soft-delete migration + DELETE /api/deals (требует ALTER TABLE deals + bump schema).
|
||||
- Polling/SSE для real-time (на MVP — manual reload-btn).
|
||||
- **#6 Yandex 360 SSO** ⏸ ждёт Б-1.
|
||||
- **#7 Pest browser-mode** — отложен (инфра).
|
||||
|
||||
- **Сводка §0:** без изменений (70 ✅ / 5 🟦 / 4 ⏸ / 1 P0 + 3 P1 + 0 P2).
|
||||
|
||||
**Что изменилось в v1.64 относительно v1.63:**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user