chore: prune Liderra-specs-plans over-copied into claude-brain
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,768 +0,0 @@
|
||||
# Sprint 4 «Audit tail» Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Закрыть 3 оставшихся O-* пункта audit'а (после Sprint 1–3): keyset pagination в `DealController::index` (O-perf-04), split 8 Vue-компонентов >300 строк (O-refactor-04 хвост), dead-code detection через bundle analyzer + удаление unused exports (O-refactor-06).
|
||||
|
||||
**Architecture:** 3 фазы / 3 PR-коммита (Phase A backend keyset, Phase B frontend split, Phase C frontend cleanup) + 1 финальный регрессионный коммит. Каждая фаза независима — порядок гибкий, но рекомендация A → B → C ради монотонной валидации.
|
||||
|
||||
**Tech Stack:** Laravel 13 + Pest 4 + Vue 3.5 + Vuetify 3.12 + Vite 8 + rollup-plugin-visualizer
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-10-roadmap-to-production-design.md](../specs/2026-05-10-roadmap-to-production-design.md) §3.1 (Sprint 4 содержание)
|
||||
|
||||
**Базовый HEAD:** `77b018d` (после commit'а roadmap design'а)
|
||||
|
||||
**Pre-requisites:**
|
||||
|
||||
- Проект собран (`cd app && composer install && npm install`).
|
||||
- PG/Redis запущены (Memurai/PostgreSQL service running на dev-стенде).
|
||||
- `php artisan migrate:fresh --seed` если нужна свежая БД.
|
||||
|
||||
---
|
||||
|
||||
## Phase A — O-perf-04: keyset pagination в DealController::index
|
||||
|
||||
**Контекст:** Текущий `DealController::index` использует OFFSET/LIMIT. На больших offset'ах (deep pagination) это O(N) — PG считает все записи до offset'а, затем выкидывает их. Keyset pagination использует cursor `(received_at, id)` — клиент передаёт «последние видимые значения», PG идёт по индексу с WHERE `(received_at, id) < (cursor_received_at, cursor_id)`. O(1) при любой глубине.
|
||||
|
||||
**Решение API:** добавить опциональный query-параметр `cursor` (base64-encoded JSON `{r: timestamp, i: id}`). При передаче cursor — игнорируем offset, используем keyset. Без cursor — текущее поведение OFFSET (для совместимости с frontend).
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/DealController.php:59-143` (метод `index`)
|
||||
- Test: `app/tests/Feature/DealIndexTest.php` (добавить 3 теста)
|
||||
- Modify: `app/resources/js/composables/useDealsList.ts` (если есть; иначе — пометить как Post-Sprint-4 follow-up для frontend integration)
|
||||
|
||||
### Task A.1 — Pest tests для keyset (TDD)
|
||||
|
||||
- [ ] **Step A.1.1: Открыть `app/tests/Feature/DealIndexTest.php` и добавить 3 failing-теста в конец файла (перед закрывающей `});` если она есть, или просто в конец).**
|
||||
|
||||
```php
|
||||
test('GET /api/deals с cursor возвращает следующую страницу через keyset', function () {
|
||||
// Создаём 5 сделок с разными received_at (через 1 минуту).
|
||||
$base = now()->subHours(5);
|
||||
$ids = [];
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$ids[] = Deal::factory()
|
||||
->for($this->tenant)
|
||||
->for($this->project)
|
||||
->create([
|
||||
'status' => 'new',
|
||||
'received_at' => $base->copy()->addMinutes($i),
|
||||
])->id;
|
||||
}
|
||||
|
||||
// Первая страница без cursor: limit=2 → последние 2 (по received_at DESC).
|
||||
$r1 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2');
|
||||
$r1->assertStatus(200);
|
||||
expect($r1->json('deals'))->toHaveLength(2);
|
||||
expect($r1->json('deals.0.id'))->toBe($ids[4]); // последняя по времени
|
||||
expect($r1->json('deals.1.id'))->toBe($ids[3]);
|
||||
|
||||
// Cursor для следующей страницы — последний id и его received_at.
|
||||
$cursor = base64_encode(json_encode([
|
||||
'r' => $r1->json('deals.1.received_at'),
|
||||
'i' => $r1->json('deals.1.id'),
|
||||
]));
|
||||
|
||||
$r2 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2&cursor='.$cursor);
|
||||
$r2->assertStatus(200);
|
||||
expect($r2->json('deals'))->toHaveLength(2);
|
||||
expect($r2->json('deals.0.id'))->toBe($ids[2]);
|
||||
expect($r2->json('deals.1.id'))->toBe($ids[1]);
|
||||
});
|
||||
|
||||
test('GET /api/deals c невалидным cursor возвращает 422', function () {
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&cursor=not-base64-json');
|
||||
$r->assertStatus(422);
|
||||
expect($r->json('message'))->toBeString();
|
||||
});
|
||||
|
||||
test('GET /api/deals c cursor возвращает next_cursor когда есть ещё страницы', function () {
|
||||
$base = now()->subHours(3);
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||||
'status' => 'new',
|
||||
'received_at' => $base->copy()->addMinutes($i),
|
||||
]);
|
||||
}
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2');
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('next_cursor'))->toBeString(); // base64-encoded
|
||||
expect($r->json('next_cursor'))->not->toBeEmpty();
|
||||
|
||||
// Последняя страница: next_cursor = null.
|
||||
$cursor = $r->json('next_cursor');
|
||||
$r2 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2&cursor='.$cursor);
|
||||
$r2->assertStatus(200);
|
||||
expect($r2->json('next_cursor'))->toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step A.1.2: Запустить тесты — они должны упасть.**
|
||||
|
||||
```bash
|
||||
cd app && php vendor/bin/pest --filter="DealIndex" --stop-on-failure
|
||||
```
|
||||
|
||||
Expected: 3 новых теста FAIL (либо assertion mismatch на `next_cursor`, либо 422 не вернётся для невалидного cursor — текущий код игнорирует параметр).
|
||||
|
||||
### Task A.2 — Реализация keyset
|
||||
|
||||
- [ ] **Step A.2.1: Открыть `app/app/Http/Controllers/Api/DealController.php` и заменить метод `index` (строки 59–143).**
|
||||
|
||||
Логика:
|
||||
|
||||
- Если `cursor` пустой/отсутствует → старое поведение OFFSET (возвращаем `limit/offset/total`).
|
||||
- Если `cursor` присутствует → декодировать `base64_decode → json_decode` → проверить ключи `r` (timestamp) + `i` (int id). При ошибке — 422.
|
||||
- При валидном cursor → keyset query: `WHERE (received_at, id) < (cursor.r, cursor.i)` через `whereRaw('(received_at, id) < (?, ?)', [...])` (PG row constructor comparison).
|
||||
- Возвращать `next_cursor` (base64-encoded `{r, i}` от последней записи в выдаче), `null` если страница неполная (выдано < limit).
|
||||
- `total` возвращать только при OFFSET-режиме (при keyset — невозможно без COUNT(*) который дорого).
|
||||
|
||||
```php
|
||||
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);
|
||||
}
|
||||
|
||||
$statuses = (array) $request->query('status_in', []);
|
||||
$projectId = $request->query('project_id') !== null ? (int) $request->query('project_id') : null;
|
||||
$managerId = $request->query('manager_id') !== null ? (int) $request->query('manager_id') : null;
|
||||
$search = trim((string) $request->query('search', ''));
|
||||
$limit = max(1, min(500, (int) $request->query('limit', '100')));
|
||||
$offset = max(0, (int) $request->query('offset', '0'));
|
||||
$onlyDeleted = $request->boolean('only_deleted');
|
||||
$cursorRaw = (string) $request->query('cursor', '');
|
||||
|
||||
$cursor = null;
|
||||
if ($cursorRaw !== '') {
|
||||
$decoded = base64_decode($cursorRaw, true);
|
||||
if ($decoded === false) {
|
||||
return response()->json(['message' => 'Невалидный cursor (не base64).'], 422);
|
||||
}
|
||||
$parsed = json_decode($decoded, true);
|
||||
if (! is_array($parsed) || ! isset($parsed['r'], $parsed['i'])) {
|
||||
return response()->json(['message' => 'Невалидный cursor (нет полей r/i).'], 422);
|
||||
}
|
||||
$cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
|
||||
}
|
||||
|
||||
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$query = Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['project:id,name', 'manager:id,email,first_name,last_name']);
|
||||
|
||||
if ($onlyDeleted) {
|
||||
$query->withTrashed()->whereNotNull('deleted_at');
|
||||
}
|
||||
|
||||
if ($statuses !== []) {
|
||||
$query->whereIn('status', array_filter($statuses, 'is_string'));
|
||||
}
|
||||
if ($projectId !== null) {
|
||||
$query->where('project_id', $projectId);
|
||||
}
|
||||
if ($managerId !== null) {
|
||||
$query->where('manager_id', $managerId);
|
||||
}
|
||||
if ($search !== '') {
|
||||
$like = '%'.$search.'%';
|
||||
$query->where(function ($q) use ($like) {
|
||||
$q->where('phone', 'ilike', $like)
|
||||
->orWhere('contact_name', 'ilike', $like);
|
||||
});
|
||||
}
|
||||
|
||||
if ($cursor !== null) {
|
||||
// Keyset pagination — PG row constructor comparison через индекс
|
||||
// (received_at DESC, id DESC). Не считаем total (дорого без COUNT).
|
||||
$query->whereRaw('(received_at, id) < (?, ?)', [$cursor['r'], $cursor['i']]);
|
||||
$rows = $query->orderByDesc('received_at')->orderByDesc('id')
|
||||
->limit($limit + 1)->get(); // +1 чтобы понять, есть ли следующая страница
|
||||
$hasNext = $rows->count() > $limit;
|
||||
if ($hasNext) {
|
||||
$rows = $rows->slice(0, $limit)->values();
|
||||
}
|
||||
$next = null;
|
||||
if ($hasNext && $rows->isNotEmpty()) {
|
||||
$last = $rows->last();
|
||||
$next = base64_encode(json_encode([
|
||||
'r' => $last->received_at?->toIso8601String(),
|
||||
'i' => $last->id,
|
||||
]));
|
||||
}
|
||||
|
||||
return [$rows, null, $next];
|
||||
}
|
||||
|
||||
// Старый OFFSET-путь (backward-compat для frontend).
|
||||
$total = (clone $query)->count();
|
||||
$rows = $query->orderByDesc('received_at')->orderByDesc('id')
|
||||
->limit($limit + 1)->offset($offset)->get();
|
||||
$hasNext = $rows->count() > $limit;
|
||||
if ($hasNext) {
|
||||
$rows = $rows->slice(0, $limit)->values();
|
||||
}
|
||||
$next = null;
|
||||
if ($hasNext && $rows->isNotEmpty()) {
|
||||
$last = $rows->last();
|
||||
$next = base64_encode(json_encode([
|
||||
'r' => $last->received_at?->toIso8601String(),
|
||||
'i' => $last->id,
|
||||
]));
|
||||
}
|
||||
|
||||
return [$rows, $total, $next];
|
||||
});
|
||||
|
||||
$payload = [
|
||||
'deals' => $deals->map(fn (Deal $d) => [
|
||||
'id' => $d->id,
|
||||
'tenant_id' => $d->tenant_id,
|
||||
'project_id' => $d->project_id,
|
||||
'project_name' => $d->project?->name,
|
||||
'phone' => $d->phone,
|
||||
'contact_name' => $d->contact_name,
|
||||
'status' => $d->status,
|
||||
'manager_id' => $d->manager_id,
|
||||
'manager_name' => $d->manager
|
||||
? ManagerController::formatName($d->manager->first_name, $d->manager->last_name, $d->manager->email)
|
||||
: null,
|
||||
'manager_initials' => $d->manager
|
||||
? ManagerController::formatInitials($d->manager->first_name, $d->manager->last_name, $d->manager->email)
|
||||
: null,
|
||||
'received_at' => $d->received_at?->toIso8601String(),
|
||||
]),
|
||||
'limit' => $limit,
|
||||
'next_cursor' => $nextCursor,
|
||||
];
|
||||
|
||||
if ($cursor === null) {
|
||||
$payload['total'] = $total;
|
||||
$payload['offset'] = $offset;
|
||||
}
|
||||
|
||||
return response()->json($payload);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step A.2.2: Запустить тесты — должны пройти.**
|
||||
|
||||
```bash
|
||||
cd app && php vendor/bin/pest --filter="DealIndex"
|
||||
```
|
||||
|
||||
Expected: ВСЕ 3 новых теста PASS, старые тесты `DealIndexTest` тоже PASS (backward-compat сохранён).
|
||||
|
||||
- [ ] **Step A.2.3: Запустить полный Pest для регрессии.**
|
||||
|
||||
```bash
|
||||
cd app && composer test
|
||||
```
|
||||
|
||||
Expected: 419+ tests PASS (418 baseline + 3 новых; точное число зависит от других возможных skipped).
|
||||
|
||||
- [ ] **Step A.2.4: Larastan + Pint.**
|
||||
|
||||
```bash
|
||||
cd app && composer pint && composer stan
|
||||
```
|
||||
|
||||
Expected: Pint без diff, Larastan 0 errors.
|
||||
|
||||
- [ ] **Step A.2.5: Commit.**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Controllers/Api/DealController.php app/tests/Feature/DealIndexTest.php
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(backend): Sprint 4 Phase A — keyset pagination в DealController::index (audit O-perf-04)
|
||||
|
||||
Добавлен опциональный query-параметр `cursor` (base64-encoded {r:timestamp,i:id}).
|
||||
При cursor — keyset через PG row constructor `(received_at, id) < (?, ?)`,
|
||||
O(1) при любой глубине. Без cursor — старое OFFSET-поведение (backward-compat).
|
||||
|
||||
Возвращает `next_cursor` в обоих режимах (NULL = последняя страница).
|
||||
`total` возвращается только при OFFSET (при keyset COUNT(*) дорог).
|
||||
|
||||
3 новых Pest-теста: keyset pagination, 422 на невалидный cursor,
|
||||
next_cursor flow.
|
||||
|
||||
Frontend integration в `useDealsList`/`DealsView` — отдельным шагом
|
||||
(не блокирует backend deploy, OFFSET путь жив).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase B — O-refactor-04 хвост: split 8 Vue-компонентов >300 строк
|
||||
|
||||
**Контекст:** Top-3 Vue-компонента (DealsView 852, ReportsView 592, DealDetailDrawer 580) split закрыт в Sprint 3 Phase C. Осталось 8 компонентов >300 строк:
|
||||
|
||||
| Компонент | Текущий size | Целевой | Стратегия split'а |
|
||||
|---|---|---|---|
|
||||
| `layouts/AppLayout.vue` | 466 | <200 | Извлечь `<AppSidebar>` (nav-tree) + `<AppTopbar>` (search/notifications/user-chip) |
|
||||
| `views/admin/AdminTenantDetailView.vue` | 436 | <200 | Извлечь `<TenantHeader>` (base-info), `<TenantUsersTable>`, `<TenantBalanceHistory>`, `<TenantActivityList>` |
|
||||
| `views/BillingView.vue` | 416 | <200 | Извлечь `<BalanceCard>`, `<TopupDialog>`, `<TransactionsTable>`, `<InvoicesTable>` |
|
||||
| `views/admin/AdminTenantsView.vue` | 377 | <200 | Извлечь `<TenantsFilters>`, `<TenantsTable>` (typed VDataTable slots), `<TenantStatusChip>` |
|
||||
| `views/settings/SecurityTab.vue` | 354 | <200 | Извлечь `<ChangePasswordCard>`, `<TwoFactorCard>`, `<RecoveryCodesCard>`, `<SessionsTable>` |
|
||||
| `views/RemindersView.vue` | 345 | <200 | Извлечь `<RemindersFilters>`, `<RemindersList>`, `<ReminderForm>` |
|
||||
| `views/errors/ErrorView.vue` | 320 | <200 | Извлечь `<ErrorIllustration>` (404/403/500 SVG), `<ErrorActions>` (Login/Home/Back buttons) |
|
||||
| `views/DashboardView.vue` | 302 | <200 | Извлечь `<DashboardKpiRow>`, `<DashboardBalance>`, `<DashboardRecentDeals>` |
|
||||
|
||||
**Принцип split'а:**
|
||||
|
||||
- Sub-компоненты живут рядом (например, `AppSidebar.vue` и `AppTopbar.vue` в `layouts/parts/` или в `components/layout/`).
|
||||
- Props down (родитель передаёт data), events up (sub-компонент эмитит actions).
|
||||
- TypeScript: явные интерфейсы props.
|
||||
- Vuetify: использовать typed slots для VDataTable где уместно (Sprint 2 Phase B уже начал — продолжать).
|
||||
- Тесты: если sub-компонент имеет state/logic → отдельный Vitest test file. Чисто-визуальные slots — без tests.
|
||||
- Story (Histoire): для каждого нового sub-компонента — `.story.vue` если разумно (variants для разных состояний).
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: 8 view-файлов выше
|
||||
- Create: ~25 новых sub-компонентов (3-4 на каждый split)
|
||||
- Create: ~10-15 новых Vitest-тестов на sub-компоненты с логикой
|
||||
- Modify: при необходимости — `composables/` для общих state-helper'ов
|
||||
|
||||
### Task B.1 — Группа 1: 3 admin views (AppLayout + AdminTenantDetailView + AdminTenantsView)
|
||||
|
||||
- [ ] **Step B.1.1: AppLayout.vue split.**
|
||||
|
||||
Создать `app/resources/js/components/layout/AppSidebar.vue` (nav-tree из текущего AppLayout строки ~40–250) с props `:nav-groups`, `:active-route`, events `@navigate`. ImportTL в AppLayout, заменить inline.
|
||||
|
||||
Создать `app/resources/js/components/layout/AppTopbar.vue` (topbar из текущего AppLayout строки ~250–420) с props `:user`, `:notifications-count`, events `@logout`, `@open-search`, `@toggle-notifications`. Импортировать в AppLayout.
|
||||
|
||||
После split: `wc -l app/resources/js/layouts/AppLayout.vue` должен показать <200.
|
||||
|
||||
- [ ] **Step B.1.2: AdminTenantDetailView.vue split.**
|
||||
|
||||
Создать в `app/resources/js/components/admin/tenant-detail/`:
|
||||
|
||||
- `TenantHeader.vue` (base info card: name, subdomain, status, tariff, mrr_rub, runway_days)
|
||||
- `TenantUsersTable.vue` (VDataTable + 4 columns)
|
||||
- `TenantBalanceHistory.vue` (VDataTable + transaction badges)
|
||||
- `TenantActivityList.vue` (VList + actor + summary)
|
||||
|
||||
Каждый принимает `:tenant-detail` (или подмножество) через props, без emits (read-only view).
|
||||
|
||||
После split: `wc -l app/resources/js/views/admin/AdminTenantDetailView.vue` <200.
|
||||
|
||||
- [ ] **Step B.1.3: AdminTenantsView.vue split.**
|
||||
|
||||
Создать в `app/resources/js/components/admin/tenants/`:
|
||||
|
||||
- `TenantsFilters.vue` (search + status select + tariff select), props `:filters`, emit `@update:filters`
|
||||
- `TenantsTable.vue` (VDataTable с typed slots: tenant column, status chip, MRR, last_active), props `:tenants`, emit `@row-click`
|
||||
- `TenantStatusChip.vue` (chip с цветом по статусу: active/trial/suspended/churned)
|
||||
|
||||
После split: `wc -l app/resources/js/views/admin/AdminTenantsView.vue` <200.
|
||||
|
||||
- [ ] **Step B.1.4: Vitest для новых компонентов.**
|
||||
|
||||
Минимум по 1 test-file на компонент с логикой (TenantsFilters, TenantStatusChip — props/emits; TenantsTable — slot rendering; TenantHeader — computed display).
|
||||
|
||||
Шаблон: `tests/Vue/components/admin/tenants/TenantsFilters.test.ts` с `mount(TenantsFilters, { props: {...}})`, `wrapper.find('.v-text-field input').setValue(...)`, `expect(wrapper.emitted('update:filters')).toBeTruthy()`.
|
||||
|
||||
- [ ] **Step B.1.5: Регрессия.**
|
||||
|
||||
```bash
|
||||
cd app && npm run lint:vue && npm run type-check && npm run test:vue && npm run build
|
||||
```
|
||||
|
||||
Expected: ESLint 0, vue-tsc 0, Vitest все PASS (включая новые), build successful.
|
||||
|
||||
- [ ] **Step B.1.6: Commit.**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/layouts/AppLayout.vue \
|
||||
app/resources/js/components/layout/ \
|
||||
app/resources/js/views/admin/AdminTenantDetailView.vue \
|
||||
app/resources/js/components/admin/tenant-detail/ \
|
||||
app/resources/js/views/admin/AdminTenantsView.vue \
|
||||
app/resources/js/components/admin/tenants/ \
|
||||
app/tests/Vue/components/
|
||||
git commit -m "refactor(frontend): Sprint 4 Phase B/1 — split 3 admin/layout views (audit O-refactor-04 хвост)
|
||||
|
||||
AppLayout 466→<200 (+ AppSidebar + AppTopbar)
|
||||
AdminTenantDetailView 436→<200 (+ TenantHeader/UsersTable/BalanceHistory/ActivityList)
|
||||
AdminTenantsView 377→<200 (+ TenantsFilters/Table/StatusChip)
|
||||
|
||||
+N Vitest на новые sub-компоненты с логикой.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
### Task B.2 — Группа 2: 3 user views (BillingView + SecurityTab + RemindersView)
|
||||
|
||||
- [ ] **Step B.2.1: BillingView.vue split.**
|
||||
|
||||
Создать в `app/resources/js/components/billing/`:
|
||||
|
||||
- `BalanceCard.vue` (текущий баланс + auto-topup status), props `:balance`, `:auto-topup-config`
|
||||
- `TopupDialog.vue` (form: amount, payment-method), v-model:show, emit `@submit`
|
||||
- `TransactionsTable.vue` (VDataTable + filters), props `:transactions`, `:filter-state`
|
||||
- `InvoicesTable.vue` (VDataTable + download button), props `:invoices`
|
||||
|
||||
После split: <200 строк.
|
||||
|
||||
- [ ] **Step B.2.2: SecurityTab.vue split.**
|
||||
|
||||
Создать в `app/resources/js/components/settings/security/`:
|
||||
|
||||
- `ChangePasswordCard.vue` (form 3 fields: current/new/confirm)
|
||||
- `TwoFactorCard.vue` (status + enable/disable + qr-dialog)
|
||||
- `RecoveryCodesCard.vue` (list + regenerate)
|
||||
- `SessionsTable.vue` (VDataTable + revoke action)
|
||||
|
||||
После split: <200 строк.
|
||||
|
||||
- [ ] **Step B.2.3: RemindersView.vue split.**
|
||||
|
||||
Создать в `app/resources/js/components/reminders/`:
|
||||
|
||||
- `RemindersFilters.vue` (status: active/done/overdue + manager-select)
|
||||
- `RemindersList.vue` (VList с группировкой по дате)
|
||||
- `ReminderForm.vue` (dialog: text + due_at + assignee)
|
||||
|
||||
После split: <200 строк.
|
||||
|
||||
- [ ] **Step B.2.4: Vitest для группы 2.**
|
||||
|
||||
Шаблон как в Task B.1.4. Минимум `BalanceCard` (computed display), `ChangePasswordCard` (validation), `RemindersFilters` (filter emit), `ReminderForm` (form submit).
|
||||
|
||||
- [ ] **Step B.2.5: Регрессия.**
|
||||
|
||||
```bash
|
||||
cd app && npm run lint:vue && npm run type-check && npm run test:vue && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step B.2.6: Commit.**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/BillingView.vue \
|
||||
app/resources/js/components/billing/ \
|
||||
app/resources/js/views/settings/SecurityTab.vue \
|
||||
app/resources/js/components/settings/security/ \
|
||||
app/resources/js/views/RemindersView.vue \
|
||||
app/resources/js/components/reminders/ \
|
||||
app/tests/Vue/components/
|
||||
git commit -m "refactor(frontend): Sprint 4 Phase B/2 — split 3 user views (audit O-refactor-04 хвост)
|
||||
|
||||
BillingView 416→<200 (+ BalanceCard/TopupDialog/Transactions/InvoicesTable)
|
||||
SecurityTab 354→<200 (+ ChangePassword/TwoFactor/RecoveryCodes/Sessions)
|
||||
RemindersView 345→<200 (+ RemindersFilters/List/Form)
|
||||
|
||||
+N Vitest.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
### Task B.3 — Группа 3: 2 utility views (ErrorView + DashboardView)
|
||||
|
||||
- [ ] **Step B.3.1: ErrorView.vue split.**
|
||||
|
||||
Создать в `app/resources/js/components/errors/`:
|
||||
|
||||
- `ErrorIllustration.vue` (SVG 404/403/500, props `:code`)
|
||||
- `ErrorActions.vue` (Login/Home/Back buttons, props `:code`, emit `@navigate`)
|
||||
|
||||
После split: <200 строк.
|
||||
|
||||
- [ ] **Step B.3.2: DashboardView.vue split.**
|
||||
|
||||
Создать в `app/resources/js/components/dashboard/`:
|
||||
|
||||
- `DashboardKpiRow.vue` (3-4 KPI-карты: leads_today/week/month, props `:metrics`)
|
||||
- `DashboardBalance.vue` (баланс + low-balance warning)
|
||||
- `DashboardRecentDeals.vue` (список последних 10 сделок, props `:deals`)
|
||||
|
||||
После split: <200 строк.
|
||||
|
||||
- [ ] **Step B.3.3: Vitest для группы 3.**
|
||||
|
||||
`ErrorIllustration` (SVG render по props.code), `DashboardKpiRow` (компьютед labels + numerics через JetBrains Mono `tnum`).
|
||||
|
||||
- [ ] **Step B.3.4: Регрессия.**
|
||||
|
||||
```bash
|
||||
cd app && npm run lint:vue && npm run type-check && npm run test:vue && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step B.3.5: Commit.**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/errors/ErrorView.vue \
|
||||
app/resources/js/components/errors/ \
|
||||
app/resources/js/views/DashboardView.vue \
|
||||
app/resources/js/components/dashboard/ \
|
||||
app/tests/Vue/components/
|
||||
git commit -m "refactor(frontend): Sprint 4 Phase B/3 — split 2 utility views (audit O-refactor-04 хвост)
|
||||
|
||||
ErrorView 320→<200 (+ ErrorIllustration + ErrorActions)
|
||||
DashboardView 302→<200 (+ KpiRow + Balance + RecentDeals)
|
||||
|
||||
+N Vitest. O-refactor-04 закрыт полностью (12 → 0 компонентов >300 строк
|
||||
после Sprint 3 Phase C + Sprint 4 Phase B/1-3).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
### Task B.4 — Acceptance Phase B
|
||||
|
||||
- [ ] **Step B.4.1: Проверить, что 0 Vue-компонентов >300 строк.**
|
||||
|
||||
```bash
|
||||
find app/resources/js -name "*.vue" -exec wc -l {} + | awk '$1 > 300 && $2 != "total" {print}'
|
||||
```
|
||||
|
||||
Expected: пустой output (либо 1-2 с явным обоснованием в комментарии — например, гигантский `app.vue` шаблон если есть).
|
||||
|
||||
- [ ] **Step B.4.2: Histoire build smoke.**
|
||||
|
||||
```bash
|
||||
cd app && npm run story:build
|
||||
```
|
||||
|
||||
Expected: build successful, новые stories видны (опционально — по 1 story на каждый sub-компонент с логикой).
|
||||
|
||||
---
|
||||
|
||||
## Phase C — O-refactor-06: dead-code detection + удаление unused exports
|
||||
|
||||
**Контекст:** Bundle size analyzer покажет, какие модули включены в финальный build, и какие exports никогда не импортируются. Цель — найти и удалить мёртвый код в `app/resources/js/utils/` и `app/resources/js/helpers/` (если есть). Установка `rollup-plugin-visualizer` через Vite-плагин — стандартный способ.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/scripts/analyze-bundle.sh` (опционально, или просто в README)
|
||||
- Modify: `app/vite.config.js` (добавить visualizer plugin условно)
|
||||
- Modify: `app/package.json` (новый script `npm run build:analyze`)
|
||||
- Modify: файлы в `app/resources/js/utils/` и `helpers/` (удаление unused exports)
|
||||
|
||||
### Task C.1 — Установить bundle analyzer
|
||||
|
||||
- [ ] **Step C.1.1: Установить rollup-plugin-visualizer как devDependency.**
|
||||
|
||||
```bash
|
||||
cd app && npm install --save-dev rollup-plugin-visualizer
|
||||
```
|
||||
|
||||
Expected: пакет в `devDependencies` секции `package.json`, никаких peer-dep warnings (если есть — `--legacy-peer-deps`).
|
||||
|
||||
- [ ] **Step C.1.2: Modify `app/vite.config.js` — добавить visualizer условно.**
|
||||
|
||||
```javascript
|
||||
import { defineConfig } from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import vuetify from 'vite-plugin-vuetify';
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
laravel({
|
||||
input: ['resources/css/app.css', 'resources/js/app.ts'],
|
||||
refresh: true,
|
||||
}),
|
||||
vue({
|
||||
template: {
|
||||
transformAssetUrls: {
|
||||
base: null,
|
||||
includeAbsolute: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
vuetify({ autoImport: true }),
|
||||
// Bundle analyzer — активен только при `BUILD_ANALYZE=1` или `vite build --mode analyze`.
|
||||
// Генерирует storage/bundle-analyze.html (открыть в браузере).
|
||||
process.env.BUILD_ANALYZE === '1' && visualizer({
|
||||
filename: 'storage/bundle-analyze.html',
|
||||
open: false,
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
].filter(Boolean),
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ['**/storage/framework/views/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step C.1.3: Modify `app/package.json` — добавить script.**
|
||||
|
||||
В секцию `scripts`:
|
||||
|
||||
```json
|
||||
"build:analyze": "BUILD_ANALYZE=1 vite build"
|
||||
```
|
||||
|
||||
(на Windows — кросс-платформенно через `cross-env` если будет нужно; пока на dev-стенде используем PowerShell `$env:BUILD_ANALYZE='1'; npm run build`).
|
||||
|
||||
- [ ] **Step C.1.4: Запустить analyzer.**
|
||||
|
||||
```bash
|
||||
cd app && BUILD_ANALYZE=1 npm run build
|
||||
# Windows PowerShell альтернатива:
|
||||
# cd app; $env:BUILD_ANALYZE='1'; npm run build
|
||||
```
|
||||
|
||||
Expected: `storage/bundle-analyze.html` создан, vite build успешен.
|
||||
|
||||
### Task C.2 — Найти и удалить unused exports
|
||||
|
||||
- [ ] **Step C.2.1: Открыть `storage/bundle-analyze.html` в браузере.**
|
||||
|
||||
Просмотреть treemap. Найти:
|
||||
|
||||
- Модули с подозрительно маленьким footprint (1-2 импорта на весь bundle — кандидаты на inline)
|
||||
- Модули из `utils/` и `helpers/` которые не входят в build вообще (они unused — удалить)
|
||||
|
||||
Альтернатива через CLI: использовать `knip` или `ts-prune` для систематического поиска. Установка опциональна, если visualizer достаточно.
|
||||
|
||||
- [ ] **Step C.2.2: Установить `knip` для автоматического поиска unused exports.**
|
||||
|
||||
```bash
|
||||
cd app && npm install --save-dev knip
|
||||
```
|
||||
|
||||
- [ ] **Step C.2.3: Запустить knip с базовым конфигом.**
|
||||
|
||||
Создать `app/knip.config.ts`:
|
||||
|
||||
```typescript
|
||||
import type { KnipConfig } from 'knip';
|
||||
|
||||
const config: KnipConfig = {
|
||||
entry: ['resources/js/app.ts', 'resources/js/router/index.ts'],
|
||||
project: ['resources/js/**/*.{ts,vue}'],
|
||||
ignore: ['**/*.story.vue', 'tests/**'],
|
||||
ignoreDependencies: ['@vue/test-utils', 'jsdom', 'vitest'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
```bash
|
||||
cd app && npx knip
|
||||
```
|
||||
|
||||
Expected: список unused files / unused exports / unused dependencies.
|
||||
|
||||
- [ ] **Step C.2.4: Удалить найденные dead exports.**
|
||||
|
||||
Идти по списку knip:
|
||||
|
||||
- **Unused files** → если правда не нужен (проверить через `Grep` импорты по имени) → `git rm`.
|
||||
- **Unused exports** → удалить export, оставить функцию приватной если используется внутри файла; иначе `git rm` функцию целиком.
|
||||
- **Unused dependencies** → `npm uninstall <pkg>`.
|
||||
|
||||
Аккуратно: `knip` может ложно-положительно ругаться на dynamic imports, Vuetify auto-import, pinia stores. Перед удалением — `Grep` проверка.
|
||||
|
||||
- [ ] **Step C.2.5: Регрессия.**
|
||||
|
||||
```bash
|
||||
cd app && npm run lint:vue && npm run type-check && npm run test:vue && npm run build
|
||||
```
|
||||
|
||||
Expected: всё зелёное. Если что-то сломалось — `git checkout` файл, перепроверить.
|
||||
|
||||
- [ ] **Step C.2.6: Повторный knip — должен показать 0 unused (или явно объяснимые false-positive).**
|
||||
|
||||
```bash
|
||||
cd app && npx knip
|
||||
```
|
||||
|
||||
- [ ] **Step C.2.7: Commit.**
|
||||
|
||||
```bash
|
||||
git add app/vite.config.js app/package.json app/package-lock.json app/knip.config.ts \
|
||||
app/resources/js/utils/ app/resources/js/helpers/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore(frontend): Sprint 4 Phase C — bundle analyzer + dead-code cleanup (audit O-refactor-06)
|
||||
|
||||
- rollup-plugin-visualizer + script `npm run build:analyze` (env BUILD_ANALYZE=1)
|
||||
- knip + конфиг + cleanup unused exports/files в utils/ и helpers/
|
||||
- удалено N exports, M dependencies (точное число — в diff)
|
||||
|
||||
Bundle size снижение: ~X% gzip (точные цифры из storage/bundle-analyze.html).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Финальная регрессия + summary
|
||||
|
||||
### Task R.1 — Полный sweep после Sprint 4
|
||||
|
||||
- [ ] **Step R.1.1: Backend полная регрессия.**
|
||||
|
||||
```bash
|
||||
cd app && composer pint && composer stan && composer test
|
||||
```
|
||||
|
||||
Expected: Pint без diff, Larastan 0, Pest 419+ (baseline 418 + 3 keyset).
|
||||
|
||||
- [ ] **Step R.1.2: Frontend полная регрессия.**
|
||||
|
||||
```bash
|
||||
cd app && npm run lint:vue && npm run type-check && npm run test:vue && npm run build && npm run story:build
|
||||
```
|
||||
|
||||
Expected: ESLint 0, vue-tsc 0, Vitest 430+ (baseline 416 + ~15 за новые sub-components), build OK, Histoire build OK.
|
||||
|
||||
- [ ] **Step R.1.3: Doc-sweep.**
|
||||
|
||||
```bash
|
||||
npm run check:docs
|
||||
```
|
||||
|
||||
Expected: markdownlint 0, cspell 0, lychee 0, pa11y 0 (skip если HTML концепты убраны).
|
||||
|
||||
- [ ] **Step R.1.4: Squawk + format:sql:check.**
|
||||
|
||||
```bash
|
||||
npm run lint:sql && npm run format:sql:check
|
||||
```
|
||||
|
||||
Expected: 0 issues, format-check без diff.
|
||||
|
||||
- [ ] **Step R.1.5: Обновить CLAUDE.md и Открытые_вопросы_v8_3.md через `claude-md-management`.**
|
||||
|
||||
Через skill `/claude-md-management:claude-md-improver` — bump CLAUDE.md шапки (новые метрики Pest/Vitest/Vue-counts), обновить §6 «Текущая фаза» с записью «Sprint 4 «Audit tail» закрыт».
|
||||
|
||||
В `docs/Открытые_вопросы_v8_3.md` — добавить запись в начало с детальной разбивкой Sprint 4.
|
||||
|
||||
В `db/CHANGELOG_schema.md` — без изменений (Sprint 4 не трогает schema).
|
||||
|
||||
- [ ] **Step R.1.6: Commit doc-sync.**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md docs/Открытые_вопросы_v8_3.md docs/CHANGELOG_claude_md.md
|
||||
git commit -m "docs(narrative): sync versions + Sprint 4 acceptance (audit O-perf-04 + O-refactor-04 хвост + O-refactor-06)"
|
||||
```
|
||||
|
||||
### Acceptance Sprint 4
|
||||
|
||||
- ✅ keyset pagination в `DealController::index` работает + Pest +3 теста
|
||||
- ✅ 0 Vue-компонентов >300 строк (12 audit-кандидатов закрыты: Top-3 в Sprint 3 + 8 в Sprint 4 + ImpersonationDialog уже <300)
|
||||
- ✅ Bundle analyzer + knip активны, dead exports удалены
|
||||
- ✅ Регрессия зелёная (Pest, Vitest, Larastan, vue-tsc, ESLint, squawk, markdownlint, cspell, lychee, build, Histoire)
|
||||
- ✅ CLAUDE.md / Открытые_вопросы синхронизированы
|
||||
- ✅ 6 коммитов: Phase A (keyset) + Phase B/1 (3 admin) + Phase B/2 (3 user) + Phase B/3 (2 utility) + Phase C (cleanup) + R (doc-sync). При минимальном doc-sync R можно слить с C — итого 5.
|
||||
@@ -1,563 +0,0 @@
|
||||
# Sprint 5 «Pre-prod tooling» Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Активировать phase-3 tooling, не требующий YC-инфраструктуры: Semgrep SAST (#25) + Semgrep MCP + Dependabot (#27); подготовить Trivy workflow (#26) к будущей активации после Docker pipeline в YC.
|
||||
|
||||
**Architecture:** Три независимых потока — (1) Dependabot: native GitHub config, без кода; (2) Semgrep: `npm run sast` в корне + CI workflow + MCP-запись в `.mcp.json` + allow в `.claude/settings.json`; (3) Trivy prep: workflow-файл создан, но отключён (`if: false`) до Sprint 7.
|
||||
|
||||
**Tech Stack:** Semgrep CLI (pip/CI), GitHub Actions, Dependabot native, Trivy (CI-only), MCP JSON config.
|
||||
|
||||
---
|
||||
|
||||
## Карта файлов
|
||||
|
||||
| Файл | Действие | Назначение |
|
||||
|------|----------|------------|
|
||||
| `.github/dependabot.yml` | Создать | Dependabot: npm×2 (root + app) + composer (app) |
|
||||
| `.github/workflows/sast.yml` | Создать | Semgrep SAST на push/PR в main |
|
||||
| `.github/workflows/trivy.yml` | Создать | Trivy Docker scan — отключён до YC (`if: false`) |
|
||||
| `.semgrep.yml` | Создать | Semgrep: пути + исключения |
|
||||
| `trivy.yaml` | Создать | Trivy config — готов к активации |
|
||||
| `package.json` (корень) | Изменить | Добавить скрипт `"sast"` |
|
||||
| `.mcp.json` | Изменить | Добавить запись Semgrep MCP server |
|
||||
| `.claude/settings.json` | Изменить | Allow `Bash(npm run sast:*)` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Dependabot — `.github/dependabot.yml`
|
||||
|
||||
Dependabot создаёт автоматические PR для обновлений зависимостей прямо через GitHub (без кода на стороне проекта). Существующий `.github/workflows/dependency-check.yml` остаётся — он выполняет другую задачу (уведомление о устаревших версиях), не создаёт PR.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `.github/dependabot.yml`
|
||||
|
||||
- [ ] **Step 1: Создать `.github/dependabot.yml`**
|
||||
|
||||
```yaml
|
||||
# Dependabot — Лидерра (#27)
|
||||
# Docs: https://docs.github.com/code-security/dependabot/dependabot-version-updates
|
||||
# Создаёт PR автоматически при выходе обновлений зависимостей.
|
||||
# CI-workflow dependency-check.yml — параллельный, оставить (разные задачи).
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
# Root package.json: markdownlint-cli2, cspell, pa11y-ci, stylelint, lefthook, npm-run-all2
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "09:00"
|
||||
timezone: Europe/Moscow
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
groups:
|
||||
dev-tools:
|
||||
patterns:
|
||||
- "markdownlint-cli2"
|
||||
- "cspell"
|
||||
- "@cspell/*"
|
||||
- "pa11y*"
|
||||
- "stylelint*"
|
||||
- "lefthook"
|
||||
- "npm-run-all2"
|
||||
|
||||
# app/package.json: Vue, Vuetify, Vite, Vitest, ESLint, Prettier, Histoire, etc.
|
||||
- package-ecosystem: npm
|
||||
directory: /app
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "09:00"
|
||||
timezone: Europe/Moscow
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- dependencies
|
||||
groups:
|
||||
vue-ecosystem:
|
||||
patterns:
|
||||
- "vue"
|
||||
- "vue-*"
|
||||
- "@vue/*"
|
||||
- "vuetify"
|
||||
- "vite"
|
||||
- "vite-*"
|
||||
- "@vitejs/*"
|
||||
- "vite-plugin-*"
|
||||
- "laravel-vite-plugin"
|
||||
vitest:
|
||||
patterns:
|
||||
- "vitest"
|
||||
- "@vitest/*"
|
||||
eslint:
|
||||
patterns:
|
||||
- "eslint"
|
||||
- "eslint-*"
|
||||
- "@eslint/*"
|
||||
- "prettier"
|
||||
- "eslint-config-prettier"
|
||||
histoire:
|
||||
patterns:
|
||||
- "histoire"
|
||||
- "@histoire/*"
|
||||
|
||||
# app/composer.json: Laravel 13, Pest 4, Pint, Larastan, Boost, IDE Helper, etc.
|
||||
- package-ecosystem: composer
|
||||
directory: /app
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "09:00"
|
||||
timezone: Europe/Moscow
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- dependencies
|
||||
groups:
|
||||
laravel-framework:
|
||||
patterns:
|
||||
- "laravel/framework"
|
||||
- "laravel/sanctum"
|
||||
- "laravel/tinker"
|
||||
- "laravel/pail"
|
||||
- "laravel/pao"
|
||||
- "nunomaduro/collision"
|
||||
pest:
|
||||
patterns:
|
||||
- "pestphp/*"
|
||||
dev-tools:
|
||||
patterns:
|
||||
- "larastan/larastan"
|
||||
- "barryvdh/laravel-ide-helper"
|
||||
- "fakerphp/faker"
|
||||
- "laravel/pint"
|
||||
- "mockery/mockery"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/dependabot.yml
|
||||
git commit -m "chore(deps): add Dependabot config — npm×2 + composer weekly (#27)"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Проверить на GitHub после push**
|
||||
|
||||
После `git push origin main`: открыть GitHub → репозиторий → tab **Insights** → **Dependency graph** → **Dependabot**.
|
||||
Expected: 3 экосистемы (`npm /`, `npm /app`, `composer /app`). Первые PR появятся в течение 24 часов.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Semgrep config + `npm run sast`
|
||||
|
||||
Semgrep — статический анализ безопасности (SAST). Запускается в CI (`ubuntu-latest`), поэтому `npm run sast` не требует локальной установки Python на dev-машине.
|
||||
**На Windows dev:** `semgrep` недоступен без Python 3.9+. Локально пропустить; для ручного запуска — `choco install python` + `pip install semgrep`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `.semgrep.yml`
|
||||
- Modify: `package.json` (корень) — добавить скрипт `"sast"`
|
||||
|
||||
- [ ] **Step 1: Создать `.semgrep.yml`**
|
||||
|
||||
```yaml
|
||||
# Semgrep ruleset — Лидерра (#25)
|
||||
# Docs: https://semgrep.dev/docs/writing-rules/rule-syntax
|
||||
# Локально: npm run sast (требует pip install semgrep)
|
||||
# CI: .github/workflows/sast.yml (ubuntu-latest, без установки)
|
||||
|
||||
rules: [] # custom rules — пустые; используем облачные рулсеты через --config p/...
|
||||
|
||||
paths:
|
||||
include:
|
||||
- app/app
|
||||
- app/resources/js
|
||||
- app/database/migrations
|
||||
exclude:
|
||||
- app/vendor
|
||||
- app/node_modules
|
||||
- app/storage
|
||||
- "**/*.min.js"
|
||||
- "**/*.min.css"
|
||||
- "**/_ide_helper*.php"
|
||||
- "**/phpstan-baseline.neon"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Добавить скрипт `"sast"` в корневой `package.json`**
|
||||
|
||||
Открыть `package.json` в корне (не в `app/`). Текущий раздел `scripts`:
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"lint:md": "...",
|
||||
"lint:md:fix": "...",
|
||||
"spell": "...",
|
||||
"links": "...",
|
||||
"lint:css": "...",
|
||||
"lint:sql": "...",
|
||||
"format:sql:check": "...",
|
||||
"format:sql": "...",
|
||||
"a11y": "...",
|
||||
"check:docs": "run-p lint:md spell links a11y"
|
||||
}
|
||||
```
|
||||
|
||||
Добавить `"sast"` после `"check:docs"`:
|
||||
|
||||
```json
|
||||
"check:docs": "run-p lint:md spell links a11y",
|
||||
"sast": "semgrep --config=p/php --config=p/javascript --config=p/typescript --config=p/secrets --config=.semgrep.yml --error --time"
|
||||
```
|
||||
|
||||
Итоговый scripts-раздел должен быть:
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"lint:md": "markdownlint-cli2 \"docs/**/*.md\" \"db/**/*.md\" \"*.md\"",
|
||||
"lint:md:fix": "markdownlint-cli2 --fix \"docs/**/*.md\" \"db/**/*.md\" \"*.md\"",
|
||||
"spell": "cspell --no-progress --show-suggestions \"docs/**/*.md\" \"db/**/*.md\" \"*.md\" \"web/**/*.html\"",
|
||||
"links": "bin\\lychee.exe --config .lychee.toml \"docs/**/*.md\" \"db/**/*.md\" \"*.md\"",
|
||||
"lint:css": "stylelint \"web/**/*.html\"",
|
||||
"lint:sql": "bin\\squawk.exe db/schema.sql",
|
||||
"format:sql:check": "perl -I bin/pgFormatter/lib bin/pgFormatter/pg_format db/schema.sql > db/.schema-formatted.tmp.sql && diff -q db/schema.sql db/.schema-formatted.tmp.sql || echo \"pgFormatter would reformat — run npm run format:sql to apply\"",
|
||||
"format:sql": "perl -I bin/pgFormatter/lib bin/pgFormatter/pg_format -o db/schema.sql.formatted db/schema.sql && echo \"Wrote db/schema.sql.formatted — review diff before replacing source\"",
|
||||
"a11y": "pa11y-ci --config pa11y.config.json",
|
||||
"check:docs": "run-p lint:md spell links a11y",
|
||||
"sast": "semgrep --config=p/php --config=p/javascript --config=p/typescript --config=p/secrets --config=.semgrep.yml --error --time"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .semgrep.yml package.json
|
||||
git commit -m "feat(tooling): Semgrep config + npm run sast script (#25)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Semgrep CI workflow — `.github/workflows/sast.yml`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `.github/workflows/sast.yml`
|
||||
|
||||
- [ ] **Step 1: Создать `.github/workflows/sast.yml`**
|
||||
|
||||
```yaml
|
||||
name: SAST — Semgrep
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'app/app/**'
|
||||
- 'app/resources/js/**'
|
||||
- 'app/database/migrations/**'
|
||||
- '.semgrep.yml'
|
||||
- '.github/workflows/sast.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'app/app/**'
|
||||
- 'app/resources/js/**'
|
||||
- 'app/database/migrations/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # нужно для upload-sarif
|
||||
|
||||
jobs:
|
||||
semgrep:
|
||||
runs-on: ubuntu-latest
|
||||
name: Semgrep SAST scan
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Semgrep
|
||||
uses: semgrep/semgrep-action@v1
|
||||
with:
|
||||
config: >-
|
||||
p/php
|
||||
p/javascript
|
||||
p/typescript
|
||||
p/secrets
|
||||
env:
|
||||
# SEMGREP_APP_TOKEN — опциональный, для Semgrep Cloud dashboard.
|
||||
# Без него: open-source режим, результаты только в GitHub Security tab.
|
||||
# Добавить: GitHub → Settings → Secrets → Actions → SEMGREP_APP_TOKEN
|
||||
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
|
||||
|
||||
- name: Upload SARIF to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
continue-on-error: true # не блокировать PR если SARIF upload упал
|
||||
```
|
||||
|
||||
**Важно — первый запуск:** Semgrep может найти реальные проблемы в существующем коде (не только новом). При первом прогоне нужен тriage:
|
||||
|
||||
1. Смотреть вкладку **Security → Code scanning alerts** на GitHub.
|
||||
2. Ложные срабатывания — помечать «Dismissed» с пояснением.
|
||||
3. Реальные находки P0/P1 — исправлять до merge.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/sast.yml
|
||||
git commit -m "feat(ci): Semgrep SAST workflow — push/PR to main (#25)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Semgrep MCP server — `.mcp.json`
|
||||
|
||||
Semgrep MCP даёт Claude Code инструменты для семантического поиска по Semgrep-правилам прямо в разговоре (аналитика кода, поиск паттернов безопасности).
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `.mcp.json`
|
||||
|
||||
- [ ] **Step 1: Добавить запись `semgrep` в `.mcp.json`**
|
||||
|
||||
Текущий `.mcp.json` содержит ключи `playwright`, `github`, `laravel-boost`. Добавить `semgrep` четвёртым:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/anthropics/claude-code/main/schemas/mcp.json",
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest"],
|
||||
"comment": "Фаза 0 #2 — открыть web/*.html, screenshot, проверка интерактива"
|
||||
},
|
||||
"github": {
|
||||
"type": "http",
|
||||
"url": "https://api.githubcopilot.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${GITHUB_TOKEN}"
|
||||
},
|
||||
"comment": "Фаза 0 #3 — официальный hosted GitHub MCP (https://github.com/github/github-mcp-server). Требует env GITHUB_TOKEN с PAT (scopes: repo, read:org, не давать admin/delete). Раньше использовали deprecated @modelcontextprotocol/server-github — заменён 06.05.2026."
|
||||
},
|
||||
"laravel-boost": {
|
||||
"command": "php",
|
||||
"args": ["app/artisan", "boost:mcp"],
|
||||
"comment": "Фаза 1 #10 — Laravel Boost MCP (laravel/boost v2.4.6). Заменяет PostgreSQL MCP из фазы 0. Через Roster детектит установленные пакеты (Laravel 13, Pest 4, Pint, Larastan, IDE Helper) и серверит соответствующие guidelines + DB query/schema/tinker tools. Кастомный Vuetify 3 guideline — в app/.ai/guidelines/vuetify.md. Конфиг wizard'а — app/boost.json (написан вручную: wizard сломан на кириллице-пути)."
|
||||
},
|
||||
"semgrep": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "semgrep-mcp"],
|
||||
"comment": "Фаза 3 #25 — Semgrep MCP (SAST). Семантический поиск/анализ кода через Semgrep rules в Claude Code. Пакет: npmjs.com/package/semgrep-mcp — если 404, запустить 'npm search semgrep mcp' для актуального имени."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Проверить подключение MCP**
|
||||
|
||||
Перезапустить Claude Code. Выполнить в разговоре:
|
||||
|
||||
```
|
||||
/mcp
|
||||
```
|
||||
|
||||
Expected: в списке серверов появился `semgrep`. Если статус `error` вместо `connected` — пакет `semgrep-mcp` не найден на npm, нужно уточнить имя:
|
||||
|
||||
```bash
|
||||
npm search semgrep mcp --json | Select-Object -First 5
|
||||
```
|
||||
|
||||
Заменить `semgrep-mcp` в `.mcp.json` на найденное имя пакета.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .mcp.json
|
||||
git commit -m "feat(tooling): Semgrep MCP server in .mcp.json (#25)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Allow `npm run sast` в `.claude/settings.json`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `.claude/settings.json`
|
||||
|
||||
- [ ] **Step 1: Добавить разрешение в `.claude/settings.json`**
|
||||
|
||||
Текущий `permissions.allow` содержит `"Bash(npm run check:docs:*)"` и аналогичные строки. Добавить `"Bash(npm run sast:*)"` рядом с остальными npm-скриптами:
|
||||
|
||||
```json
|
||||
"allow": [
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npm run lint:md:*)",
|
||||
"Bash(npm run spell:*)",
|
||||
"Bash(npm run links:*)",
|
||||
"Bash(npm run lint:css:*)",
|
||||
"Bash(npm run a11y:*)",
|
||||
"Bash(npm run check:docs:*)",
|
||||
"Bash(npm run lint:md:fix:*)",
|
||||
"Bash(npm run sast:*)",
|
||||
"Bash(git status)",
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add .claude/settings.json
|
||||
git commit -m "chore(claude): allow npm run sast in settings.json (#25)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Trivy prep — workflow + config (disabled)
|
||||
|
||||
Trivy сканирует Docker-образы на CVE. Docker pipeline будет настроен в Sprint 7 (YC Container Registry). До тех пор — файлы готовы, workflow отключён через `if: false`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `trivy.yaml`
|
||||
- Create: `.github/workflows/trivy.yml`
|
||||
|
||||
- [ ] **Step 1: Создать `trivy.yaml`**
|
||||
|
||||
```yaml
|
||||
# Trivy config — Лидерра (#26)
|
||||
# Активировать в Sprint 7 после настройки Docker pipeline в YC.
|
||||
# Docs: https://aquasecurity.github.io/trivy/latest/docs/configuration/
|
||||
|
||||
exit-code: 1
|
||||
severity: CRITICAL,HIGH
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
ignore-unfixed: true
|
||||
|
||||
vulnerability:
|
||||
type:
|
||||
- os
|
||||
- library
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Создать `.github/workflows/trivy.yml` (disabled)**
|
||||
|
||||
```yaml
|
||||
name: Trivy — Docker image scan
|
||||
|
||||
# ОТКЛЮЧЕНО до Sprint 7 (YC Docker pipeline).
|
||||
# Для активации:
|
||||
# 1. Убрать `if: false` у job trivy
|
||||
# 2. Добавить GitHub secret YC_REGISTRY (полный адрес, напр. cr.yandex/crp.../liderra)
|
||||
# 3. Убедиться, что CI job собирает образ перед этим workflow
|
||||
# См. roadmap Sprint 7 «YC infrastructure setup».
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- 'docker-compose*.yml'
|
||||
- '.github/workflows/trivy.yml'
|
||||
- 'trivy.yaml'
|
||||
schedule:
|
||||
- cron: '0 10 * * 1' # каждый понедельник 10:00 UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
trivy:
|
||||
runs-on: ubuntu-latest
|
||||
if: false # TODO Sprint 7: убрать после настройки Docker pipeline
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Trivy image scan
|
||||
uses: aquasecurity/trivy-action@0.30.0
|
||||
with:
|
||||
image-ref: '${{ secrets.YC_REGISTRY }}:${{ github.sha }}'
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
exit-code: '1'
|
||||
ignore-unfixed: true
|
||||
vuln-type: 'os,library'
|
||||
trivy-config: 'trivy.yaml'
|
||||
|
||||
- name: Upload Trivy SARIF to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add trivy.yaml .github/workflows/trivy.yml
|
||||
git commit -m "feat(tooling): Trivy CI workflow prep — disabled until YC Docker (#26)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Push и тriage первого Semgrep прогона
|
||||
|
||||
- [ ] **Step 1: Push всех коммитов**
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Наблюдать за CI**
|
||||
|
||||
Открыть GitHub → Actions → workflow **SAST — Semgrep**. Ждать завершения (~2–3 мин).
|
||||
|
||||
- [ ] **Step 3: Тriage Security alerts**
|
||||
|
||||
Открыть GitHub → Security → **Code scanning** (появляется после первого upload-sarif).
|
||||
|
||||
Алгоритм тriage:
|
||||
|
||||
- **CRITICAL/HIGH** с реальным уязвимым паттерном → создать Issue, исправить в ближайшем спринте.
|
||||
- **Ложное срабатывание** (напр., флаг в тесте, mock-данные) → Dismiss с пояснением «test/mock data».
|
||||
- **MEDIUM/LOW** → Dismiss как «acceptable risk» или создать тикет Post-MVP.
|
||||
|
||||
- [ ] **Step 4: Проверить Dependabot**
|
||||
|
||||
GitHub → Insights → Dependency graph → Dependabot. Expected: 3 экосистемы зарегистрированы.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec coverage
|
||||
|
||||
| Требование roadmap Sprint 5 | Task | Статус |
|
||||
|-----------------------------|------|--------|
|
||||
| #25 Semgrep local `npm run sast` | Task 2 | ✅ |
|
||||
| #25 Semgrep CI workflow | Task 3 | ✅ |
|
||||
| #25 Semgrep MCP | Task 4 | ✅ |
|
||||
| #26 Trivy prep (disabled until YC) | Task 6 | ✅ |
|
||||
| #27 Dependabot | Task 1 | ✅ |
|
||||
| Allow `npm run sast` в settings | Task 5 | ✅ |
|
||||
| Push + первый тriage | Task 7 | ✅ |
|
||||
|
||||
### Placeholder scan
|
||||
|
||||
- Task 2 Step 2: показан полный итоговый блок `scripts` целиком ✅
|
||||
- Task 4 Step 1: полный `.mcp.json` показан, не «добавить что-то» ✅
|
||||
- Task 4 Step 2: конкретная команда для поиска пакета если npm 404 ✅
|
||||
- Task 6 Step 2: TODO с конкретными инструкциями активации, не просто «активировать потом» ✅
|
||||
- Task 7 Step 3: конкретный алгоритм тriage по severity ✅
|
||||
|
||||
### Type consistency
|
||||
|
||||
Конфигурационные файлы — без типов. Имена файлов, скриптов и ключей согласованы по всем задачам: `.semgrep.yml` / `p/php p/javascript p/typescript p/secrets` — одинаковый набор в Task 2 и Task 3. `trivy.yaml` + `trivy-config: 'trivy.yaml'` в workflow совпадают ✅
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,808 +0,0 @@
|
||||
# Supplier Integration — Plan 2.6 cleanup CV.11 audit остатков
|
||||
|
||||
> **✅ Plan completed (10.05.2026 поздняя ночь финал).** Все 4 fix'а закрыты атомарными commit'ами на main: `e71a02e` (#i deploy validator), `f78a855` (#ii IP allowlist production fail-closed), `451a294` (#iii timestamp partition guard ±24h), `7899071` (#iv crm_supplier_worker BYPASSRLS-роль). Pest 558/556 (+9 от Plan 2.5 baseline 549/547), Larastan + Pint + squawk clean. Memory updated (`project_supplier_integration.md`, `project_state.md`, `feedback_environment.md` quirk #57). Plan 3 backlog: BLOCKER #6 (RLS на `failed_webhook_jobs`) первая задача + 5 minor WARN + 5 NIT.
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Закрыть 4 production-impact остатка CV.11 audit Plan 2: 2 operational deploy gates (placeholder secret + empty IP allowlist в production) + 1 code-bug (Carbon timestamp partition crash) + 1 архитектурный (BYPASSRLS-роль для queue worker — вариант C из Plan 2.6 brainstorm).
|
||||
|
||||
**Architecture:** Все 4 fix'а — атомарные independent изменения, каждый закрыт отдельным commit'ом.
|
||||
|
||||
- **Fix #i (placeholder secret):** новая Console-команда `supplier:check-webhook-secret` для deploy-time gate, exit 1 если seed = `'__SET_ON_DEPLOY__'` или len < 32.
|
||||
- **Fix #ii (empty IP allowlist):** в `SupplierWebhookController::verifyIpAllowlist` — `if ($list === []) return !app()->environment('production')` — production fail-closed, dev/testing fail-open.
|
||||
- **Fix #iii (timestamp partition guard):** в `SupplierWebhookController::receive` — `'time' => 'integer|min:<24h_ago>|max:<24h_future>'`; защита от старого/будущего timestamp → `no partition for row` CRASH.
|
||||
- **Fix #iv (RLS под crm_app_user):** новая PG-роль `crm_supplier_worker` (BYPASSRLS) в `db/00_create_roles.sql` для queue worker; обновить inline-warnings в `LeadRouter.php` + `ResetDeliveredTodayCommand.php`; зафиксировать в memory.
|
||||
|
||||
**Tech Stack:** Laravel 13.7 (Console + Validator + AppEnvironment), PostgreSQL 16 (CREATE ROLE + GRANT), Pest 4, Larastan, Pint.
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](../specs/2026-05-10-supplier-integration-design.md).
|
||||
|
||||
**Plan основан на:** CV.11 code-reviewer subagent audit Plan 2 (10.05.2026 поздняя ночь) — 6 BLOCKER findings из которых #2 + #3 закрыты Plan 2.5 (`1ba1df8` + `c1ae195`); #6 → Plan 3 первая задача; #4/#5 + 8 minor WARN + 5 NIT — частично здесь, частично в Plan 3 NOTES. Полные findings — `memory/project_supplier_integration.md` секция «Pending CV.11 audit findings → Plan 3 backlog».
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
**New files:**
|
||||
|
||||
- `app/app/Console/Commands/CheckSupplierWebhookSecretCommand.php`
|
||||
- `app/tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php`
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `app/app/Http/Controllers/Api/SupplierWebhookController.php` — Fix #ii (verifyIpAllowlist) + Fix #iii (timestamp validation)
|
||||
- `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` — TDD-тесты для Fix #ii + Fix #iii
|
||||
- `db/schema.sql` — без изменений (Fix #iv не требует bump схемы; роль создаётся через `db/00_create_roles.sql`, не через `load_initial_schema.php`)
|
||||
- `db/00_create_roles.sql` — Fix #iv: добавить `crm_supplier_worker` (BYPASSRLS)
|
||||
- `app/app/Services/LeadRouter.php` — Fix #iv: обновить inline-warning (ссылка на `crm_supplier_worker`)
|
||||
- `app/app/Console/Commands/ResetDeliveredTodayCommand.php` — Fix #iv: обновить inline-warning
|
||||
- `docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md` — статус-header после Task 5
|
||||
|
||||
**Memory updates (вне репо, в `~/.claude/projects/.../memory/`):**
|
||||
|
||||
- `feedback_environment.md` — новый quirk про deploy queue worker под `crm_supplier_worker`
|
||||
- `project_supplier_integration.md` — Plan 2.6 closure секция
|
||||
- `project_state.md` — header (HEAD origin/main, Pest count)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Fix #i — `supplier:check-webhook-secret` deploy validator (WARN #4)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Console/Commands/CheckSupplierWebhookSecretCommand.php`
|
||||
- Create: `app/tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php`
|
||||
|
||||
**Контекст:** Seed в `db/schema.sql:2582` — `supplier_webhook_secret = '__SET_ON_DEPLOY__'` (17 chars). `SupplierWebhookController::verifySecret` (line 96-98) явно блокирует placeholder OR len < 32 → 404. На production без deploy-time override — endpoint нерабочий silently. Нужен fail-fast deploy-gate.
|
||||
|
||||
- [ ] **Step 1: Failing test (4 кейса в одном файле)**
|
||||
|
||||
Создать `app/tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('rejects placeholder seed `__SET_ON_DEPLOY__`', function (): void {
|
||||
DB::table('system_settings')
|
||||
->updateOrInsert(
|
||||
['key' => 'supplier_webhook_secret'],
|
||||
['value' => '__SET_ON_DEPLOY__', 'value_type' => 'string', 'description' => 'test seed']
|
||||
);
|
||||
|
||||
$exitCode = artisan('supplier:check-webhook-secret')->run();
|
||||
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('rejects too-short secret (< 32 chars)', function (): void {
|
||||
DB::table('system_settings')
|
||||
->updateOrInsert(
|
||||
['key' => 'supplier_webhook_secret'],
|
||||
['value' => 'short-secret-only-20-chars', 'value_type' => 'string', 'description' => 'test']
|
||||
);
|
||||
|
||||
$exitCode = artisan('supplier:check-webhook-secret')->run();
|
||||
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('rejects missing seed', function (): void {
|
||||
DB::table('system_settings')->where('key', 'supplier_webhook_secret')->delete();
|
||||
|
||||
$exitCode = artisan('supplier:check-webhook-secret')->run();
|
||||
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('accepts valid secret (≥32 chars and not placeholder)', function (): void {
|
||||
DB::table('system_settings')
|
||||
->updateOrInsert(
|
||||
['key' => 'supplier_webhook_secret'],
|
||||
['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed']
|
||||
);
|
||||
|
||||
$exitCode = artisan('supplier:check-webhook-secret')->run();
|
||||
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
```
|
||||
|
||||
NB: `artisan()` — Pest helper для PestPhp\Plugins\Laravel; alternative — `\Illuminate\Support\Facades\Artisan::call('supplier:check-webhook-secret')`. Проверить какой используется в проекте — посмотреть существующий `tests/Feature/Console/ResetDeliveredTodayCommandTest.php` и взять оттуда паттерн.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```powershell
|
||||
./vendor/bin/pest tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php
|
||||
```
|
||||
|
||||
Expected: 4 failed (Command not found / "There are no commands defined in the 'supplier' namespace").
|
||||
|
||||
- [ ] **Step 3: Implement `CheckSupplierWebhookSecretCommand`**
|
||||
|
||||
Создать `app/app/Console/Commands/CheckSupplierWebhookSecretCommand.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Deploy-time validator для supplier_webhook_secret.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §5.1.
|
||||
* Plan 2.6 fix #i: closes CV.11 audit WARN #4 (placeholder seed на production
|
||||
* → endpoint silent 404 на ВСЕ запросы).
|
||||
*
|
||||
* Использование в deploy-script: `php artisan supplier:check-webhook-secret`
|
||||
* exit code 0 = OK, exit code 1 = блокирующая ошибка (deploy не должен продолжаться).
|
||||
*
|
||||
* Проверки:
|
||||
* 1. system_settings row существует с key='supplier_webhook_secret'.
|
||||
* 2. value !== '__SET_ON_DEPLOY__' (placeholder из schema seed).
|
||||
* 3. strlen(value) >= 32 chars (требование verifySecret в SupplierWebhookController).
|
||||
*/
|
||||
class CheckSupplierWebhookSecretCommand extends Command
|
||||
{
|
||||
protected $signature = 'supplier:check-webhook-secret';
|
||||
|
||||
protected $description = 'Deploy-time validator: проверка supplier_webhook_secret seed (Plan 2.6 fix #i)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$row = DB::table('system_settings')->where('key', 'supplier_webhook_secret')->first();
|
||||
|
||||
if ($row === null) {
|
||||
$this->error('FAIL: system_settings row для key=supplier_webhook_secret не найдена. Schema seed повреждён или БД не мигрирована.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$value = (string) $row->value;
|
||||
|
||||
if ($value === '__SET_ON_DEPLOY__') {
|
||||
$this->error('FAIL: supplier_webhook_secret = "__SET_ON_DEPLOY__" (placeholder из schema seed). Override через UPDATE system_settings перед deploy.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (strlen($value) < 32) {
|
||||
$this->error('FAIL: supplier_webhook_secret слишком короткий (length='.strlen($value).', нужно ≥32 chars для совместимости с verifySecret в SupplierWebhookController).');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('OK: supplier_webhook_secret valid (length='.strlen($value).' chars, не placeholder).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
```powershell
|
||||
./vendor/bin/pest tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php
|
||||
```
|
||||
|
||||
Expected: 4 passed.
|
||||
|
||||
- [ ] **Step 5: Larastan + Pint**
|
||||
|
||||
```powershell
|
||||
./vendor/bin/phpstan analyse --memory-limit=512M app/Console/Commands/CheckSupplierWebhookSecretCommand.php
|
||||
./vendor/bin/pint --test app/Console/Commands/CheckSupplierWebhookSecretCommand.php
|
||||
```
|
||||
|
||||
Expected: passed / passed.
|
||||
|
||||
- [ ] **Step 6: Commit (atomic)**
|
||||
|
||||
```bash
|
||||
git add app/app/Console/Commands/CheckSupplierWebhookSecretCommand.php app/tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(commands): supplier:check-webhook-secret — deploy validator (Plan 2.6 #i)
|
||||
|
||||
Закрывает CV.11 audit WARN #4 (placeholder secret '__SET_ON_DEPLOY__' = silent
|
||||
404 на production).
|
||||
|
||||
Console command для deploy-script: SELECT system_settings.supplier_webhook_secret
|
||||
→ exit 1 если placeholder OR len < 32 OR row отсутствует. Иначе exit 0.
|
||||
|
||||
Использование: deploy-script вызывает `php artisan supplier:check-webhook-secret`
|
||||
перед запуском приложения; non-zero exit прерывает deploy.
|
||||
|
||||
TDD: 4 теста (placeholder rejected / short rejected / missing rejected / valid accepted).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Fix #ii — IP allowlist production fail-closed (WARN #5)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/SupplierWebhookController.php` (lines 109-115 в `verifyIpAllowlist`)
|
||||
- Modify: `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` (+ failing test)
|
||||
|
||||
**Контекст:** `verifyIpAllowlist` (line 113-114): `if ($list === []) return true;` — fail-open, любой IP пропускается. На dev OK (localhost development), на production — security gap. Inline-warning lines 34-39 признаёт. Fix: в production env пустой allowlist = блокировать.
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
Добавить в `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` (в конец файла перед `?>` или последний `});`):
|
||||
|
||||
```php
|
||||
it('blocks empty IP allowlist в production env (Plan 2.6 fix #ii)', function (): void {
|
||||
// Setup: valid secret + пустой IP allowlist (default seed).
|
||||
DB::table('system_settings')
|
||||
->updateOrInsert(
|
||||
['key' => 'supplier_webhook_secret'],
|
||||
['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed']
|
||||
);
|
||||
DB::table('system_settings')
|
||||
->updateOrInsert(
|
||||
['key' => 'supplier_ip_allowlist'],
|
||||
['value' => '[]', 'value_type' => 'json', 'description' => 'test seed']
|
||||
);
|
||||
|
||||
// Mock app environment как production.
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
|
||||
$response = $this->postJson('/api/webhook/supplier/'.str_repeat('a', 64), [
|
||||
'vid' => 1,
|
||||
'project' => 'B1_test.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => now()->getTimestamp(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
it('allows empty IP allowlist в dev env (Plan 2.6 fix #ii — fail-open для dev)', function (): void {
|
||||
DB::table('system_settings')
|
||||
->updateOrInsert(
|
||||
['key' => 'supplier_webhook_secret'],
|
||||
['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed']
|
||||
);
|
||||
DB::table('system_settings')
|
||||
->updateOrInsert(
|
||||
['key' => 'supplier_ip_allowlist'],
|
||||
['value' => '[]', 'value_type' => 'json', 'description' => 'test seed']
|
||||
);
|
||||
|
||||
// Test env (default — testing) → fail-open сохраняется.
|
||||
|
||||
$response = $this->postJson('/api/webhook/supplier/'.str_repeat('a', 64), [
|
||||
'vid' => 999999,
|
||||
'project' => 'B1_dev-test.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => now()->getTimestamp(),
|
||||
]);
|
||||
|
||||
// 202 = accepted (значит verifyIpAllowlist пропустил).
|
||||
$response->assertStatus(202);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```powershell
|
||||
./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php --filter="blocks empty IP allowlist в production"
|
||||
```
|
||||
|
||||
Expected: 1 failed (assertStatus 404, actual 202 — потому что fix ещё не применён, production env не блокирует).
|
||||
|
||||
- [ ] **Step 3: Implement fix в `verifyIpAllowlist`**
|
||||
|
||||
Modify `app/app/Http/Controllers/Api/SupplierWebhookController.php`:
|
||||
|
||||
```php
|
||||
private function verifyIpAllowlist(?string $clientIp): bool
|
||||
{
|
||||
if ($clientIp === null) {
|
||||
return false;
|
||||
}
|
||||
$row = DB::table('system_settings')->where('key', 'supplier_ip_allowlist')->first();
|
||||
if ($row === null) {
|
||||
return true;
|
||||
}
|
||||
$list = json_decode((string) $row->value, true) ?: [];
|
||||
if ($list === []) {
|
||||
// Plan 2.6 fix #ii: production env — пустой allowlist fail-closed (защита от
|
||||
// забытого override в schema seed); dev/testing — fail-open для localhost dev.
|
||||
// CV.11 audit WARN #5: inline-warning lines 34-39 признавал проблему,
|
||||
// теперь enforced.
|
||||
return ! app()->environment('production');
|
||||
}
|
||||
|
||||
return IpUtils::checkIp($clientIp, $list);
|
||||
}
|
||||
```
|
||||
|
||||
Также обновить inline-warning header в файле (lines 34-39) — заменить «только secret защищает» на «production env enforce'ит non-empty allowlist через verifyIpAllowlist».
|
||||
|
||||
- [ ] **Step 4: Run tests to verify it passes**
|
||||
|
||||
```powershell
|
||||
./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php
|
||||
```
|
||||
|
||||
Expected: все тесты passed (включая 2 новых + существующие 8 не сломались).
|
||||
|
||||
- [ ] **Step 5: Larastan + Pint**
|
||||
|
||||
```powershell
|
||||
./vendor/bin/phpstan analyse --memory-limit=512M app/Http/Controllers/Api/SupplierWebhookController.php
|
||||
./vendor/bin/pint --test app/Http/Controllers/Api/SupplierWebhookController.php
|
||||
```
|
||||
|
||||
Expected: passed / passed.
|
||||
|
||||
- [ ] **Step 6: Commit (atomic)**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Controllers/Api/SupplierWebhookController.php app/tests/Feature/Http/Webhook/SupplierWebhookTest.php
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix(http): IP allowlist fail-closed в production env (Plan 2.6 #ii)
|
||||
|
||||
Закрывает CV.11 audit WARN #5 (пустой supplier_ip_allowlist '[]' = fail-open
|
||||
на production — любой IP пропускается).
|
||||
|
||||
Изменение: SupplierWebhookController::verifyIpAllowlist — если allowlist пустой,
|
||||
возвращаем true только если env != production. На production пустой allowlist
|
||||
блокирует (404). На dev/testing fail-open сохраняется (для localhost development).
|
||||
|
||||
TDD: +2 теста (production env empty → 404; testing env empty → 202).
|
||||
Inline-warning header обновлён.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Fix #iii — Timestamp partition guard ±24h (WARN minor #5)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/SupplierWebhookController.php` (lines 52-60 validation rules)
|
||||
- Modify: `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` (+ 2 failing tests)
|
||||
|
||||
**Контекст:** `Carbon::createFromTimestamp((int) $payload['time'])` (`RouteSupplierLeadJob.php:168`) использует raw `time` от поставщика. Партиции `deals` месячные (`deals_2026_05` от 2026-05-01). Timestamp за пределами текущего месячного окна → INSERT упадёт с `no partition of relation "deals" found for row`. Fix: контроллер отвергает webhooks с time за окном [now-24h, now+24h].
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
Добавить в `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php`:
|
||||
|
||||
```php
|
||||
it('rejects timestamp older than 24h (Plan 2.6 fix #iii — partition guard)', function (): void {
|
||||
DB::table('system_settings')
|
||||
->updateOrInsert(
|
||||
['key' => 'supplier_webhook_secret'],
|
||||
['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed']
|
||||
);
|
||||
|
||||
$response = $this->postJson('/api/webhook/supplier/'.str_repeat('a', 64), [
|
||||
'vid' => 100001,
|
||||
'project' => 'B1_old-time.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => now()->subDays(2)->getTimestamp(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)->assertJsonValidationErrors('time');
|
||||
});
|
||||
|
||||
it('rejects timestamp more than 24h in future (Plan 2.6 fix #iii — partition guard)', function (): void {
|
||||
DB::table('system_settings')
|
||||
->updateOrInsert(
|
||||
['key' => 'supplier_webhook_secret'],
|
||||
['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed']
|
||||
);
|
||||
|
||||
$response = $this->postJson('/api/webhook/supplier/'.str_repeat('a', 64), [
|
||||
'vid' => 100002,
|
||||
'project' => 'B1_future-time.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => now()->addDays(2)->getTimestamp(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)->assertJsonValidationErrors('time');
|
||||
});
|
||||
|
||||
it('accepts timestamp within ±24h window (Plan 2.6 fix #iii — partition guard)', function (): void {
|
||||
DB::table('system_settings')
|
||||
->updateOrInsert(
|
||||
['key' => 'supplier_webhook_secret'],
|
||||
['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed']
|
||||
);
|
||||
|
||||
$response = $this->postJson('/api/webhook/supplier/'.str_repeat('a', 64), [
|
||||
'vid' => 100003,
|
||||
'project' => 'B1_valid-time.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => now()->subHours(6)->getTimestamp(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(202);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
```powershell
|
||||
./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php --filter="timestamp"
|
||||
```
|
||||
|
||||
Expected: 2 failed (-2 days и +2 days сейчас принимаются как valid → assertStatus 422 fails).
|
||||
|
||||
- [ ] **Step 3: Implement validation в `receive`**
|
||||
|
||||
Modify `SupplierWebhookController::receive`:
|
||||
|
||||
```php
|
||||
public function receive(Request $request, string $secret): JsonResponse
|
||||
{
|
||||
if (! $this->verifySecret($secret)) {
|
||||
return response()->json(['message' => 'Not found.'], 404);
|
||||
}
|
||||
|
||||
if (! $this->verifyIpAllowlist($request->ip())) {
|
||||
return response()->json(['message' => 'Not found.'], 404);
|
||||
}
|
||||
|
||||
// Plan 2.6 fix #iii: timestamp partition guard. Партиции deals месячные
|
||||
// (deals_2026_MM); time за пределами текущего месяца → INSERT CRASH
|
||||
// "no partition of relation deals found for row". Окно ±24h защищает от
|
||||
// wildly out-of-range значений (старый/будущий дроп от поставщика).
|
||||
$minTime = now()->subDay()->getTimestamp();
|
||||
$maxTime = now()->addDay()->getTimestamp();
|
||||
|
||||
$validated = $request->validate([
|
||||
'vid' => 'required|integer|min:1',
|
||||
'project' => ['required', 'string', 'max:255', 'regex:/^B[123]_.+$/'],
|
||||
'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
|
||||
'time' => ['required', 'integer', "min:{$minTime}", "max:{$maxTime}"],
|
||||
'tag' => 'nullable|string|max:255',
|
||||
'phones' => 'nullable|array',
|
||||
'phones.*' => 'string|regex:/^7\d{10}$/',
|
||||
]);
|
||||
|
||||
// ... (остальной код без изменений)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
```powershell
|
||||
./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php
|
||||
```
|
||||
|
||||
Expected: все passed (включая 3 новых).
|
||||
|
||||
- [ ] **Step 5: Larastan + Pint**
|
||||
|
||||
```powershell
|
||||
./vendor/bin/phpstan analyse --memory-limit=512M app/Http/Controllers/Api/SupplierWebhookController.php
|
||||
./vendor/bin/pint --test app/Http/Controllers/Api/SupplierWebhookController.php
|
||||
```
|
||||
|
||||
Expected: passed / passed.
|
||||
|
||||
- [ ] **Step 6: Commit (atomic)**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Controllers/Api/SupplierWebhookController.php app/tests/Feature/Http/Webhook/SupplierWebhookTest.php
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix(http): timestamp validation ±24h для partition guard (Plan 2.6 #iii)
|
||||
|
||||
Закрывает CV.11 audit WARN minor #5 (Carbon::createFromTimestamp(time) без
|
||||
range guard → INSERT CRASH "no partition of relation deals found for row"
|
||||
для timestamp вне текущего месячного окна).
|
||||
|
||||
Изменение: SupplierWebhookController::receive — добавлено min/max constraint
|
||||
на 'time' = [now-24h, now+24h] unix-timestamp. Timestamp вне окна → 422
|
||||
ValidationException.
|
||||
|
||||
±24h: покрывает retry-задержки поставщика (network-сбой) + clock-drift серверов;
|
||||
шире окно (±48h+) = риск partition-промаха на стыке месяцев (нужен Plan 5
|
||||
partition cron).
|
||||
|
||||
TDD: +3 теста (-2 days → 422; +2 days → 422; -6h → 202).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Fix #iv — `crm_supplier_worker` BYPASSRLS-роль (WARN minor #2 + #3)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `db/00_create_roles.sql` (новая роль)
|
||||
- Modify: `app/app/Services/LeadRouter.php` (inline-warning lines 25-29)
|
||||
- Modify: `app/app/Console/Commands/ResetDeliveredTodayCommand.php` (inline-warning lines 16-18)
|
||||
|
||||
**Контекст:** `LeadRouter::matchEligibleProjects` (sharing-flow) и `ResetDeliveredTodayCommand` (cron) делают cross-tenant SELECT/UPDATE без `SET LOCAL app.current_tenant_id`. На dev (`postgres` BYPASSRLS) работает; на prod (`crm_app_user` RLS-enforce) — отвергнутся policy `tenant_isolation`. Plan 2.6 brainstorming → вариант C (BYPASSRLS-роль для queue worker).
|
||||
|
||||
Архитектурное обоснование: queue worker — backend system process, обрабатывает webhooks для разных tenant'ов sharing-flow + global crons. По дизайну видит все tenant'ы. Privilege-boundary совпадает с архитектурной ролью. WHERE(tenant_id=…) фильтры в коде (например `RouteSupplierLeadJob::createDealCopyForProject` line 161-164) сохраняются как defense-in-depth.
|
||||
|
||||
**Без TDD-теста на роль** — integration test требует создания роли в test DB + смены connection (overhead не оправдан); делаем grep-smoke в Step 5.
|
||||
|
||||
- [ ] **Step 1: Add role definition в `db/00_create_roles.sql`**
|
||||
|
||||
Прочитать существующий `db/00_create_roles.sql` для понимания style:
|
||||
|
||||
```powershell
|
||||
cat db/00_create_roles.sql
|
||||
```
|
||||
|
||||
Добавить блок (в конец файла):
|
||||
|
||||
```sql
|
||||
-- =============================================================================
|
||||
-- crm_supplier_worker — BYPASSRLS-роль для backend queue worker (Plan 2.6 fix #iv)
|
||||
-- =============================================================================
|
||||
-- Закрывает CV.11 audit WARN minor #2 + #3 (LeadRouter и ResetDeliveredTodayCommand
|
||||
-- под crm_app_user → RLS-policy tenant_isolation отвергает cross-tenant SELECT/UPDATE).
|
||||
--
|
||||
-- Privilege-boundary by design:
|
||||
-- • Queue worker (php artisan queue:work) = backend system process для cross-tenant
|
||||
-- операций: sharing-webhook routing (RouteSupplierLeadJob), global crons
|
||||
-- (projects:reset-delivered-today, supplier:check-webhook-secret).
|
||||
-- • Web worker = end-user-facing, остаётся под crm_app_user (RLS-enforce).
|
||||
-- WHERE(tenant_id=) фильтры в коде сохраняются как defense-in-depth даже под
|
||||
-- BYPASSRLS-ролью.
|
||||
--
|
||||
-- Deploy:
|
||||
-- • Создать роль через этот файл при первом deploy (DBA / migrate:fresh).
|
||||
-- • Установить пароль через secrets manager (Yandex KMS / Vault).
|
||||
-- • Queue worker .env (отдельный от web .env): DB_USERNAME=crm_supplier_worker.
|
||||
--
|
||||
-- Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6.
|
||||
-- Brainstorm decision (Plan 2.6, 10.05.2026 поздняя ночь): вариант C из 3 опций
|
||||
-- (A=elevated DB-connection / B=RLS WITH-CHECK exception / C=BYPASSRLS-роль).
|
||||
-- =============================================================================
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
|
||||
-- Пароль ОБЯЗАТЕЛЬНО override через secrets manager перед production deploy.
|
||||
EXECUTE format('CREATE ROLE crm_supplier_worker WITH LOGIN BYPASSRLS PASSWORD %L', '__SET_VIA_SECRETS_MANAGER__');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
GRANT USAGE ON SCHEMA public TO crm_supplier_worker;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO crm_supplier_worker;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_supplier_worker;
|
||||
|
||||
-- DEFAULT PRIVILEGES для будущих таблиц / sequences:
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO crm_supplier_worker;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO crm_supplier_worker;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update inline-warning в `LeadRouter.php`**
|
||||
|
||||
Modify lines 25-29 в `app/app/Services/LeadRouter.php`:
|
||||
|
||||
```php
|
||||
* RLS-quirk: запрос работает поверх N tenant'ов одновременно (sharing-model).
|
||||
* Не использует SET LOCAL app.current_tenant_id (в sharing-flow tenant ещё не определён —
|
||||
* запрос подбирает кандидатов из всех tenant'ов параллельно). На production queue worker
|
||||
* запускается под ролью crm_supplier_worker (BYPASSRLS) — Plan 2.6 fix #iv. На dev
|
||||
* подключение под postgres (BYPASSRLS implicit). См. db/00_create_roles.sql.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update inline-warning в `ResetDeliveredTodayCommand.php`**
|
||||
|
||||
Modify lines 14-18 в `app/app/Console/Commands/ResetDeliveredTodayCommand.php`:
|
||||
|
||||
```php
|
||||
/**
|
||||
* Сброс projects.delivered_today=0 для всех tenant'ов.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6.1.
|
||||
* Расписание: каждый день в 00:00 МСК (timezone Europe/Moscow).
|
||||
*
|
||||
* NB: tenant-scoped запрос без RLS — UPDATE сразу на все tenant'ы. На production
|
||||
* queue worker (через Scheduler) запускается под ролью crm_supplier_worker
|
||||
* (BYPASSRLS) — Plan 2.6 fix #iv. На dev подключение под postgres (BYPASSRLS
|
||||
* implicit). См. db/00_create_roles.sql.
|
||||
*/
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Smoke-grep verify (вместо integration-теста)**
|
||||
|
||||
```powershell
|
||||
grep -c "crm_supplier_worker" db/00_create_roles.sql
|
||||
grep -c "BYPASSRLS" db/00_create_roles.sql
|
||||
grep -c "crm_supplier_worker" app/app/Services/LeadRouter.php
|
||||
grep -c "crm_supplier_worker" app/app/Console/Commands/ResetDeliveredTodayCommand.php
|
||||
```
|
||||
|
||||
Expected: каждый grep ≥1 (роль определена + cross-refs из кода работают).
|
||||
|
||||
- [ ] **Step 5: Run full Pest для regression check**
|
||||
|
||||
```powershell
|
||||
./vendor/bin/pest --parallel
|
||||
```
|
||||
|
||||
Expected: все тесты passed (Plan 2.6 fix #i + #ii + #iii добавили ~7-8 новых тестов; baseline после Plan 2.5 — 549/547, ожидается ~556-557).
|
||||
|
||||
- [ ] **Step 6: Larastan + Pint**
|
||||
|
||||
```powershell
|
||||
./vendor/bin/phpstan analyse --memory-limit=512M
|
||||
./vendor/bin/pint --test
|
||||
```
|
||||
|
||||
Expected: passed / passed.
|
||||
|
||||
- [ ] **Step 7: Commit (atomic)**
|
||||
|
||||
```bash
|
||||
git add db/00_create_roles.sql app/app/Services/LeadRouter.php app/app/Console/Commands/ResetDeliveredTodayCommand.php
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(db): crm_supplier_worker BYPASSRLS-роль для queue worker (Plan 2.6 #iv)
|
||||
|
||||
Закрывает CV.11 audit WARN minor #2 + #3 (LeadRouter + ResetDeliveredTodayCommand
|
||||
под crm_app_user → RLS-policy tenant_isolation отвергает cross-tenant SELECT/UPDATE).
|
||||
|
||||
Архитектурное решение (Plan 2.6 brainstorm 10.05.2026 поздняя ночь, вариант C из 3
|
||||
опций): новая PG-роль crm_supplier_worker с BYPASSRLS — privilege-boundary by design.
|
||||
Queue worker = backend system process для cross-tenant операций (sharing-webhook
|
||||
routing, global crons); web worker остаётся под crm_app_user (RLS-enforce).
|
||||
|
||||
WHERE(tenant_id=) фильтры в коде сохраняются как defense-in-depth.
|
||||
|
||||
Deploy:
|
||||
• Роль создаётся через db/00_create_roles.sql при первом deploy.
|
||||
• Пароль override через secrets manager (Yandex KMS).
|
||||
• Queue worker .env: DB_USERNAME=crm_supplier_worker (отдельно от web .env).
|
||||
|
||||
Inline-warnings обновлены в LeadRouter.php + ResetDeliveredTodayCommand.php.
|
||||
|
||||
Без TDD-теста на роль (integration-тест требует CREATE ROLE в test DB +
|
||||
смены connection — overhead не оправдан); smoke-grep проверяет файлы.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Closure — Memory updates + Plan 2.6 status header + push
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md` (status header)
|
||||
- Modify (memory, вне репо): `feedback_environment.md`, `project_supplier_integration.md`, `project_state.md`
|
||||
|
||||
- [ ] **Step 1: Re-run full verification**
|
||||
|
||||
```powershell
|
||||
./vendor/bin/pest --parallel
|
||||
./vendor/bin/phpstan analyse --memory-limit=512M
|
||||
./vendor/bin/pint --test
|
||||
```
|
||||
|
||||
Expected: всё green; Pest count = 549 (baseline) + 4 (Task 1 idempotency tests) + 2 (Task 2 IP allowlist tests) + 3 (Task 3 timestamp tests) = ~558 / ~556 passed.
|
||||
|
||||
- [ ] **Step 2: Update memory `feedback_environment.md`**
|
||||
|
||||
Добавить новый quirk-блок:
|
||||
|
||||
```markdown
|
||||
- **Plan 2.6 fix #iv (10.05.2026 поздняя ночь): crm_supplier_worker BYPASSRLS-роль для queue worker.** Backend queue worker (php artisan queue:work) на production должен подключаться к БД под ролью `crm_supplier_worker` (BYPASSRLS), не под `crm_app_user` (RLS-enforce). Причина: sharing-webhook routing + global crons работают cross-tenant. См. `db/00_create_roles.sql`. Web worker остаётся под `crm_app_user`. Раздельные `.env` для web и queue.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update memory `project_supplier_integration.md`**
|
||||
|
||||
Добавить секцию «Plan 2.6 cleanup (10.05.2026 поздняя ночь финал) — closure CV.11 operational + RLS остатков» с детализацией:
|
||||
|
||||
- 4 fix'а закрыты (4 atomic commits)
|
||||
- Pest count после Plan 2.6
|
||||
- Что осталось в Plan 3 backlog (BLOCKER #6 + 5 NIT + некоторые WARN minor #1, #4, #6, #7, #8)
|
||||
|
||||
Также обновить таблицу декомпозиции Plans 1-5: добавить строку «2.6 hotfix #i+ii+iii+iv ✅ DONE» по аналогии с Plan 2.5.
|
||||
|
||||
- [ ] **Step 4: Update memory `project_state.md`**
|
||||
|
||||
Header description: HEAD origin/main = новый SHA (после push); Pest count = реальное число.
|
||||
|
||||
- [ ] **Step 5: Update Plan 2.6 file — status header**
|
||||
|
||||
Modify `docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md` — добавить blockquote после h1:
|
||||
|
||||
```markdown
|
||||
> **✅ Plan completed (10.05.2026 поздняя ночь финал).** Все 4 fix'а закрыты атомарными commit'ами. Pest <N>/<M> passed (+9 тестов от Plan 2.5 baseline 549/547). Larastan + Pint clean. Memory updated. CV.11 audit Plan 3 backlog: BLOCKER #6 (RLS на failed_webhook_jobs INSERT NULL tenant) + 5 NIT + 5 minor WARN.
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit Plan 2.6 status header**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(plans): close Plan 2.6 — ✅ status header
|
||||
|
||||
Все 4 fix'а закрыты атомарными commits:
|
||||
• Fix #i (placeholder secret deploy validator)
|
||||
• Fix #ii (IP allowlist production fail-closed)
|
||||
• Fix #iii (timestamp partition guard ±24h)
|
||||
• Fix #iv (crm_supplier_worker BYPASSRLS-роль)
|
||||
|
||||
Pest <N>/<M> passed (+9 тестов от Plan 2.5 baseline 549/547).
|
||||
Larastan + Pint clean.
|
||||
|
||||
Plan 3 backlog: BLOCKER #6 + 5 NIT + 5 minor WARN.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Push origin main**
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Expected: lefthook pre-push (gitleaks-full-history + lychee) PASS, push successful. После Plan 2.6 на origin/main будет 5 новых commits (Tasks 1-5).
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
- ✅ WARN #4 (placeholder secret) — Task 1.
|
||||
- ✅ WARN #5 (empty IP allowlist) — Task 2.
|
||||
- ✅ WARN minor #5 (Carbon partition crash) — Task 3.
|
||||
- ✅ WARN minor #2 + #3 (LeadRouter + ResetCmd RLS под crm_app_user) — Task 4.
|
||||
- ✅ Memory closure — Task 5.
|
||||
|
||||
**Placeholder scan:** нет TODO/TBD/«implement later». Все шаги имеют конкретный код / команды.
|
||||
|
||||
**Type consistency:** Console signature `supplier:check-webhook-secret` (Task 1) — не дублируется. `verifyIpAllowlist` (Task 2) и `receive` validation (Task 3) — оба в `SupplierWebhookController`, без коллизий имен. `crm_supplier_worker` имя роли — единое во всех Tasks (4 и 5).
|
||||
|
||||
**Что НЕ покрыто (вынесено в Plan 3 / NOTES):**
|
||||
|
||||
- BLOCKER #6 (RLS на failed_webhook_jobs INSERT NULL tenant) — архитектурное решение (отдельная SaaS-таблица или INSERT-policy WITH CHECK true), Plan 3 первая задача.
|
||||
- WARN minor #1 (SET LOCAL quotes стилистика) — defer.
|
||||
- WARN minor #4 (COALESCE NOT NULL guard) — defer.
|
||||
- WARN minor #6 (PhonePrefix re-validation в job) — defer.
|
||||
- WARN minor #7 (parsePlatform DRY) — defer.
|
||||
- WARN minor #8 (DuplicateDetector тай-брейкер) — defer.
|
||||
- 5 NIT — defer.
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
**Plan saved:** `docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md`.
|
||||
|
||||
**Two execution options:**
|
||||
|
||||
1. **Subagent-Driven (recommended)** — fresh subagent per task + 2-stage review между.
|
||||
2. **Inline Execution** — гонит таски в текущей сессии через `superpowers:executing-plans`.
|
||||
|
||||
**Which approach?**
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,976 +0,0 @@
|
||||
# Test Quality + Pre-Prod Sprint Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Phase B — закрыть 3 test-quality debt items (vitest timeout, prettier drift, ProjectFactory unique-name + test:parallel alias); Phase A — закрыть 3 pre-prod P2 gaps из Audit #2 Phase 14 (partitions schedule, runbook, aria-tooltip-name).
|
||||
|
||||
**Architecture:** Phase B — чистые config/formatting/factory изменения без behavioral code changes. Phase A — +1 cron entry, новый `RUNBOOK.md`, +1 a11y attribute fix в TenantsTable.vue. Фазы независимы, коммиты атомарны. Итог: 6 content-commits + push.
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4, Vue 3 / Vuetify 3 / Vitest 4, Prettier 3.8, axe-core 4.10 (Playwright MCP verification)
|
||||
|
||||
**Reference:**
|
||||
|
||||
- Quirk #80: `memory/feedback_environment.md` — vitest coverage timeout 5000ms на router.spec.ts
|
||||
- Quirk #62/#73: sequential Pest cumulative state в `liderra_testing`
|
||||
- Quirk #77: ProjectFactory birthday-paradox collision в --parallel
|
||||
- Audit #2 Phase 14 findings: [`docs/superpowers/audits/2026-05-13-portal-full-audit-findings.md`](../audits/2026-05-13-portal-full-audit-findings.md)
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
### Phase B
|
||||
|
||||
- Modify: [`../../../app/vitest.config.ts`](../../../app/vitest.config.ts) — add `testTimeout: 10000`
|
||||
- Modify: `../../../app/resources/js/**/*.{ts,vue,css}` + `../../../app/tests/Frontend/**/*.ts` — prettier --write (312-file drift)
|
||||
- Modify: [`../../../app/database/factories/ProjectFactory.php`](../../../app/database/factories/ProjectFactory.php) — `fake()->unique()->words(3, true)`
|
||||
- Modify: [`../../../app/composer.json`](../../../app/composer.json) — add `"test:parallel"` script alias
|
||||
|
||||
### Phase A
|
||||
|
||||
- Modify: [`../../../app/routes/console.php`](../../../app/routes/console.php) — add `Schedule::command('partitions:create-months')->daily()`
|
||||
- Create: `RUNBOOK.md` (repo root, рядом с CLAUDE.md)
|
||||
- Modify: [`../../../app/resources/js/components/admin/tenants/TenantsTable.vue`](../../../app/resources/js/components/admin/tenants/TenantsTable.vue) — `aria-label` на `v-tooltip`
|
||||
|
||||
---
|
||||
|
||||
## Quirks / Guardrails
|
||||
|
||||
| Quirk | Правило |
|
||||
|---|---|
|
||||
| #79 | Bash absolute paths — не стекать `cd app &&` между calls |
|
||||
| #74 | `npm install` → `--legacy-peer-deps` |
|
||||
| #76 | Links в plans/ → `../../../` prefix для app/ references |
|
||||
| #62/#73 | Sequential Pest failures из cumulative `liderra_testing` data → использовать `--recreate-databases` |
|
||||
| #77 | ProjectFactory `fake()->words(3,true)` → birthday-paradox collision в --parallel |
|
||||
| #80 | router.spec.ts coverage timeout 5000ms → `testTimeout: 10000` fix |
|
||||
|
||||
---
|
||||
|
||||
## Task B.1: vitest.config.ts — testTimeout: 10000 (Quirk #80)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/vitest.config.ts:9` — добавить `testTimeout` внутри `test: {}`
|
||||
|
||||
- [ ] **B.1.1: Verify router.spec.ts тайм-аутит под coverage (baseline до фикса)**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
npx vitest run --coverage --coverage.reporter=text-summary --include="tests/Frontend/router.spec.ts" 2>&1
|
||||
```
|
||||
|
||||
Expected: ≥1 timeout failure (`Error: Test timed out after 5000ms`). Показать полный output.
|
||||
|
||||
Если 0 failures — фикс уже применён; skip B.1.2, перейти к B.1.3.
|
||||
|
||||
- [ ] **B.1.2: Edit vitest.config.ts**
|
||||
|
||||
Изменить `app/vitest.config.ts` — добавить `testTimeout: 10000` в блок `test`:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import vuetify from 'vite-plugin-vuetify';
|
||||
|
||||
// Vitest config (фаза 2, Tooling §3.3 #23). Отдельно от vite.config.js
|
||||
// чтобы не тащить laravel-vite-plugin в JSDOM-тестах.
|
||||
export default defineConfig({
|
||||
plugins: [vue(), vuetify({ autoImport: true })],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
testTimeout: 10000,
|
||||
setupFiles: ['./tests/Frontend/setup.ts'],
|
||||
include: ['tests/Frontend/**/*.{test,spec}.ts'],
|
||||
server: {
|
||||
deps: {
|
||||
inline: ['vuetify'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **B.1.3: Verify router.spec.ts passes под coverage**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
npx vitest run --coverage --coverage.reporter=text-summary --include="tests/Frontend/router.spec.ts" 2>&1
|
||||
```
|
||||
|
||||
Expected: **все 9 тестов PASS**, 0 timeouts. Coverage table включает `router/index.ts` row. Показать полный output.
|
||||
|
||||
- [ ] **B.1.4: Verify полный Vitest suite не сломан**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
npx vitest run 2>&1 | tail -15
|
||||
```
|
||||
|
||||
Expected: **88 files / 683 passed + 3 skipped**. Показать output.
|
||||
|
||||
- [ ] **B.1.5: Commit**
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация"
|
||||
git add "app/vitest.config.ts"
|
||||
git commit -m "test(vitest): add testTimeout: 10000 — fix quirk #80 router.spec.ts coverage timeout
|
||||
|
||||
v8 coverage instrumentation adds ~10x overhead to router-guard async tests,
|
||||
pushing past the 5000ms default. Audit #2 Phase 13 finding.
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task B.2: Prettier drift — format 312 files
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/**/*.{ts,vue,css}`, `app/tests/Frontend/**/*.ts`
|
||||
|
||||
- [ ] **B.2.1: Check drift scope**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
npm run format:check 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: список ~312 файлов с formatting issues + exit 1.
|
||||
|
||||
Если выход 0 / 0 files — drift уже применён; skip B.2.2–B.2.3.
|
||||
|
||||
- [ ] **B.2.2: Apply prettier --write**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
npm run format 2>&1
|
||||
```
|
||||
|
||||
Expected: output вида `X files written`. Показать полный output.
|
||||
|
||||
- [ ] **B.2.3: Re-verify 0 issues**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
npm run format:check 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: exit 0, 0 formatting issues.
|
||||
|
||||
- [ ] **B.2.4: Vitest suite — verify no breakage**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
npx vitest run 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: 88 files / 683 passed + 3 skipped.
|
||||
|
||||
- [ ] **B.2.5: ESLint — verify no regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
npm run lint:vue 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **B.2.6: Stage и commit**
|
||||
|
||||
NB: если Vite dev server запущен background → `dev-indices.json` мог auto-regenerate. Стейджить вместе с logical commit.
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация"
|
||||
git add "app/resources/js/" "app/tests/Frontend/" "app/dev-indices.json"
|
||||
git status --short | head -20
|
||||
git commit -m "style: prettier --write all frontend files — eliminate 312-file drift
|
||||
|
||||
Audit #2 Phase 1 found 312 files with formatting mismatch vs .prettierrc.
|
||||
No logic changes — whitespace/quote/trailing-comma normalization only.
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task B.3: ProjectFactory unique() + test:parallel alias (Quirks #77, #62)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/database/factories/ProjectFactory.php:23`
|
||||
- Modify: `app/composer.json` (add `test:parallel` script)
|
||||
- Test: `app/tests/Feature/Api/ProjectBulkActionsTest.php` (verify --parallel pass)
|
||||
|
||||
**Context quirk #77:** `fake()->words(3, true)` на ~100 English слов → ~1M 3-word комбинаций. Birthday paradox для 501 projects в одном tenant → ~12.5% collision probability per test run. `fake()->unique()` держит per-instance трекер использованных значений → 0 дублей внутри одного factory instance.
|
||||
|
||||
- [ ] **B.3.1: Verify collision reproducible (baseline до фикса)**
|
||||
|
||||
Run 2–3 раза:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
vendor/bin/pest --parallel tests/Feature/Api/ProjectBulkActionsTest.php 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: ≥1 из 3 прогонов → failure `SQLSTATE[23505] projects_tenant_id_name_key`. Показать output каждого прогона.
|
||||
|
||||
- [ ] **B.3.2: Применить фикс в ProjectFactory.php**
|
||||
|
||||
Изменить `app/database/factories/ProjectFactory.php` строку 23 с:
|
||||
|
||||
```php
|
||||
'name' => fake()->words(3, true),
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```php
|
||||
'name' => fake()->unique()->words(3, true),
|
||||
```
|
||||
|
||||
- [ ] **B.3.3: Run affected test 5 раз — verify 0 collisions**
|
||||
|
||||
Run 5 раз:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
vendor/bin/pest --parallel tests/Feature/Api/ProjectBulkActionsTest.php 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: **14/14 passed** все 5 прогонов. Показать output каждого.
|
||||
|
||||
- [ ] **B.3.4: Добавить test:parallel alias в composer.json**
|
||||
|
||||
В `app/composer.json` добавить строку после `"pint:test"`:
|
||||
|
||||
```json
|
||||
"test:parallel": "vendor/bin/pest --parallel --recreate-databases",
|
||||
```
|
||||
|
||||
Результирующий блок scripts (фрагмент):
|
||||
|
||||
```json
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi @no_additional_args",
|
||||
"@php artisan test"
|
||||
],
|
||||
"pint": "@php vendor/bin/pint",
|
||||
"pint:test": "@php vendor/bin/pint --test",
|
||||
"test:parallel": "vendor/bin/pest --parallel --recreate-databases",
|
||||
```
|
||||
|
||||
- [ ] **B.3.5: Validate composer.json + smoke-run test:parallel**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
composer validate 2>&1 | tail -3
|
||||
```
|
||||
|
||||
Expected: `./composer.json is valid`
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
composer test:parallel 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: 742 passed, ≤1 failed (квирк 72 sporadic). Показать полный tail.
|
||||
|
||||
- [ ] **B.3.6: Commit**
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация"
|
||||
git add "app/database/factories/ProjectFactory.php" "app/composer.json"
|
||||
git commit -m "fix(test): ProjectFactory unique() name + test:parallel alias (quirks #77, #62)
|
||||
|
||||
fake()->unique()->words() prevents birthday-paradox collision on 501 projects
|
||||
in one tenant (1M combos, ~12.5% parallel-run failure rate). Quirk #77.
|
||||
|
||||
Adds composer test:parallel alias (--parallel --recreate-databases) for
|
||||
accumulated-state cleanup in sequential Pest runs. Quirks #62/#73.
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task A.1: Register partitions:create-months в schedule (Audit #2 Phase 14 P2)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/routes/console.php`
|
||||
|
||||
**Context:** `PartitionsCreateMonths` (Command signature: `partitions:create-months`) создаёт ежемесячные партиции для `deals` и `supplier_lead_costs` на 2 месяца вперёд. Idempotent (CREATE TABLE IF NOT EXISTS via `pg_class` check). Команда существует, но НЕ зарегистрирована в `routes/console.php` → partition lifecycle не automated. Audit #2 Phase 14 P2.
|
||||
|
||||
На dev (Windows): запускается вручную / Windows Task Scheduler.
|
||||
На prod (YC Linux): через `php artisan schedule:run` каждую минуту cron.
|
||||
|
||||
- [ ] **A.1.1: Verify команда отсутствует в schedule**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
php artisan schedule:list 2>&1
|
||||
```
|
||||
|
||||
Expected: `partitions:create-months` **отсутствует** в списке. Показать полный output.
|
||||
|
||||
- [ ] **A.1.2: Добавить Schedule entry в routes/console.php**
|
||||
|
||||
В `app/routes/console.php` добавить в конце файла (после блока CsvReconcileJob):
|
||||
|
||||
```php
|
||||
|
||||
// Partition maintenance: create next 2 monthly partitions for `deals` + `supplier_lead_costs`.
|
||||
// Replaces pg_partman (unavailable on native Windows; see project_phase1_strategy.md).
|
||||
// Idempotent: CREATE TABLE IF NOT EXISTS via pg_class check in PartitionsCreateMonths::partitionExists().
|
||||
// On prod: triggered daily via `php artisan schedule:run` (cron every minute).
|
||||
// On dev (Windows): run manually or via Windows Task Scheduler.
|
||||
Schedule::command('partitions:create-months')
|
||||
->daily()
|
||||
->timezone('Europe/Moscow');
|
||||
```
|
||||
|
||||
- [ ] **A.1.3: Verify команда появилась в schedule:list**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
php artisan schedule:list 2>&1
|
||||
```
|
||||
|
||||
Expected: строка с `partitions:create-months` и частотой `Daily`. Показать полный output.
|
||||
|
||||
- [ ] **A.1.4: Verify команда выполняется без ошибок**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
php artisan partitions:create-months 2>&1
|
||||
```
|
||||
|
||||
Expected: `Done: 0 created, 6 skipped (ahead=2).` (все партиции уже существуют от migrate:fresh). Exit code 0. Показать полный output.
|
||||
|
||||
- [ ] **A.1.5: Pest regression check**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
vendor/bin/pest --parallel --recreate-databases 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: 742 passed, ≤1 failed (квирк 72).
|
||||
|
||||
- [ ] **A.1.6: Commit**
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация"
|
||||
git add "app/routes/console.php"
|
||||
git commit -m "fix(scheduler): register partitions:create-months in schedule — close Audit #2 P2
|
||||
|
||||
Missing from routes/console.php left partition lifecycle not automated.
|
||||
Audit #2 Phase 14 P2 finding. Idempotent daily at Europe/Moscow timezone.
|
||||
On dev: run manually. On prod: via schedule:run cron.
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task A.2: Create RUNBOOK.md (Audit #2 Phase 14 P2)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `RUNBOOK.md` (repo root, рядом с `CLAUDE.md`)
|
||||
|
||||
**Context:** Audit #2 Phase 14 P2 — deployment runbook отсутствует. Статус: pre-prod (Б-1 ООО регистрация pending; prod credentials/infra не finalized). Runbook покрывает local dev setup и production deploy на YC Linux VM.
|
||||
|
||||
- [ ] **A.2.1: Create RUNBOOK.md**
|
||||
|
||||
Создать файл `RUNBOOK.md` в корне репозитория (`c:\моя\проекты\портал crm\Документация\RUNBOOK.md`):
|
||||
|
||||
```markdown
|
||||
# Лидерра — Runbook развёртывания
|
||||
|
||||
> **Версия:** 1.0 от 13.05.2026. Статус: 🟡 PRE-PROD (Б-1 ООО регистрация pending — prod credentials/infra не finalized).
|
||||
> **Стек:** Laravel 13 + Vue 3 + Vuetify 3 + PostgreSQL 16 (ICU) + Redis 7 + Yandex Cloud.
|
||||
|
||||
---
|
||||
|
||||
## Содержание
|
||||
|
||||
1. [Системные требования](#1-системные-требования)
|
||||
2. [Клонирование и зависимости](#2-клонирование-и-зависимости)
|
||||
3. [Конфигурация .env](#3-конфигурация-env)
|
||||
4. [База данных](#4-база-данных)
|
||||
5. [Сборка frontend](#5-сборка-frontend)
|
||||
6. [Очереди (Queue Worker)](#6-очереди-queue-worker)
|
||||
7. [Планировщик (Scheduler)](#7-планировщик-scheduler)
|
||||
8. [Проверка работоспособности](#8-проверка-работоспособности)
|
||||
9. [Типовые проблемы](#9-типовые-проблемы)
|
||||
10. [Первый запуск — Dev seed](#10-первый-запуск--dev-seed)
|
||||
|
||||
---
|
||||
|
||||
## 1. Системные требования
|
||||
|
||||
| Компонент | Версия | Примечания |
|
||||
|-----------|--------|------------|
|
||||
| PHP | 8.3+ | Extensions: pdo_pgsql, pgsql, redis, bcmath, mbstring, openssl, fileinfo |
|
||||
| Composer | 2.x | |
|
||||
| Node.js | 20+ | |
|
||||
| npm | 10+ | |
|
||||
| PostgreSQL | 16+ | ICU collation support обязателен (ILIKE кириллица) |
|
||||
| Redis | 7+ | Memurai на Windows-dev |
|
||||
| Git | 2.x | |
|
||||
|
||||
**Linux prod (Yandex Cloud Ubuntu 22.04):**
|
||||
|
||||
```bash
|
||||
# PHP 8.3
|
||||
sudo apt install php8.3-fpm php8.3-pgsql php8.3-redis php8.3-bcmath \
|
||||
php8.3-mbstring php8.3-xml php8.3-curl php8.3-zip
|
||||
|
||||
# PostgreSQL 16 client
|
||||
sudo apt install libpq-dev postgresql-client-16
|
||||
|
||||
# Node.js 20
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
|
||||
sudo apt install nodejs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Клонирование и зависимости
|
||||
|
||||
```bash
|
||||
git clone git@github.com:CoralMinister/lidpotok.git liderra
|
||||
cd liderra/app
|
||||
|
||||
# PHP-зависимости (prod)
|
||||
composer install --no-dev --optimize-autoloader
|
||||
|
||||
# Node-зависимости (prod)
|
||||
# --legacy-peer-deps обязателен (Histoire 1.0-beta.1 peerDep conflict)
|
||||
npm install --legacy-peer-deps --omit=dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Конфигурация .env
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
```
|
||||
|
||||
Обязательные переменные:
|
||||
|
||||
```ini
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://your-domain.ru
|
||||
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=<managed-pg-host>
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=liderra
|
||||
DB_USERNAME=crm_app_user
|
||||
DB_PASSWORD=<strong-password>
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=<redis-password>
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=smtp.unisender.ru
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=<unisender-login>
|
||||
MAIL_PASSWORD=<unisender-api-key>
|
||||
MAIL_ENCRYPTION=tls
|
||||
MAIL_FROM_ADDRESS=noreply@liderra.ru
|
||||
MAIL_FROM_NAME="Лидерра"
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
CACHE_STORE=redis
|
||||
QUEUE_CONNECTION=redis
|
||||
```
|
||||
|
||||
> **Sentry (pending Б-1):** `SENTRY_LARAVEL_DSN=https://...@sentry.your-domain.ru/...`
|
||||
|
||||
---
|
||||
|
||||
## 4. База данных
|
||||
|
||||
### 4.1 Создание БД с ICU collation
|
||||
|
||||
```sql
|
||||
-- От суперпользователя PostgreSQL
|
||||
CREATE DATABASE liderra
|
||||
WITH ENCODING 'UTF8'
|
||||
LOCALE_PROVIDER icu
|
||||
ICU_LOCALE 'und'
|
||||
LC_COLLATE 'C'
|
||||
LC_CTYPE 'C'
|
||||
TEMPLATE template0;
|
||||
```
|
||||
|
||||
> **Важно:** ICU collation обязателен для корректного `ILIKE` на кириллице. БД без ICU → поиск по русским строкам возвращает 0 результатов (Quirk #61).
|
||||
|
||||
### 4.2 Роли БД (только prod)
|
||||
|
||||
```bash
|
||||
# На dev используется postgres-superuser; на prod создать роли:
|
||||
psql -U postgres -f db/00_create_roles.sql
|
||||
```
|
||||
|
||||
### 4.3 Миграции
|
||||
|
||||
```bash
|
||||
php artisan migrate --force
|
||||
```
|
||||
|
||||
Единственная миграция `load_initial_schema.php` загружает `db/schema.sql` через `DB::unprepared()`. Ожидается ~800ms.
|
||||
|
||||
### 4.4 Grants (prod)
|
||||
|
||||
```bash
|
||||
psql -U postgres -d liderra -f db/02_grants.sql
|
||||
```
|
||||
|
||||
### 4.5 Первичное создание партиций
|
||||
|
||||
```bash
|
||||
# Создать ежемесячные партиции deals + supplier_lead_costs на 2 мес. вперёд
|
||||
php artisan partitions:create-months
|
||||
```
|
||||
|
||||
Далее планировщик создаёт их автоматически ежедневно.
|
||||
|
||||
---
|
||||
|
||||
## 5. Сборка frontend
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Ожидается ~3–5 сек. Артефакты в `public/build/`.
|
||||
|
||||
> Bundle analyzer: `BUILD_ANALYZE=1 npm run build` → `storage/bundle-analyze.html`
|
||||
|
||||
---
|
||||
|
||||
## 6. Очереди (Queue Worker)
|
||||
|
||||
**Dev:**
|
||||
|
||||
```bash
|
||||
php artisan queue:work redis --tries=3 --timeout=60
|
||||
```
|
||||
|
||||
**Prod (Supervisor)** — `/etc/supervisor/conf.d/liderra-worker.conf`:
|
||||
|
||||
```ini
|
||||
[program:liderra-worker]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php /var/www/liderra/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
numprocs=2
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/liderra/worker.log
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo supervisorctl reread && sudo supervisorctl update
|
||||
sudo supervisorctl start liderra-worker:*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Планировщик (Scheduler)
|
||||
|
||||
Добавить в crontab на prod:
|
||||
|
||||
```bash
|
||||
* * * * * cd /var/www/liderra/app && php artisan schedule:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
Расписание (`routes/console.php`):
|
||||
|
||||
| Команда | Частота | Назначение |
|
||||
|---------|---------|------------|
|
||||
| `projects:reset-delivered-today` | Ежедневно 00:00 МСК | Сброс daily-счётчика лидов |
|
||||
| `projects:reset-monthly` | 1-го числа 00:00 МСК | Сброс месячных счётчиков |
|
||||
| `partitions:create-months` | Ежедневно | Партиции PG на 2 мес. вперёд |
|
||||
| `supplier:retry-failed` | Ежечасно | Повтор failed supplier jobs |
|
||||
| Queue jobs | см. console.php | Supplier sync + CSV reconcile |
|
||||
|
||||
Проверить расписание:
|
||||
|
||||
```bash
|
||||
php artisan schedule:list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Проверка работоспособности
|
||||
|
||||
```bash
|
||||
# PHP + Laravel
|
||||
php artisan about
|
||||
|
||||
# БД соединение
|
||||
php artisan db:show
|
||||
|
||||
# Redis
|
||||
php artisan tinker --execute="echo cache()->get('healthcheck', 'redis-ok');"
|
||||
|
||||
# HTTP ответ (ожидается 200)
|
||||
curl -o /dev/null -s -w "%{http_code}" http://localhost/login
|
||||
|
||||
# Frontend build артефакты
|
||||
ls public/build/assets/*.js | head -5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Типовые проблемы
|
||||
|
||||
### ILIKE не находит кириллицу
|
||||
|
||||
**Симптом:** поиск по русским строкам → 0 результатов.
|
||||
**Причина:** БД создана с `LC_CTYPE=C` без ICU (Quirk #61).
|
||||
**Решение:** пересоздать БД через раздел 4.1 + повторить миграции.
|
||||
|
||||
### Pest sequential тесты падают (LookupsTest / ProjectExtensionsTest)
|
||||
|
||||
**Симптом:** `composer test` → 3 failures «N matches 2».
|
||||
**Причина:** накопленные данные в `liderra_testing` (Quirk #62/#73).
|
||||
**Решение:** `composer test:parallel` (использует `--recreate-databases`).
|
||||
|
||||
### partitions:create-months — ошибка доступа к pg_class
|
||||
|
||||
**Симптом:** `SELECT 1 FROM pg_class WHERE ...` → permission error.
|
||||
**Причина:** ограниченная роль без доступа к pg_class.
|
||||
**Решение:** запускать от `crm_migrator` роли или postgres superuser.
|
||||
|
||||
### Queue worker зависает на RefreshSupplierSessionJob
|
||||
|
||||
**Симптом:** job в статусе `processing` >60 сек.
|
||||
**Причина:** PlaywrightBridge не может войти в портал поставщика.
|
||||
**Решение:** `php artisan queue:failed` → проверить error message. Обновить credentials в таблице `system_settings`.
|
||||
|
||||
### Vite hot-reload не работает в staging
|
||||
|
||||
**Симптом:** изменения Vue-компонентов не отражаются без hard-reload.
|
||||
**Причина:** `VITE_HMR_*` env vars не настроены для staging hostname.
|
||||
**Решение:** в `.env` добавить `VITE_HMR_HOST=your-staging-host`. Или использовать production build (`npm run build`).
|
||||
|
||||
---
|
||||
|
||||
## 10. Первый запуск — Dev seed
|
||||
|
||||
Только для локальной разработки:
|
||||
|
||||
```bash
|
||||
php artisan db:seed --class=DemoSeeder
|
||||
```
|
||||
|
||||
Создаёт: `admin@demo.local` / `password`, 1 тенант, 3 проекта, 14 сделок.
|
||||
|
||||
---
|
||||
|
||||
*Обновлять при изменении инфраструктуры, деплой-процедур или списка scheduled-команд.*
|
||||
|
||||
```
|
||||
|
||||
- [ ] **A.2.2: Verify markdownlint**
|
||||
|
||||
Run:
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация"
|
||||
npx markdownlint-cli2 RUNBOOK.md 2>&1
|
||||
```
|
||||
|
||||
Expected: exit 0, 0 errors. При ошибках — исправить inline (trailing spaces, heading levels, bare URLs).
|
||||
|
||||
- [ ] **A.2.3: Commit**
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация"
|
||||
git add "RUNBOOK.md"
|
||||
git commit -m "docs: add RUNBOOK.md — deployment runbook (Audit #2 Phase 14 P2)
|
||||
|
||||
Covers system requirements (PHP 8.3/PG 16 ICU/Redis), DB setup with ICU
|
||||
collation, migrations, roles, partition maintenance, queue worker (Supervisor),
|
||||
scheduler crontab, health checks, common issues.
|
||||
Status: pre-prod (B-1 pending; prod credentials TBD).
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task A.3: Fix aria-tooltip-name — VTooltip /admin/tenants (Audit #2 Phase 10.2 P2)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/components/admin/tenants/TenantsTable.vue:81`
|
||||
|
||||
**Context:** axe-core 4.10 violation `aria-tooltip-name` (impact: serious) на `/admin/tenants`. `v-tooltip` рендерит `<div role="tooltip" id="v-tooltip-v-N">` без явно вычислимого accessible name. Vuetify VOverlay передаёт `$attrs` к overlay root div → добавление `aria-label` на `<v-tooltip>` даёт explicit accessible name для axe.
|
||||
|
||||
Текущий код `TenantsTable.vue:81–95`:
|
||||
|
||||
```html
|
||||
<template #[`item.actions`]="{ item }: { item: AdminTenant }">
|
||||
<v-tooltip text="Войти как клиент (impersonation)" location="top">
|
||||
<template #activator="{ props: tipProps }">
|
||||
<v-btn
|
||||
v-bind="tipProps"
|
||||
icon="mdi-account-switch"
|
||||
variant="text"
|
||||
size="small"
|
||||
density="comfortable"
|
||||
:aria-label="`Войти как клиент (impersonation) для ${item.name}`"
|
||||
:disabled="item.status === 'suspended'"
|
||||
:data-testid="`impersonate-btn-${item.id}`"
|
||||
@click.stop="emit('impersonate', item)"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **A.3.1: Baseline axe-core на /admin/tenants (pre-fix)**
|
||||
|
||||
Через Playwright MCP — убедиться что dev servers запущены:
|
||||
|
||||
```powershell
|
||||
curl -s -o NUL -w "%{http_code}" http://127.0.0.1:8000/login
|
||||
```
|
||||
|
||||
Expected: `200`. Если нет → запустить `php artisan serve --port=8000` + `npm run dev` в background.
|
||||
|
||||
Login (если сессия не активна):
|
||||
|
||||
```javascript
|
||||
// browser_navigate → http://127.0.0.1:8000/login
|
||||
// browser_evaluate:
|
||||
document.querySelector('input[type="email"]').value = 'admin@demo.local';
|
||||
document.querySelector('input[type="email"]').dispatchEvent(new Event('input', { bubbles: true }));
|
||||
document.querySelector('input[type="password"]').value = 'password';
|
||||
document.querySelector('input[type="password"]').dispatchEvent(new Event('input', { bubbles: true }));
|
||||
// browser_click → button[type="submit"]
|
||||
// verify redirect → /dashboard
|
||||
```
|
||||
|
||||
Baseline axe scan:
|
||||
|
||||
```javascript
|
||||
// browser_navigate → http://127.0.0.1:8000/admin/tenants
|
||||
// browser_evaluate:
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://cdn.jsdelivr.net/npm/axe-core@4.10.0/axe.min.js';
|
||||
document.head.appendChild(s);
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
const res = await axe.run({ runOnly: { type: 'rule', values: ['aria-tooltip-name'] } });
|
||||
return res.violations.map(v => ({ id: v.id, nodes: v.nodes.length, help: v.help }));
|
||||
```
|
||||
|
||||
Expected pre-fix: `[{ id: 'aria-tooltip-name', nodes: 1, ... }]`. Если `[]` — violation исчезла сама (e.g. tooltip рендерится скрытым); зафиксировать в findings и skip A.3.2–A.3.4.
|
||||
|
||||
- [ ] **A.3.2: Apply fix в TenantsTable.vue**
|
||||
|
||||
Изменить `app/resources/js/components/admin/tenants/TenantsTable.vue` строку 81–82 с:
|
||||
|
||||
```html
|
||||
<template #[`item.actions`]="{ item }: { item: AdminTenant }">
|
||||
<v-tooltip text="Войти как клиент (impersonation)" location="top">
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```html
|
||||
<template #[`item.actions`]="{ item }: { item: AdminTenant }">
|
||||
<v-tooltip
|
||||
text="Войти как клиент (impersonation)"
|
||||
location="top"
|
||||
aria-label="Войти как клиент (impersonation)"
|
||||
>
|
||||
```
|
||||
|
||||
- [ ] **A.3.3: Run Vitest AdminTenantsView spec — no regression**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
npx vitest run tests/Frontend/AdminTenantsView.spec.ts 2>&1
|
||||
```
|
||||
|
||||
Expected: все тесты pass. Показать полный output.
|
||||
|
||||
- [ ] **A.3.4: Verify fix via axe-core post-fix**
|
||||
|
||||
Через Playwright MCP:
|
||||
|
||||
```javascript
|
||||
// browser_navigate → http://127.0.0.1:8000/admin/tenants (hard reload)
|
||||
// browser_evaluate (та же axe-инъекция + axe.run):
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
// [inject axe-core script как в A.3.1]
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
const res = await axe.run({ runOnly: { type: 'rule', values: ['aria-tooltip-name'] } });
|
||||
return res.violations.map(v => ({ id: v.id, nodes: v.nodes.length }));
|
||||
```
|
||||
|
||||
Expected: **`[]`** — 0 `aria-tooltip-name` violations.
|
||||
|
||||
**Если не `[]` — ≥3 гипотезы:**
|
||||
|
||||
- **H1:** Vuetify не форвардит `aria-label` к overlay root div (attrs идут в content div, не overlay). Фикс: попробовать `:content-props="{ 'aria-label': 'Войти как клиент (impersonation)' }"` вместо прямого `aria-label`.
|
||||
- **H2:** Tooltip overlay рендерится в `display:none` во время axe scan — accessible name не вычислима. Фикс: hover кнопки перед scan (`browser_hover` на `[data-testid="impersonate-btn-1"]`) чтобы tooltip открылся, затем axe.run.
|
||||
- **H3:** axe CDN кэш прогрузил old version (4.9.x вместо 4.10). Проверить: `axe.version` в evaluate. Если не `4.10.0` → принудительный cache-bust `?v=${Date.now()}` в src URL.
|
||||
|
||||
Если H1+H2+H3 не дают `[]` → эскалировать как «VTooltip overlay teleport не доступен axe — требует отдельного исследования».
|
||||
|
||||
- [ ] **A.3.5: Commit**
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация"
|
||||
git add "app/resources/js/components/admin/tenants/TenantsTable.vue"
|
||||
git commit -m "fix(a11y): aria-label on VTooltip overlay /admin/tenants — close Audit #2 P2
|
||||
|
||||
axe-core 4.10 aria-tooltip-name: role=tooltip overlay had no accessible name.
|
||||
aria-label forwarded via VOverlay attrs. Audit #2 Phase 10.2 finding.
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task I: Финальная регрессия + Pre-push + Push
|
||||
|
||||
- [ ] **I.1: Pest полный suite**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
vendor/bin/pest --parallel --recreate-databases 2>&1 | tail -15
|
||||
```
|
||||
|
||||
Expected: **742 passed**, ≤1 failed (квирк 72 sporadic). Показать полный tail.
|
||||
|
||||
- [ ] **I.2: Vitest полный suite**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
npx vitest run 2>&1 | tail -15
|
||||
```
|
||||
|
||||
Expected: **88 files / 683 passed + 3 skipped. 0 failures.** Показать полный tail.
|
||||
|
||||
- [ ] **I.3: Pint + Larastan**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация\app"
|
||||
composer pint:test 2>&1 | tail -5
|
||||
composer stan 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: Pint 0 issues; Larastan 0 errors. Показать output обоих.
|
||||
|
||||
- [ ] **I.4: lefthook pre-push**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация"
|
||||
npx lefthook run pre-push 2>&1
|
||||
```
|
||||
|
||||
Expected: gitleaks-full-history 0 leaks + lychee 0 broken links. Показать **полный** output.
|
||||
|
||||
- [ ] **I.5: Push origin/main**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd "c:\моя\проекты\портал crm\Документация"
|
||||
git push origin main 2>&1
|
||||
```
|
||||
|
||||
Expected: exit 0. Показать полный output.
|
||||
|
||||
- [ ] **I.6: Verify remote HEAD**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git log origin/main --oneline -8
|
||||
```
|
||||
|
||||
Expected: 6 новых коммитов (B.1, B.2, B.3, A.1, A.2, A.3) на вершине после `9e175a1`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage:**
|
||||
|
||||
| Требование | Task |
|
||||
|---|---|
|
||||
| Quirk #80 testTimeout router.spec.ts | B.1 ✅ |
|
||||
| Prettier drift 312 files | B.2 ✅ |
|
||||
| ProjectFactory unique() (quirk #77) | B.3 ✅ |
|
||||
| Quirk #62/#73 test:parallel alias | B.3 ✅ |
|
||||
| partitions:create-months в schedule | A.1 ✅ |
|
||||
| RUNBOOK.md deployment runbook | A.2 ✅ |
|
||||
| aria-tooltip-name VTooltip fix | A.3 ✅ |
|
||||
| Финальная регрессия + push | I ✅ |
|
||||
|
||||
**2. Placeholder scan:** нет TBD/TODO/"implement later" в step bodies. A.3.4 H-гипотезы — contingency debugging, не placeholder (конкретные действия с конкретными ожидаемыми результатами). A.2 RUNBOOK.md `<managed-pg-host>` / `<strong-password>` — это actual env-var placeholders, not plan placeholders (правильно — юзер заполняет).
|
||||
|
||||
**3. Type consistency:** PHP factory — `string` (возврат `fake()->unique()->words(3, true)` тот же type). Vue template — `aria-label` string attr (стандартный HTML attr). composer.json — корректный JSON script entry. Нет новых типов/методов.
|
||||
|
||||
**Gaps found и закрыты:**
|
||||
|
||||
- B.2.6: Vite dev server в background может auto-regenerate `dev-indices.json` → добавлено в stage-команду ✅
|
||||
- A.3.4: H-гипотезы охватывают 3 failure-mode'а VTooltip axe ✅
|
||||
- lychee проверка RUNBOOK.md: файл в корне, links к `app/` корректны без `../../../` (не в plans/) ✅
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,424 +0,0 @@
|
||||
# Sprint 2 Plan A — Auth subsystem (A2/A3 + A7) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remove the orphaned `/recovery` RecoveryCodesView page (dead code) and make the `/legal/offer` & `/legal/privacy` footer links resolve to real stub pages instead of a 404.
|
||||
|
||||
**Architecture:** Two independent, atomic changes. (1) Delete the `/recovery` route + `RecoveryCodesView.vue` + its story + spec — the real 2FA recovery-codes flow lives entirely in Settings → Безопасность (`TwoFactorCard.vue` setup wizard shows codes inline; `RecoveryCodesCard.vue` regenerates them), and nothing in the UI navigates to `/recovery`. (2) Add a single DRY `LegalDocView.vue` served by a `/legal/:doc(offer|privacy)` route, rendering an honest "document being finalized" stub (real legal text needs юр. редактура — реестр K3 / blocker Б-1). Upgrade the AuthLayout footer links from raw `<a href>` to `<RouterLink>`.
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup>`, Vuetify 3, vue-router 4 (`createWebHistory`, lazy imports), Vitest + `@vue/test-utils`, Laravel 13 (`routes/web.php` SPA `Route::view`).
|
||||
|
||||
---
|
||||
|
||||
## Context for the implementer
|
||||
|
||||
- **Working directory:** `c:\моя\проекты\портал crm\Документация`. The Laravel app is under `app/`. Frontend = `app/resources/js/`, frontend tests = `app/tests/Frontend/`.
|
||||
- **Branch:** `main` (consistent with Sprint 1 — implementers commit atomically directly to `main`). Do **not** push (user pushes manually).
|
||||
- **Commands** (run from `app/`):
|
||||
- Vitest: `npm run test:vue`
|
||||
- Type-check: `npm run type-check`
|
||||
- ESLint: `npm run lint:vue`
|
||||
- **Baseline (fresh, 2026-05-15):** Pest `742/739/3sk/0`, Vitest `92 files / 774 passed / 3 skipped / 0 failed`, vue-tsc `0`, ESLint `0`.
|
||||
- **Lefthook pre-commit** runs gitleaks / markdownlint / cspell / eslint-vue / pint / larastan on staged files — must stay green. Do **not** bypass hooks (`--no-verify` forbidden).
|
||||
- **`app/dev-indices.json`** is a pre-existing **uncommitted** dev artifact (audit I2 — Sprint 6 cleanup). Do **NOT** stage or modify it. Stage only the exact files each task names.
|
||||
- **Why `/recovery` is orphaned (verified):** grep across `app/resources/js` finds **no** `router.push('/recovery')` and **no** `<RouterLink to="/recovery">`. The only references are the route definition itself, the Histoire stub, the `.story.vue`, and the view file. `TwoFactorView.vue:126` links `/recovery-use` (a different page — consuming a code to log in), which is **kept**. The real codes-display is `TwoFactorCard.vue` setup wizard step `'codes'` and `RecoveryCodesCard.vue` regeneration — both already use the real API (`twoFactorConfirm` / `twoFactorRegenerateRecoveryCodes`).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `app/resources/js/views/auth/RecoveryCodesView.vue` | **Delete** | Orphaned mock recovery-codes page |
|
||||
| `app/resources/js/views/auth/RecoveryCodesView.story.vue` | **Delete** | Histoire story for the orphan |
|
||||
| `app/tests/Frontend/RecoveryCodesView.spec.ts` | **Delete** | Spec for the orphan (4 tests) |
|
||||
| `app/resources/js/router/index.ts` | Modify | Remove `/recovery` route; add `/legal/:doc` route |
|
||||
| `app/routes/web.php` | Modify | Remove `Route::view('/recovery')`; add 2 legal `Route::view` |
|
||||
| `app/resources/js/histoire.setup.ts` | Modify | Remove `/recovery` Histoire stub route |
|
||||
| `app/resources/js/views/legal/LegalDocView.vue` | **Create** | One DRY view for offer + privacy stub docs |
|
||||
| `app/tests/Frontend/LegalDocView.spec.ts` | **Create** | Spec for the legal view (4 tests) |
|
||||
| `app/resources/js/layouts/AuthLayout.vue` | Modify | Footer links `<a href>` → `<RouterLink>` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Delete orphaned RecoveryCodesView (closes A2 + A3)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Delete: `app/resources/js/views/auth/RecoveryCodesView.vue`
|
||||
- Delete: `app/resources/js/views/auth/RecoveryCodesView.story.vue`
|
||||
- Delete: `app/tests/Frontend/RecoveryCodesView.spec.ts`
|
||||
- Modify: `app/resources/js/router/index.ts`
|
||||
- Modify: `app/routes/web.php`
|
||||
- Modify: `app/resources/js/histoire.setup.ts`
|
||||
|
||||
This task has no "failing test first" — it is a verified dead-code removal. Discipline = confirm-then-delete-then-verify-suite-green.
|
||||
|
||||
- [ ] **Step 1: Confirm orphan status**
|
||||
|
||||
Run (from repo root):
|
||||
|
||||
```bash
|
||||
grep -rn "RecoveryCodesView\|'/recovery'\|\"/recovery\"\|to=\"/recovery\"\|push('/recovery')" app/resources/js app/routes app/tests
|
||||
```
|
||||
|
||||
Expected: matches **only** in `router/index.ts` (the `/recovery` route), `web.php` (`Route::view('/recovery', ...)`), `histoire.setup.ts` (the stub), `RecoveryCodesView.vue` itself, and `RecoveryCodesView.story.vue`. There must be **no** `router.push('/recovery')` and **no** `<RouterLink to="/recovery">`. (`/recovery-use` matches are a different, kept page — ignore them.) If a real navigation to `/recovery` is found, **STOP** and report — the orphan assumption is wrong.
|
||||
|
||||
- [ ] **Step 2: Delete the three orphan files**
|
||||
|
||||
```bash
|
||||
rm "app/resources/js/views/auth/RecoveryCodesView.vue"
|
||||
rm "app/resources/js/views/auth/RecoveryCodesView.story.vue"
|
||||
rm "app/tests/Frontend/RecoveryCodesView.spec.ts"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove the `/recovery` route from `app/resources/js/router/index.ts`**
|
||||
|
||||
Delete this exact route object from the `routes` array (it sits between the `/forgot` route and the `/recovery-use` route):
|
||||
|
||||
```ts
|
||||
{
|
||||
path: '/recovery',
|
||||
name: 'recovery',
|
||||
component: () => import('../views/auth/RecoveryCodesView.vue'),
|
||||
meta: { layout: 'auth', title: 'Резервные коды', devIndex: 6, devLabel: 'Recovery codes' },
|
||||
},
|
||||
```
|
||||
|
||||
Keep the `/recovery-use` route (`UseRecoveryCodeView.vue`) untouched. Leaving a gap in `devIndex` numbering (5 → 7) is acceptable — `devIndex` is a temporary feature (audit I1/I3, removed in a later sprint); do **not** renumber other routes.
|
||||
|
||||
- [ ] **Step 4: Remove the SPA route from `app/routes/web.php`**
|
||||
|
||||
Delete the line:
|
||||
|
||||
```php
|
||||
Route::view('/recovery', 'welcome');
|
||||
```
|
||||
|
||||
Keep `Route::view('/recovery-use', 'welcome');`.
|
||||
|
||||
- [ ] **Step 5: Remove the Histoire stub from `app/resources/js/histoire.setup.ts`**
|
||||
|
||||
Delete the line:
|
||||
|
||||
```ts
|
||||
{ path: '/recovery', component: { template: '<div />' } },
|
||||
```
|
||||
|
||||
Keep the `/recovery-use` stub line.
|
||||
|
||||
- [ ] **Step 6: Verify no dangling references**
|
||||
|
||||
```bash
|
||||
grep -rn "RecoveryCodesView" app/resources/js app/tests
|
||||
```
|
||||
|
||||
Expected: **no output** (zero matches).
|
||||
|
||||
- [ ] **Step 7: Run frontend verification**
|
||||
|
||||
```bash
|
||||
cd app && npm run test:vue
|
||||
```
|
||||
|
||||
Expected: `91 files / 770 passed / 3 skipped / 0 failed` (baseline `92/774` minus the deleted `RecoveryCodesView.spec.ts` = 1 file / 4 tests). **0 failures.**
|
||||
|
||||
```bash
|
||||
cd app && npm run type-check
|
||||
```
|
||||
|
||||
Expected: `0 errors`.
|
||||
|
||||
```bash
|
||||
cd app && npm run lint:vue
|
||||
```
|
||||
|
||||
Expected: `0 errors`.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add "app/resources/js/views/auth/RecoveryCodesView.vue" \
|
||||
"app/resources/js/views/auth/RecoveryCodesView.story.vue" \
|
||||
"app/tests/Frontend/RecoveryCodesView.spec.ts" \
|
||||
app/resources/js/router/index.ts \
|
||||
app/routes/web.php \
|
||||
app/resources/js/histoire.setup.ts
|
||||
git commit -m "refactor(auth): remove orphaned /recovery RecoveryCodesView page (closes A2, A3)
|
||||
|
||||
Audit A2/A3: RecoveryCodesView (route /recovery) had a TODO no-op
|
||||
continue handler and 8 hardcoded mock codes. Recon found the page is
|
||||
orphaned — nothing in the UI navigates to /recovery. The real 2FA
|
||||
recovery-codes flow lives entirely in Settings -> Безопасность
|
||||
(TwoFactorCard setup wizard + RecoveryCodesCard regeneration), both
|
||||
already wired to the real API. Per user decision (2026-05-15) the
|
||||
orphan is deleted rather than polished.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
(`git add` only the 6 named paths — `git rm`-tracked deletions are picked up by `git add` on the path. Do **not** stage `app/dev-indices.json`.)
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Legal stub pages — `/legal/offer` + `/legal/privacy` (closes A7)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/resources/js/views/legal/LegalDocView.vue`
|
||||
- Create: `app/tests/Frontend/LegalDocView.spec.ts`
|
||||
- Modify: `app/resources/js/router/index.ts`
|
||||
- Modify: `app/routes/web.php`
|
||||
- Modify: `app/resources/js/layouts/AuthLayout.vue`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `app/tests/Frontend/LegalDocView.spec.ts` with exactly:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory, type Router } from 'vue-router';
|
||||
|
||||
import LegalDocView from '../../resources/js/views/legal/LegalDocView.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
const buildRouter = (): Router =>
|
||||
createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/legal/:doc(offer|privacy)', name: 'legal', component: LegalDocView },
|
||||
{ path: '/login', name: 'login', component: { template: '<div>login</div>' } },
|
||||
],
|
||||
});
|
||||
|
||||
const mountAt = async (path: string) => {
|
||||
const router = buildRouter();
|
||||
await router.push(path);
|
||||
await router.isReady();
|
||||
return mount(LegalDocView, { global: { plugins: [vuetify, router] } });
|
||||
};
|
||||
|
||||
describe('LegalDocView.vue', () => {
|
||||
it('рендерит «Договор-оферта» на /legal/offer', async () => {
|
||||
const wrapper = await mountAt('/legal/offer');
|
||||
expect(wrapper.text()).toContain('Договор-оферта');
|
||||
});
|
||||
|
||||
it('рендерит «Политика конфиденциальности» на /legal/privacy', async () => {
|
||||
const wrapper = await mountAt('/legal/privacy');
|
||||
expect(wrapper.text()).toContain('Политика конфиденциальности');
|
||||
});
|
||||
|
||||
it('показывает честную заглушку «документ готовится», а не фейк-текст', async () => {
|
||||
const wrapper = await mountAt('/legal/offer');
|
||||
const notice = wrapper.find('[data-testid="legal-stub-notice"]');
|
||||
expect(notice.exists()).toBe(true);
|
||||
expect(notice.text()).toContain('готовится');
|
||||
});
|
||||
|
||||
it('содержит ссылку возврата ко входу', async () => {
|
||||
const wrapper = await mountAt('/legal/privacy');
|
||||
const back = wrapper.find('a.legal-back');
|
||||
expect(back.exists()).toBe(true);
|
||||
expect(back.attributes('href')).toBe('/login');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run tests/Frontend/LegalDocView.spec.ts
|
||||
```
|
||||
|
||||
Expected: **FAIL** — `Failed to resolve import "../../resources/js/views/legal/LegalDocView.vue"` (the view does not exist yet).
|
||||
|
||||
- [ ] **Step 3: Create the view `app/resources/js/views/legal/LegalDocView.vue`**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Правовые документы — заглушки оферты и политики конфиденциальности.
|
||||
*
|
||||
* Audit A7: ссылки /legal/offer и /legal/privacy в подвале AuthLayout вели
|
||||
* на 404 (catch-all). Финальные тексты документов требуют юридической
|
||||
* редактуры (реестр K3 / блокер Б-1) — до этого страницы показывают честную
|
||||
* заглушку «документ готовится», а не фейк-текст (юридический риск).
|
||||
*
|
||||
* Один view на оба документа (DRY): контент выбирается по route.params.doc.
|
||||
* Маршрут /legal/:doc(offer|privacy) — иные значения отсекает regex-constraint,
|
||||
* уходя в catch-all 404.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
interface LegalDoc {
|
||||
title: string;
|
||||
intro: string;
|
||||
}
|
||||
|
||||
const DOCS: Record<'offer' | 'privacy', LegalDoc> = {
|
||||
offer: {
|
||||
title: 'Договор-оферта',
|
||||
intro: 'Публичная оферта на оказание услуг сервиса «Лидерра» — условия использования платформы, права и обязанности сторон, порядок оплаты.',
|
||||
},
|
||||
privacy: {
|
||||
title: 'Политика конфиденциальности',
|
||||
intro: 'Порядок обработки и защиты персональных данных пользователей сервиса «Лидерра» в соответствии с Федеральным законом № 152-ФЗ «О персональных данных».',
|
||||
},
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const doc = computed<LegalDoc>(() => (String(route.params.doc) === 'privacy' ? DOCS.privacy : DOCS.offer));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card variant="flat" :max-width="480" width="100%" color="transparent" class="legal-card">
|
||||
<header class="legal-header">
|
||||
<h1 class="text-h5 mb-1">{{ doc.title }}</h1>
|
||||
<p class="text-body-2 text-medium-emphasis ma-0">{{ doc.intro }}</p>
|
||||
</header>
|
||||
|
||||
<v-alert type="info" variant="tonal" density="compact" data-testid="legal-stub-notice">
|
||||
Финальная редакция документа готовится и будет опубликована до запуска сервиса.
|
||||
</v-alert>
|
||||
|
||||
<RouterLink to="/login" class="text-body-2 text-primary legal-back"> ← Вернуться ко входу </RouterLink>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legal-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.legal-header h1 {
|
||||
font-variation-settings: 'opsz' 24;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.legal-back {
|
||||
text-decoration: none;
|
||||
}
|
||||
.legal-back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Register the route in `app/resources/js/router/index.ts`**
|
||||
|
||||
Add this route object to the `routes` array **before** the catch-all `/:pathMatch(.*)*` route (placing it just after the `/recovery-use` route, with the other auth-layout routes, is fine):
|
||||
|
||||
```ts
|
||||
{
|
||||
path: '/legal/:doc(offer|privacy)',
|
||||
name: 'legal',
|
||||
component: () => import('../views/legal/LegalDocView.vue'),
|
||||
meta: { layout: 'auth', title: 'Правовые документы' },
|
||||
},
|
||||
```
|
||||
|
||||
No `requiresAuth` / `guestOnly` — legal pages are public (a logged-in user may also read them). No `devIndex` — the page is not part of the temporary dev-index walkthrough.
|
||||
|
||||
- [ ] **Step 5: Run the test to verify it passes**
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run tests/Frontend/LegalDocView.spec.ts
|
||||
```
|
||||
|
||||
Expected: **PASS** — 4/4 tests.
|
||||
|
||||
- [ ] **Step 6: Add SPA routes to `app/routes/web.php`**
|
||||
|
||||
Add these two lines next to the other auth `Route::view` entries (e.g. after `Route::view('/recovery-use', 'welcome');`):
|
||||
|
||||
```php
|
||||
Route::view('/legal/offer', 'welcome');
|
||||
Route::view('/legal/privacy', 'welcome');
|
||||
```
|
||||
|
||||
This makes a hard browser load / refresh of `/legal/offer` serve the SPA shell (consistent with the project's explicit-route convention).
|
||||
|
||||
- [ ] **Step 7: Upgrade the footer links in `app/resources/js/layouts/AuthLayout.vue`**
|
||||
|
||||
Replace the raw anchors (currently at lines ~47-48):
|
||||
|
||||
```vue
|
||||
<a href="/legal/offer">Оферта</a>
|
||||
<a href="/legal/privacy">Политика</a>
|
||||
```
|
||||
|
||||
with `RouterLink`s (SPA navigation, no full page reload):
|
||||
|
||||
```vue
|
||||
<RouterLink to="/legal/offer">Оферта</RouterLink>
|
||||
<RouterLink to="/legal/privacy">Политика</RouterLink>
|
||||
```
|
||||
|
||||
Leave the `<span>v8 · Forest</span>` line and the `.bp-foot` wrapper unchanged. `RouterLink` renders a plain `<a>` element, so any existing `.bp-foot a { ... }` style rules continue to apply unchanged — no `<style>` edit needed.
|
||||
|
||||
- [ ] **Step 8: Run full frontend verification**
|
||||
|
||||
```bash
|
||||
cd app && npm run test:vue
|
||||
```
|
||||
|
||||
Expected: `92 files / 774 passed / 3 skipped / 0 failed` (Task 1 left it at `91/770`; this task adds `LegalDocView.spec.ts` = +1 file / +4 tests → `92/774`). **0 failures.**
|
||||
|
||||
```bash
|
||||
cd app && npm run type-check
|
||||
```
|
||||
|
||||
Expected: `0 errors`.
|
||||
|
||||
```bash
|
||||
cd app && npm run lint:vue
|
||||
```
|
||||
|
||||
Expected: `0 errors`.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/legal/LegalDocView.vue \
|
||||
app/tests/Frontend/LegalDocView.spec.ts \
|
||||
app/resources/js/router/index.ts \
|
||||
app/routes/web.php \
|
||||
app/resources/js/layouts/AuthLayout.vue
|
||||
git commit -m "feat(auth): /legal/offer + /legal/privacy stub pages (closes A7)
|
||||
|
||||
Audit A7: the «Оферта» / «Политика» links in the AuthLayout footer were
|
||||
raw <a href> pointing at unrouted paths -> 404 via the SPA catch-all.
|
||||
Adds a single DRY LegalDocView served by /legal/:doc(offer|privacy),
|
||||
rendering an honest «document being finalized» stub (real legal text
|
||||
needs юр. редактура — реестр K3 / blocker Б-1). Footer links upgraded
|
||||
to <RouterLink> for SPA navigation.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final Acceptance — Plan A
|
||||
|
||||
After both tasks:
|
||||
|
||||
- [ ] Pest `--parallel` unchanged from baseline: `742/739/3sk/0` (no backend files touched — run `composer test:parallel` once to confirm).
|
||||
- [ ] Vitest: `92 files / 774 passed / 3 skipped / 0 failed` (net: −`RecoveryCodesView.spec.ts` +`LegalDocView.spec.ts` = 0 file delta, −4 +4 = 0 test delta vs baseline).
|
||||
- [ ] vue-tsc: `0 errors`.
|
||||
- [ ] ESLint: `0 errors`.
|
||||
- [ ] `grep -rn "RecoveryCodesView" app/` → no matches.
|
||||
- [ ] Lefthook pre-commit green on both commits.
|
||||
- [ ] 2 atomic commits on `main`; not pushed.
|
||||
|
||||
## Spec coverage (audit `2026-05-15-portal-audit-design.md`)
|
||||
|
||||
| Audit ID | Spec line | This plan |
|
||||
|---|---|---|
|
||||
| A2 | RecoveryCodesView continue button → redirect | Task 1 — superseded: page deleted (orphan, per user decision) |
|
||||
| A3 | RecoveryCodesView → real API | Task 1 — superseded: page deleted (real flow already in Settings → Безопасность) |
|
||||
| A7 | `/legal/offer` + `/legal/privacy` routes + stub | Task 2 |
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,542 +0,0 @@
|
||||
# Sprint 3A — Layout & Navigation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Закрыть аудит-эпики B1, B4, B5 — добавить «Напоминания» в основной сайдбар, 2 недостающих пункта в admin-сайдбар, и глобальный баннер активных impersonation-сессий.
|
||||
|
||||
**Architecture:** Чисто frontend-спринт (Vue 3 + Vuetify 3), 0 schema-delta, 0 backend-кода — все нужные API уже есть (`GET /api/admin/impersonation/active`) и все SPA-маршруты уже в роутере и `routes/web.php`. B1/B4 — правка декларативных nav-массивов. B5 — новый self-fetching компонент `ImpersonationBanner.vue` с polling 30 c (паттерн `usePolling`), встроенный в `AdminLayout`.
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup>`, Vuetify 3, vue-router 4, Vitest 4 + @vue/test-utils, TypeScript.
|
||||
|
||||
**Source:** [portal-wide audit spec §3 Sprint 3](../specs/2026-05-15-portal-audit-design.md) — эпики B1, B4, B5.
|
||||
|
||||
**Branch:** работать на feature-ветке (не `main`). Не пушить без явного запроса заказчика.
|
||||
|
||||
---
|
||||
|
||||
## Контекст и факты recon (важно для исполнителя)
|
||||
|
||||
- **B1** — `/reminders` уже зарегистрирован в роутере (`router/index.ts:171`, name `reminders`) и в `routes/web.php:230`. Эпик — только добавить пункт в nav-массив `AppSidebar.vue`.
|
||||
- **AppSidebar template НЕ рендерит `icon`** — Quiet Luxury redesign убрал иконки из основного сайдбара. Поле `icon` в `interface NavItem` оставлено для консистентности; значение косметическое, на рендер не влияет.
|
||||
- **B4** — `/admin/pricing-tiers` и `/admin/supplier-prices` уже в роутере (`router/index.ts:220,232`) и в `routes/web.php:236-237`. Эпик — только добавить 2 пункта в `navItems` массив `AdminLayout.vue`.
|
||||
- **AdminLayout РЕНДЕРИТ иконки** (`:prepend-icon="item.icon"`). Иконки идут через Lucide `IconSet` (`plugins/vuetify.ts`); **unmapped `mdi-*` → fallback `HelpCircle`** (`vuetify.ts:245`). Поэтому для B4 берём только icon'ы, присутствующие в 103-entry mapping: `mdi-tag-arrow-right` (→ Tag) и `mdi-currency-rub` (→ RussianRuble) — оба замаплены (`vuetify.ts:207,170`).
|
||||
- **B5 — реальность impersonation MVP:** saas-admin auth НЕ реализован, реального «входа как клиент» (переключения сессии) нет — `verify` лишь ставит `used_at`. «Активная сессия» = токен с `used_at != null AND session_ended_at == null`. `GET /api/admin/impersonation/active` возвращает **все** такие сессии глобально. Поэтому баннер — честный **глобальный индикатор** («активны impersonation-сессии: N»), а не «вы вошли как X». Это соответствует формулировке аудита B5 («глобальный индикатор», «полоса в AdminLayout») и TODO-комментарию в самом `AdminLayout.vue:11-12`.
|
||||
- Тесты проекта: `app/tests/Frontend/**/*.spec.ts`, vitest config `app/vitest.config.ts`, setup `app/tests/Frontend/setup.ts`. Все команды ниже — **из директории `app/`**.
|
||||
- Паттерн self-fetching компонента с mock'ом admin-API — см. `tests/Frontend/AdminIncidentsViewApi.spec.ts`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Ответственность | Действие |
|
||||
|---|---|---|
|
||||
| `app/resources/js/components/layout/AppSidebar.vue` | nav-дерево основного портала | Modify (Task 1) |
|
||||
| `app/tests/Frontend/AppSidebarRedesign.spec.ts` | тесты AppSidebar | Modify (Task 1) |
|
||||
| `app/resources/js/layouts/AdminLayout.vue` | layout админки + nav | Modify (Task 2 + Task 3) |
|
||||
| `app/tests/Frontend/AdminLayout.spec.ts` | тесты AdminLayout | Modify (Task 2 + Task 3) |
|
||||
| `app/resources/js/components/admin/ImpersonationBanner.vue` | self-fetching баннер активных сессий | Create (Task 3) |
|
||||
| `app/tests/Frontend/ImpersonationBanner.spec.ts` | тесты баннера | Create (Task 3) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: B1 — пункт «Напоминания» в основном сайдбаре
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/components/layout/AppSidebar.vue:37`
|
||||
- Test: `app/tests/Frontend/AppSidebarRedesign.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
В `app/tests/Frontend/AppSidebarRedesign.spec.ts` добавить новый тест внутри `describe('AppSidebar — redesigned shell', ...)` (после теста `'active nav-item has marker pseudo-element class'`, перед закрывающей `})` блока describe):
|
||||
|
||||
```ts
|
||||
it('содержит пункт «Напоминания» со ссылкой /reminders в группе «Работа»', async () => {
|
||||
const { wrapper } = await setup();
|
||||
const items = wrapper.findAll('a.ld-nav-item');
|
||||
const reminders = items.find((a) => a.text().includes('Напоминания'));
|
||||
expect(reminders).toBeDefined();
|
||||
expect(reminders!.attributes('href')).toBe('/reminders');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — убедиться, что падает**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/AppSidebarRedesign.spec.ts`
|
||||
Expected: FAIL — новый тест падает на `expect(reminders).toBeDefined()` (пункт отсутствует), остальные 4 теста PASS.
|
||||
|
||||
- [ ] **Step 3: Добавить пункт в nav-массив**
|
||||
|
||||
В `app/resources/js/components/layout/AppSidebar.vue` в группе `eyebrow: 'Работа'` добавить пункт после «Дашборд». Заменить:
|
||||
|
||||
```ts
|
||||
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
|
||||
{ title: 'Напоминания', icon: 'mdi-bell-outline', to: '/reminders' },
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
(`mdi-bell-outline` → `Bell` замаплен в `vuetify.ts:152`; на рендер в AppSidebar не влияет — поле косметическое.)
|
||||
|
||||
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/AppSidebarRedesign.spec.ts`
|
||||
Expected: PASS — все 5 тестов зелёные.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/layout/AppSidebar.vue app/tests/Frontend/AppSidebarRedesign.spec.ts
|
||||
git commit -m "feat(nav): AppSidebar — пункт «Напоминания» в группе «Работа» (audit B1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: B4 — «Тарифная сетка» + «Цены поставщиков» в admin-сайдбаре
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/layouts/AdminLayout.vue:27-33`
|
||||
- Test: `app/tests/Frontend/AdminLayout.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Обновить тест-роутер и тесты nav/breadcrumb**
|
||||
|
||||
В `app/tests/Frontend/AdminLayout.spec.ts` выполнить 4 правки.
|
||||
|
||||
**1a.** Добавить 2 маршрута в тест-роутер. Заменить:
|
||||
|
||||
```ts
|
||||
{ path: '/admin/billing', component: { template: '<div>billing</div>' } },
|
||||
{ path: '/admin/incidents', component: { template: '<div>incidents</div>' } },
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
{ path: '/admin/billing', component: { template: '<div>billing</div>' } },
|
||||
{ path: '/admin/pricing-tiers', component: { template: '<div>pricing-tiers</div>' } },
|
||||
{ path: '/admin/supplier-prices', component: { template: '<div>supplier-prices</div>' } },
|
||||
{ path: '/admin/incidents', component: { template: '<div>incidents</div>' } },
|
||||
```
|
||||
|
||||
**1b.** Заменить тест `'рендерит 5 nav-пунктов ...'` целиком:
|
||||
|
||||
```ts
|
||||
it('рендерит 7 nav-пунктов (Тенанты, Биллинг, Тарифная сетка, Цены поставщиков, Инциденты, Impersonation, Система)', async () => {
|
||||
const { wrapper } = await mountAdminLayout();
|
||||
const text = wrapper.text();
|
||||
['Тенанты', 'Биллинг', 'Тарифная сетка', 'Цены поставщиков', 'Инциденты', 'Impersonation', 'Система'].forEach(
|
||||
(label) => expect(text).toContain(label),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
**1c.** В тесте `'breadcrumb fallback на «Админка» ...'` заменить массив-исключений. Заменить:
|
||||
|
||||
```ts
|
||||
['Тенанты', 'Биллинг', 'Инциденты', 'Impersonation', 'Система'].forEach((title) => {
|
||||
expect(crumbText).not.toContain(title);
|
||||
});
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
['Тенанты', 'Биллинг', 'Тарифная сетка', 'Цены поставщиков', 'Инциденты', 'Impersonation', 'Система'].forEach(
|
||||
(title) => {
|
||||
expect(crumbText).not.toContain(title);
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**1d.** Добавить новый breadcrumb-тест после теста `'breadcrumb на /admin/billing показывает «Биллинг»'`:
|
||||
|
||||
```ts
|
||||
it('breadcrumb на /admin/pricing-tiers показывает «Тарифная сетка»', async () => {
|
||||
const { wrapper } = await mountAdminLayout('/admin/pricing-tiers');
|
||||
expect(wrapper.find('.crumb').text()).toContain('Тарифная сетка');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тесты — убедиться, что падают**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/AdminLayout.spec.ts`
|
||||
Expected: FAIL — тест `'рендерит 7 nav-пунктов ...'` падает (`text` не содержит «Тарифная сетка»/«Цены поставщиков»), новый breadcrumb-тест падает (`currentPageTitle` fallback → `'Админка'`).
|
||||
|
||||
- [ ] **Step 3: Добавить 2 пункта в `navItems`**
|
||||
|
||||
В `app/resources/js/layouts/AdminLayout.vue` заменить массив `navItems`:
|
||||
|
||||
```ts
|
||||
const navItems: NavItem[] = [
|
||||
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants', count: 142 },
|
||||
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
|
||||
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents', count: 3 },
|
||||
{ title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' },
|
||||
{ title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' },
|
||||
];
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
const navItems: NavItem[] = [
|
||||
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants', count: 142 },
|
||||
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
|
||||
{ title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' },
|
||||
{ title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' },
|
||||
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents', count: 3 },
|
||||
{ title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' },
|
||||
{ title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' },
|
||||
];
|
||||
```
|
||||
|
||||
(Порядок: новые пункты сразу после «Биллинг» — billing-смежные. `currentPageTitle` использует `route.path.startsWith(i.to)`; префиксных коллизий между admin-маршрутами нет. Иконки `mdi-tag-arrow-right`/`mdi-currency-rub` замаплены в `vuetify.ts`.)
|
||||
|
||||
- [ ] **Step 4: Запустить тесты — убедиться, что проходят**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/AdminLayout.spec.ts`
|
||||
Expected: PASS — все тесты AdminLayout зелёные (включая нетронутый `'показывает count-badge ... toHaveLength(2)'` — новые пункты без `count`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/layouts/AdminLayout.vue app/tests/Frontend/AdminLayout.spec.ts
|
||||
git commit -m "feat(admin): AdminLayout nav — Тарифная сетка + Цены поставщиков (audit B4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: B5 — глобальный баннер активных impersonation-сессий
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/resources/js/components/admin/ImpersonationBanner.vue`
|
||||
- Create: `app/tests/Frontend/ImpersonationBanner.spec.ts`
|
||||
- Modify: `app/resources/js/layouts/AdminLayout.vue` (импорт + размещение в `<v-main>`)
|
||||
- Modify: `app/tests/Frontend/AdminLayout.spec.ts` (stub нового компонента)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест компонента**
|
||||
|
||||
Создать `app/tests/Frontend/ImpersonationBanner.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import ImpersonationBanner from '../../resources/js/components/admin/ImpersonationBanner.vue';
|
||||
import type { ImpersonationActiveSession } from '../../resources/js/api/admin';
|
||||
|
||||
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
|
||||
return { ...orig, impersonationActive: vi.fn() };
|
||||
});
|
||||
|
||||
const adminApi = await import('../../resources/js/api/admin');
|
||||
|
||||
function makeSession(overrides: Partial<ImpersonationActiveSession> = {}): ImpersonationActiveSession {
|
||||
return {
|
||||
token_id: 1,
|
||||
tenant_id: 10,
|
||||
tenant_name: 'ООО Ромашка',
|
||||
requested_by: 7,
|
||||
reason: 'Диагностика проблемы с балансом по обращению клиента',
|
||||
sent_to_email: 'client@romashka.ru',
|
||||
used_at: '2026-05-16T08:00:00Z',
|
||||
expires_at: '2026-05-16T08:15:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mountBanner = () =>
|
||||
mount(ImpersonationBanner, {
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: {
|
||||
RouterLink: {
|
||||
props: ['to'],
|
||||
template: '<a :class="$attrs.class" :href="to"><slot /></a>',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('ImpersonationBanner', () => {
|
||||
it('вызывает impersonationActive на mount', async () => {
|
||||
vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([]);
|
||||
const wrapper = mountBanner();
|
||||
await flushPromises();
|
||||
expect(adminApi.impersonationActive).toHaveBeenCalledTimes(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('0 активных сессий — баннер не рендерится', async () => {
|
||||
vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([]);
|
||||
const wrapper = mountBanner();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="impersonation-banner"]').exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('1 активная сессия — баннер с именем тенанта + ссылка на /admin/impersonation', async () => {
|
||||
vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([makeSession({ tenant_name: 'ООО Ромашка' })]);
|
||||
const wrapper = mountBanner();
|
||||
await flushPromises();
|
||||
const banner = wrapper.find('[data-testid="impersonation-banner"]');
|
||||
expect(banner.exists()).toBe(true);
|
||||
expect(banner.text()).toContain('Активна impersonation-сессия');
|
||||
expect(banner.text()).toContain('ООО Ромашка');
|
||||
expect(wrapper.find('[data-testid="impersonation-banner-link"]').attributes('href')).toBe(
|
||||
'/admin/impersonation',
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('несколько активных сессий — баннер показывает счётчик', async () => {
|
||||
vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([
|
||||
makeSession({ token_id: 1 }),
|
||||
makeSession({ token_id: 2 }),
|
||||
makeSession({ token_id: 3 }),
|
||||
]);
|
||||
const wrapper = mountBanner();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="impersonation-banner"]').text()).toContain(
|
||||
'Активны impersonation-сессии: 3',
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('tenant_name=null — fallback на «тенант #id»', async () => {
|
||||
vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([
|
||||
makeSession({ tenant_name: null, tenant_id: 42 }),
|
||||
]);
|
||||
const wrapper = mountBanner();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="impersonation-banner"]').text()).toContain('тенант #42');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('ошибка impersonationActive — баннер не падает и остаётся скрыт', async () => {
|
||||
vi.mocked(adminApi.impersonationActive).mockRejectedValueOnce(new Error('500'));
|
||||
const wrapper = mountBanner();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="impersonation-banner"]').exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('polling — impersonationActive вызывается повторно через 30 с', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(adminApi.impersonationActive).mockResolvedValue([]);
|
||||
const wrapper = mountBanner();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(adminApi.impersonationActive).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(adminApi.impersonationActive).toHaveBeenCalledTimes(2);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — убедиться, что падает**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/ImpersonationBanner.spec.ts`
|
||||
Expected: FAIL — import не резолвится (`ImpersonationBanner.vue` ещё не создан).
|
||||
|
||||
- [ ] **Step 3: Создать компонент**
|
||||
|
||||
Создать `app/resources/js/components/admin/ImpersonationBanner.vue`:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Глобальный индикатор активных impersonation-сессий (audit B5 / Ю-1).
|
||||
*
|
||||
* Размещён в AdminLayout над <RouterView> — виден на всех /admin/* страницах.
|
||||
* На MVP saas-admin auth нет и реального переключения сессии нет, поэтому
|
||||
* показываем счётчик ВСЕХ активных сессий (impersonationActive() =
|
||||
* used_at != null AND session_ended_at == null). Polling 30 c — сессия может
|
||||
* стартовать/завершиться, пока админ остаётся в админке (AdminLayout
|
||||
* persistent, перемонтируется только <RouterView>).
|
||||
*
|
||||
* Если активных сессий 0 — компонент не рендерит ничего.
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { impersonationActive, type ImpersonationActiveSession } from '../../api/admin';
|
||||
import { usePolling } from '../../composables/usePolling';
|
||||
|
||||
const sessions = ref<ImpersonationActiveSession[]>([]);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
sessions.value = await impersonationActive();
|
||||
} catch {
|
||||
// Баннер не критичен — ошибку детально покажет AdminImpersonationView.
|
||||
// Сохраняем прежнее значение sessions, не падаем.
|
||||
}
|
||||
}
|
||||
|
||||
const count = computed(() => sessions.value.length);
|
||||
|
||||
const label = computed(() => {
|
||||
if (count.value === 1) {
|
||||
const s = sessions.value[0];
|
||||
return `Активна impersonation-сессия: ${s.tenant_name ?? `тенант #${s.tenant_id}`}`;
|
||||
}
|
||||
return `Активны impersonation-сессии: ${count.value}`;
|
||||
});
|
||||
|
||||
onMounted(load);
|
||||
usePolling(load, { intervalMs: 30_000 });
|
||||
|
||||
defineExpose({ sessions, load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="count > 0" class="impersonation-banner" role="status" data-testid="impersonation-banner">
|
||||
<v-icon size="16" class="impersonation-banner__icon">mdi-account-switch</v-icon>
|
||||
<span class="impersonation-banner__label">{{ label }}</span>
|
||||
<RouterLink
|
||||
to="/admin/impersonation"
|
||||
class="impersonation-banner__link"
|
||||
data-testid="impersonation-banner-link"
|
||||
>
|
||||
Открыть
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.impersonation-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #fff4e0;
|
||||
border-bottom: 1px solid #f0d8a8;
|
||||
color: #8a5a00;
|
||||
font-size: 13px;
|
||||
padding: 8px 24px;
|
||||
}
|
||||
.impersonation-banner__icon {
|
||||
color: #b87400;
|
||||
}
|
||||
.impersonation-banner__label {
|
||||
flex: 1;
|
||||
}
|
||||
.impersonation-banner__link {
|
||||
color: #0f6e56;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.impersonation-banner__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/ImpersonationBanner.spec.ts`
|
||||
Expected: PASS — все 7 тестов зелёные.
|
||||
|
||||
- [ ] **Step 5: Встроить баннер в AdminLayout + застабить в AdminLayout.spec**
|
||||
|
||||
**5a.** В `app/resources/js/layouts/AdminLayout.vue` добавить импорт. Заменить:
|
||||
|
||||
```ts
|
||||
import DevIndexBadge from '../components/DevIndexBadge.vue';
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
import DevIndexBadge from '../components/DevIndexBadge.vue';
|
||||
import ImpersonationBanner from '../components/admin/ImpersonationBanner.vue';
|
||||
```
|
||||
|
||||
**5b.** В `app/resources/js/layouts/AdminLayout.vue` разместить баннер в начале `<v-main>`. Заменить:
|
||||
|
||||
```html
|
||||
<v-main class="admin-main">
|
||||
<RouterView />
|
||||
</v-main>
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```html
|
||||
<v-main class="admin-main">
|
||||
<ImpersonationBanner />
|
||||
<RouterView />
|
||||
</v-main>
|
||||
```
|
||||
|
||||
**5c.** В `app/tests/Frontend/AdminLayout.spec.ts` застабить новый компонент (он сам делает API-вызов на mount — в тестах AdminLayout это не нужно; у баннера есть собственный spec). Заменить:
|
||||
|
||||
```ts
|
||||
stubs: { DevIndexBadge: true },
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
stubs: { DevIndexBadge: true, ImpersonationBanner: true },
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Запустить тесты AdminLayout + ImpersonationBanner — убедиться, что проходят**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/AdminLayout.spec.ts tests/Frontend/ImpersonationBanner.spec.ts`
|
||||
Expected: PASS — обе спеки зелёные.
|
||||
|
||||
- [ ] **Step 7: Полный sweep frontend-регрессии**
|
||||
|
||||
Run (из `app/`):
|
||||
|
||||
```bash
|
||||
npm run test:vue
|
||||
npm run type-check
|
||||
npm run lint:vue
|
||||
```
|
||||
|
||||
Expected: Vitest — все файлы зелёные (+1 файл `ImpersonationBanner.spec.ts`, +7 specs; AppSidebar +1 spec; AdminLayout +1 spec); `type-check` — 0 ошибок; `lint:vue` — 0 ошибок. Выписать фактические числа (passed/failed) в отчёт.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/admin/ImpersonationBanner.vue app/tests/Frontend/ImpersonationBanner.spec.ts app/resources/js/layouts/AdminLayout.vue app/tests/Frontend/AdminLayout.spec.ts
|
||||
git commit -m "feat(admin): ImpersonationBanner — глобальный индикатор активных сессий (audit B5)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- B1: пункт «Напоминания» виден в группе «Работа» основного сайдбара, ведёт на `/reminders`.
|
||||
- B4: «Тарифная сетка» + «Цены поставщиков» видны в admin-сайдбаре между «Биллинг» и «Инциденты», иконки рендерятся (не `HelpCircle`-fallback), breadcrumb на этих страницах корректен.
|
||||
- B5: при наличии активных impersonation-сессий в AdminLayout сверху виден баннер со счётчиком и ссылкой на `/admin/impersonation`; при 0 сессий баннер скрыт; данные обновляются раз в 30 c.
|
||||
- `npm run test:vue` — 0 failed; `npm run type-check` — 0; `npm run lint:vue` — 0.
|
||||
- 3 атомарных коммита (B1 / B4 / B5), каждый — после прохождения своих тестов.
|
||||
- Без push (до явного «пуш» от заказчика).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (выполнено при написании плана)
|
||||
|
||||
**Spec coverage:** B1 → Task 1; B4 → Task 2; B5 → Task 3. Все 3 эпика Sprint 3A «Layout & Navigation» покрыты.
|
||||
|
||||
**Placeholder scan:** плейсхолдеров нет — весь код приведён полностью (тесты, компонент, точечные Edit'ы old→new).
|
||||
|
||||
**Type consistency:** `ImpersonationActiveSession` используется из `api/admin.ts` без изменения интерфейса; `usePolling(loader, { intervalMs })` — сигнатура соответствует `composables/usePolling.ts`; `interface NavItem` в AppSidebar/AdminLayout не меняется (новые элементы используют существующие поля `title`/`icon`/`to`/`count`).
|
||||
|
||||
**Recon-риски проверены:** маршруты `/reminders`, `/admin/pricing-tiers`, `/admin/supplier-prices` подтверждены в роутере и `routes/web.php` (есть `Route::fallback`); иконки B4 подтверждены в Lucide-mapping; polling-тест использует `advanceTimersByTimeAsync` (а не `flushPromises` под fake-таймерами).
|
||||
@@ -1,823 +0,0 @@
|
||||
# Sprint 3B — Dashboard & Deep-links Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Закрыть аудит-эпики C1+J3 (живой дашборд через новый backend-эндпоинт) и C8+F3 (deep-link `/deals?openId=` из напоминаний и колокольчика).
|
||||
|
||||
**Architecture:** J3 — новый `DashboardController::summary` с агрегацией по `deals` + `tenants` (RLS-обёртка `SET LOCAL app.current_tenant_id`, паттерн `DealController`). C1 — `DashboardView` фетчит endpoint и пробрасывает данные в уже-prop-driven компоненты (`DashboardKpiRow`/`DashboardBalance`/`ActivityChart`/`FunnelChart`), при ошибке — fallback на mock. C8/F3 — три точки навигации переводятся на `query: { openId }`, а `DealsView` читает `route.query.openId` и открывает drawer найденной сделки.
|
||||
|
||||
**Tech Stack:** PHP 8.3 + Laravel 13, PostgreSQL 16 (партиционированная `deals`), Pest 4; Vue 3 `<script setup>` + Vuetify 3, vue-router 4, Vitest 4 + @vue/test-utils, TypeScript.
|
||||
|
||||
**Source:** [portal-wide audit spec §3 Sprint 3](../specs/2026-05-15-portal-audit-design.md) — эпики C1, J3, C8, F3.
|
||||
|
||||
**Branch:** feature-ветка от origin/main `65381f2` (Sprint 3A уже запушен). Не пушить без явного запроса заказчика.
|
||||
|
||||
---
|
||||
|
||||
## Контекст и факты recon
|
||||
|
||||
- **J3 — эндпоинта нет.** `routes/web.php` имеет только `Route::view('/dashboard','welcome')`; `DashboardController` отсутствует. Паттерн контроллера — `DealController` (`app/app/Http/Controllers/Api/DealController.php`): `DB::transaction` + `DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId)`, `tenant_id` query-параметром (MVP, без auth-middleware), defense-in-depth `where('tenant_id', …)`.
|
||||
- **Схема (`db/schema.sql`):** `deals` — `tenant_id`, `project_id`, `status` (slug), `received_at TIMESTAMPTZ` (ключ партиционирования), `is_test BOOLEAN`, `deleted_at` (soft-delete). `tenants` — `balance_rub DECIMAL(12,2)`, `balance_leads INT`, `limits JSONB` (`{"max_projects":10,...}`). Статус оплаты — slug `paid`.
|
||||
- **`Project` модель** — scope `active()` = `whereNull('archived_at')` (НЕ фильтрует `is_active`). «Активные проекты» дашборда = `archived_at IS NULL AND is_active = true`.
|
||||
- **C1 — все 4 dashboard-компонента уже prop-driven:** `DashboardKpiRow` (prop `kpis: Kpi[]`, тип экспортируется из компонента), `DashboardBalance` (prop `balance: Balance`, тип экспортируется), `ActivityChart` (props `points: number[]`, `labels: string[]`, `max: number`), `FunnelChart` (prop `counts: Record<string, number>`). DashboardView их не трогает — только фетч + проброс.
|
||||
- **C8** — `RemindersView.vue:70-73`: `openDeal(dealId)` → `void dealId; router.push('/deals')` (явно «на MVP без deep-link»).
|
||||
- **F3** — `AppTopbar.vue:56-62`: `handleNotificationClick(id, dealId)` → `markRead` + `if (dealId !== null) router.push('/deals')`. Notification имеет `deal_id: number | null` (`api/notifications.ts:26`).
|
||||
- **Deep-link consumer** — `DealsView.vue` НЕ читает `route.query`. Имеет `openDeal(deal: MockDeal)` → `selectedDeal.value = deal; drawerOpen.value = true`. `dealsState` грузится async (`loadDeals`, limit 200). `useRoute` сейчас не импортируется.
|
||||
- Тесты: backend — `app/tests/Feature/**/*.php` (Pest); frontend — `app/tests/Frontend/**/*.spec.ts` (Vitest). Команды backend — из `app/` (`composer test`, `php artisan test`); frontend — из `app/` (`npx vitest run …`).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Ответственность | Действие |
|
||||
|---|---|---|
|
||||
| `app/app/Http/Controllers/Api/DashboardController.php` | агрегат дашборда | Create (Task 1) |
|
||||
| `app/routes/web.php` | маршрут `/api/dashboard/summary` | Modify (Task 1) |
|
||||
| `app/tests/Feature/DashboardSummaryTest.php` | Pest-тесты эндпоинта | Create (Task 1) |
|
||||
| `app/resources/js/api/dashboard.ts` | API-клиент дашборда | Create (Task 2) |
|
||||
| `app/resources/js/views/DashboardView.vue` | фетч + проброс props | Modify (Task 2) |
|
||||
| `app/tests/Frontend/DashboardView.spec.ts` | тесты DashboardView | Modify (Task 2) |
|
||||
| `app/resources/js/views/DealsView.vue` | чтение `route.query.openId` → drawer | Modify (Task 3) |
|
||||
| `app/resources/js/views/RemindersView.vue` | deep-link openDeal | Modify (Task 3) |
|
||||
| `app/resources/js/components/layout/AppTopbar.vue` | deep-link bell | Modify (Task 3) |
|
||||
| `app/tests/Frontend/DealsView.spec.ts` | тест openId-drawer | Modify (Task 3) |
|
||||
| `app/tests/Frontend/RemindersView.spec.ts` | тест deep-link | Modify (Task 3) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: J3 — backend `GET /api/dashboard/summary`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Http/Controllers/Api/DashboardController.php`
|
||||
- Modify: `app/routes/web.php`
|
||||
- Test: `app/tests/Feature/DashboardSummaryTest.php`
|
||||
|
||||
**Endpoint contract** — `GET /api/dashboard/summary?tenant_id=N&range=today|7d|30d` (range default `7d`):
|
||||
|
||||
```json
|
||||
{
|
||||
"range": "7d",
|
||||
"leads_received": { "value": 247, "delta_pct": 12.3, "delta_dir": "up" },
|
||||
"conversion": { "value": 18.4, "delta_pp": 2.1, "delta_dir": "up" },
|
||||
"active_projects":{ "active": 8, "limit": 10 },
|
||||
"balance": { "amount_rub": "14250.00", "runway_days": 4, "runway_leads": 285 },
|
||||
"activity": { "points": [3,5,2,8,6,9,4], "labels": ["сб","вс","пн","вт","ср","чт","сегодня"], "max": 10 },
|
||||
"funnel": { "new": 18, "paid": 45, ... }
|
||||
}
|
||||
```
|
||||
|
||||
`delta_dir` ∈ `up|down|neutral`. Окна: `today` = [startOfDay, now], `7d` = [now−7d, now], `30d` = [now−30d, now]; предыдущее окно — равной длины непосредственно перед текущим. Все агрегаты — `tenant_id` + `deleted_at IS NULL` + `is_test = false`. `funnel` — текущий снимок (вне окна). `runway_leads` = `tenants.balance_leads`; `runway_days` = `floor(balance_leads / avgDailyLeads7d)` (avgDaily = leads за 7д / 7; при avgDaily=0 → 0). `activity` — 7 daily-бакетов по `received_at` в MSK; `max` = `max(10, ceil(maxPoint/10)*10)`.
|
||||
|
||||
- [ ] **Step 1: Изучить factory-паттерн существующих Feature-тестов**
|
||||
|
||||
Прочитать один существующий тест в `app/tests/Feature/` который создаёт `Tenant` + `Deal` (например любой `Deal*Test.php` или supplier-тест) — зафиксировать, как создаются tenant/project/deal (фабрики `Tenant::factory()`, `Project::factory()`, `Deal::factory()` или прямые `::create`), как тест выставляет `received_at`/`status`, и как пользуется RLS (`postgres` superuser на dev — BYPASSRLS). Тест Task 1 должен использовать ровно тот же механизм. Это не плейсхолдер — это обязательная сверка фактического API фабрик перед написанием теста.
|
||||
|
||||
- [ ] **Step 2: Написать падающий Pest-тест**
|
||||
|
||||
Создать `app/tests/Feature/DashboardSummaryTest.php`. Минимум 6 тест-кейсов (синтаксис Pest mirror `tests/Feature/`-паттерна из Step 1):
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
|
||||
// helper: создать сделку с заданными status/received_at для тенанта/проекта.
|
||||
// Реализовать через фабрику/способ, зафиксированный в Step 1.
|
||||
|
||||
it('422 без tenant_id', function () {
|
||||
$this->getJson('/api/dashboard/summary')->assertStatus(422);
|
||||
});
|
||||
|
||||
it('404 для несуществующего тенанта', function () {
|
||||
$this->getJson('/api/dashboard/summary?tenant_id=999999')->assertStatus(404);
|
||||
});
|
||||
|
||||
it('возвращает структуру summary с range по умолчанию 7d', function () {
|
||||
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10], 'balance_rub' => '14250.00', 'balance_leads' => 285]);
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('range', '7d')
|
||||
->assertJsonStructure([
|
||||
'range',
|
||||
'leads_received' => ['value', 'delta_pct', 'delta_dir'],
|
||||
'conversion' => ['value', 'delta_pp', 'delta_dir'],
|
||||
'active_projects' => ['active', 'limit'],
|
||||
'balance' => ['amount_rub', 'runway_days', 'runway_leads'],
|
||||
'activity' => ['points', 'labels', 'max'],
|
||||
'funnel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leads_received считает только сделки окна, без deleted и is_test', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
// 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад)
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(2));
|
||||
makeDeal($tenant, $project, 'paid', now()->subDays(3));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1), deletedAt: now());
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1), isTest: true);
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(8));
|
||||
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=7d")
|
||||
->assertOk()
|
||||
->assertJsonPath('leads_received.value', 3);
|
||||
});
|
||||
|
||||
it('conversion = доля статуса paid в окне', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDeal($tenant, $project, 'paid', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
// 1 paid из 4 → 25.0%
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('conversion.value', 25.0);
|
||||
});
|
||||
|
||||
it('active_projects считает archived_at IS NULL AND is_active=true + limit из limits', function () {
|
||||
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now(), 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => false]);
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('active_projects.active', 2)
|
||||
->assertJsonPath('active_projects.limit', 10);
|
||||
});
|
||||
|
||||
it('funnel группирует живые сделки по статусу', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'paid', now()->subDays(1));
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('funnel.new', 2)
|
||||
->assertJsonPath('funnel.paid', 1);
|
||||
});
|
||||
|
||||
it('activity возвращает 7 точек и 7 меток', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonCount(7, 'activity.points')
|
||||
->assertJsonCount(7, 'activity.labels');
|
||||
});
|
||||
```
|
||||
|
||||
Реализовать `makeDeal($tenant, $project, $status, $receivedAt, $deletedAt = null, $isTest = false)` как локальный helper в файле теста, опираясь на фабрику из Step 1. `deals` партиционирована по `received_at` — убедиться, что партиция для тестовых дат существует (если тестовый bootstrap её не создаёт — использовать `received_at` в пределах мая-июня 2026, для которых партиции в `schema.sql` уже есть, либо вызвать `partitions:create-months`).
|
||||
|
||||
- [ ] **Step 3: Запустить тест — убедиться, что падает**
|
||||
|
||||
Run (из `app/`): `php artisan test --filter=DashboardSummaryTest`
|
||||
Expected: FAIL — маршрут `/api/dashboard/summary` не существует (404 на всех кейсах).
|
||||
|
||||
- [ ] **Step 4: Добавить маршрут**
|
||||
|
||||
В `app/routes/web.php` рядом с другими `/api/*` (например после блока deals) добавить:
|
||||
|
||||
```php
|
||||
// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). На MVP без
|
||||
// auth-middleware (tenant_id параметром); production: middleware('auth:sanctum','tenant').
|
||||
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Реализовать DashboardController**
|
||||
|
||||
Создать `app/app/Http/Controllers/Api/DashboardController.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Дашборд — агрегат для DashboardView (audit C1/J3).
|
||||
*
|
||||
* GET /api/dashboard/summary?tenant_id={id}&range=today|7d|30d
|
||||
*
|
||||
* На MVP без auth-middleware (tenant_id параметром, как DealController).
|
||||
* Production: middleware('auth:sanctum','tenant') → tenant_id из user.
|
||||
*
|
||||
* Все агрегаты — tenant-scoped, deleted_at IS NULL, is_test=false.
|
||||
* RLS-обёртка SET LOCAL app.current_tenant_id (PgBouncer-safe), как DealController.
|
||||
*/
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
private const RU_WEEKDAYS = ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'];
|
||||
|
||||
public function summary(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);
|
||||
}
|
||||
|
||||
$range = in_array($request->query('range'), ['today', '7d', '30d'], true)
|
||||
? (string) $request->query('range')
|
||||
: '7d';
|
||||
|
||||
$now = CarbonImmutable::now();
|
||||
[$windowStart, $prevStart] = match ($range) {
|
||||
'today' => [$now->startOfDay(), $now->startOfDay()->subDay()],
|
||||
'30d' => [$now->subDays(30), $now->subDays(60)],
|
||||
default => [$now->subDays(7), $now->subDays(14)],
|
||||
};
|
||||
|
||||
$data = DB::transaction(function () use ($tenantId, $tenant, $now, $range, $windowStart, $prevStart) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$base = fn () => DB::table('deals')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_test', false);
|
||||
|
||||
// --- leads received: текущее + предыдущее окно ---
|
||||
$curLeads = (clone $base())->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevLeads = (clone $base())->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
|
||||
// --- conversion: % статуса 'paid' в окне ---
|
||||
$curPaid = (clone $base())->where('status', 'paid')
|
||||
->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevPaid = (clone $base())->where('status', 'paid')
|
||||
->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
$curConv = $curLeads > 0 ? round($curPaid / $curLeads * 100, 1) : 0.0;
|
||||
$prevConv = $prevLeads > 0 ? round($prevPaid / $prevLeads * 100, 1) : 0.0;
|
||||
|
||||
// --- active projects ---
|
||||
$activeProjects = DB::table('projects')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('archived_at')
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
$maxProjects = (int) (($tenant->limits['max_projects'] ?? 0));
|
||||
|
||||
// --- activity: 7 daily-бакетов по received_at (MSK) ---
|
||||
$activityStart = $now->subDays(6)->startOfDay();
|
||||
$byDay = (clone $base())
|
||||
->where('received_at', '>=', $activityStart)
|
||||
->selectRaw("to_char((received_at AT TIME ZONE 'Europe/Moscow')::date, 'YYYY-MM-DD') AS d, COUNT(*) AS c")
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
$points = [];
|
||||
$labels = [];
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$day = $now->subDays($i);
|
||||
$key = $day->format('Y-m-d');
|
||||
$points[] = (int) ($byDay[$key] ?? 0);
|
||||
$labels[] = $i === 0 ? 'сегодня' : self::RU_WEEKDAYS[(int) $day->format('w')];
|
||||
}
|
||||
$maxPoint = $points === [] ? 0 : max($points);
|
||||
$axisMax = max(10, (int) (ceil($maxPoint / 10) * 10));
|
||||
|
||||
// --- funnel: текущий снимок по статусам ---
|
||||
$funnel = (clone $base())
|
||||
->selectRaw('status, COUNT(*) AS c')
|
||||
->groupBy('status')
|
||||
->pluck('c', 'status')
|
||||
->map(fn ($c) => (int) $c)
|
||||
->toArray();
|
||||
|
||||
// --- runway ---
|
||||
$avgDaily = $curLeads / 7.0; // средний дневной приток за 7д окно
|
||||
$balanceLeads = (int) ($tenant->balance_leads ?? 0);
|
||||
$runwayDays = $avgDaily > 0 ? (int) floor($balanceLeads / $avgDaily) : 0;
|
||||
|
||||
return [
|
||||
'range' => $range,
|
||||
'leads_received' => self::deltaBlock($curLeads, $prevLeads, 'delta_pct', self::pctDelta($curLeads, $prevLeads)),
|
||||
'conversion' => self::deltaBlock($curConv, $prevConv, 'delta_pp', round($curConv - $prevConv, 1)),
|
||||
'active_projects' => ['active' => $activeProjects, 'limit' => $maxProjects],
|
||||
'balance' => [
|
||||
'amount_rub' => (string) $tenant->balance_rub,
|
||||
'runway_days' => $runwayDays,
|
||||
'runway_leads' => $balanceLeads,
|
||||
],
|
||||
'activity' => ['points' => $points, 'labels' => $labels, 'max' => $axisMax],
|
||||
'funnel' => (object) $funnel,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
/** Процентная дельта current vs previous; 0.0 если previous=0. */
|
||||
private static function pctDelta(float $cur, float $prev): float
|
||||
{
|
||||
return $prev > 0 ? round(($cur - $prev) / $prev * 100, 1) : 0.0;
|
||||
}
|
||||
|
||||
/** Блок {value, <deltaKey>, delta_dir}. */
|
||||
private static function deltaBlock(float $value, float $prev, string $deltaKey, float $delta): array
|
||||
{
|
||||
$dir = $value > $prev ? 'up' : ($value < $prev ? 'down' : 'neutral');
|
||||
|
||||
return ['value' => $value, $deltaKey => $delta, 'delta_dir' => $dir];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Запустить тест — убедиться, что проходит**
|
||||
|
||||
Run (из `app/`): `php artisan test --filter=DashboardSummaryTest`
|
||||
Expected: PASS — все ≥7 кейсов зелёные. Если падает на партициях `deals` — перенести тестовые `received_at` в существующий партиционный диапазон или прогнать `php artisan partitions:create-months`.
|
||||
|
||||
- [ ] **Step 7: Проверка стиля и статанализа**
|
||||
|
||||
Run (из `app/`): `composer pint` и `composer stan`
|
||||
Expected: Pint — без правок (или авто-fix применён), Larastan — 0 ошибок (при новых false-positive по `Request::query` — добавить запись в `phpstan-baseline.neon` через `--generate-baseline`, как принято в проекте).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Controllers/Api/DashboardController.php app/routes/web.php app/tests/Feature/DashboardSummaryTest.php app/phpstan-baseline.neon
|
||||
git commit -m "feat(dashboard): GET /api/dashboard/summary — агрегат KPI/баланса/активности (audit J3)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
(`phpstan-baseline.neon` добавлять только если он реально изменён.) Lefthook pre-commit прогонит pint/larastan/squawk/gitleaks — если упадёт, чинить причину, не `--no-verify`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: C1 — DashboardView на real API
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/resources/js/api/dashboard.ts`
|
||||
- Modify: `app/resources/js/views/DashboardView.vue`
|
||||
- Test: `app/tests/Frontend/DashboardView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест API-клиента + view**
|
||||
|
||||
Создать `app/resources/js/api/dashboard.ts` пока НЕ создаём — сначала тест. Полностью переписать `app/tests/Frontend/DashboardView.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import DashboardView from '../../resources/js/views/DashboardView.vue';
|
||||
import type { DashboardSummary } from '../../resources/js/api/dashboard';
|
||||
|
||||
vi.mock('../../resources/js/api/dashboard', () => ({
|
||||
getDashboardSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
const dashboardApi = await import('../../resources/js/api/dashboard');
|
||||
|
||||
function makeSummary(overrides: Partial<DashboardSummary> = {}): DashboardSummary {
|
||||
return {
|
||||
range: '7d',
|
||||
leads_received: { value: 247, delta_pct: 12.3, delta_dir: 'up' },
|
||||
conversion: { value: 18.4, delta_pp: 2.1, delta_dir: 'up' },
|
||||
active_projects: { active: 8, limit: 10 },
|
||||
balance: { amount_rub: '14250.00', runway_days: 4, runway_leads: 285 },
|
||||
activity: { points: [3, 5, 2, 8, 6, 9, 4], labels: ['сб', 'вс', 'пн', 'вт', 'ср', 'чт', 'сегодня'], max: 10 },
|
||||
funnel: { new: 18, paid: 45 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mountView = () => {
|
||||
setActivePinia(createPinia());
|
||||
return mount(DashboardView, { global: { plugins: [createVuetify()] } });
|
||||
};
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('DashboardView.vue ↔ /api/dashboard/summary', () => {
|
||||
it('getDashboardSummary вызывается на mount', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(makeSummary());
|
||||
mountView();
|
||||
await flushPromises();
|
||||
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('успех — KPI и баланс из API видны', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(
|
||||
makeSummary({ balance: { amount_rub: '99000.00', runway_days: 9, runway_leads: 500 } }),
|
||||
);
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Получено лидов');
|
||||
expect(text).toContain('Конверсия в оплату');
|
||||
expect(text).toContain('Активные проекты');
|
||||
expect(text).toContain('Баланс');
|
||||
expect(text).toContain('99 000');
|
||||
});
|
||||
|
||||
it('ошибка API — fallback на mock, view не падает', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockRejectedValueOnce(new Error('500'));
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('Получено лидов');
|
||||
expect(wrapper.find('.runway-fill').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('смена range перезапрашивает summary', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValue(makeSummary());
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(1);
|
||||
(wrapper.vm as unknown as { range: string }).range = '30d';
|
||||
await flushPromises();
|
||||
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — убедиться, что падает**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/DashboardView.spec.ts`
|
||||
Expected: FAIL — `api/dashboard.ts` не существует (import не резолвится).
|
||||
|
||||
- [ ] **Step 3: Создать API-клиент**
|
||||
|
||||
Создать `app/resources/js/api/dashboard.ts`:
|
||||
|
||||
```ts
|
||||
import { apiClient } from './client';
|
||||
|
||||
/**
|
||||
* API-клиент дашборда (audit C1/J3). Эндпоинт GET /api/dashboard/summary.
|
||||
* На MVP без auth — tenant_id параметром (на prod возьмётся из middleware).
|
||||
*/
|
||||
|
||||
export type DeltaDir = 'up' | 'down' | 'neutral';
|
||||
export type DashboardRange = 'today' | '7d' | '30d';
|
||||
|
||||
export interface DashboardSummary {
|
||||
range: string;
|
||||
leads_received: { value: number; delta_pct: number; delta_dir: DeltaDir };
|
||||
conversion: { value: number; delta_pp: number; delta_dir: DeltaDir };
|
||||
active_projects: { active: number; limit: number };
|
||||
balance: { amount_rub: string; runway_days: number; runway_leads: number };
|
||||
activity: { points: number[]; labels: string[]; max: number };
|
||||
funnel: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function getDashboardSummary(tenantId: number, range: DashboardRange): Promise<DashboardSummary> {
|
||||
const { data } = await apiClient.get<DashboardSummary>('/api/dashboard/summary', {
|
||||
params: { tenant_id: tenantId, range },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Переписать DashboardView на фетч + проброс**
|
||||
|
||||
Заменить `<script setup>` в `app/resources/js/views/DashboardView.vue` (template/charts row остаются; KPI-row и balance теперь из reactive-state). Полный новый `<script setup>`:
|
||||
|
||||
```ts
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Дашборд — стартовая страница. Audit C1/J3: KPI/баланс/активность/воронка
|
||||
* грузятся из GET /api/dashboard/summary; при ошибке — fallback на mock,
|
||||
* чтобы UI оставался работоспособным (dev / отсутствие backend).
|
||||
*/
|
||||
import { ref, watch } from 'vue';
|
||||
import ActivityChart from '../components/charts/ActivityChart.vue';
|
||||
import FunnelChart from '../components/charts/FunnelChart.vue';
|
||||
import DashboardPageHead from '../components/dashboard/DashboardPageHead.vue';
|
||||
import DashboardKpiRow, { type Kpi } from '../components/dashboard/DashboardKpiRow.vue';
|
||||
import DashboardBalance, { type Balance } from '../components/dashboard/DashboardBalance.vue';
|
||||
import { getDashboardSummary, type DashboardRange, type DashboardSummary } from '../api/dashboard';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const range = ref<DashboardRange | 'custom'>('7d');
|
||||
|
||||
// runwayMax — display-константа полосы (7 сегментов), не из API.
|
||||
const RUNWAY_MAX = 7;
|
||||
|
||||
// Mock-fallback — UI работоспособен без backend (dev / 500 / нет auth).
|
||||
const MOCK_KPIS: Kpi[] = [
|
||||
{ label: 'Получено лидов', value: '247', delta: { dir: 'up', text: '12.3%' }, sub: 'vs предыдущий период' },
|
||||
{ label: 'Конверсия в оплату', value: '18.4', unit: '%', delta: { dir: 'up', text: '2.1pp' }, sub: 'vs предыдущий период' },
|
||||
{ label: 'Активные проекты', value: '8', unit: '/ 10', delta: { dir: 'neutral', text: '' }, sub: 'лимит тарифа' },
|
||||
];
|
||||
const MOCK_BALANCE: Balance = { amount: '14 250', runwayDays: 4, runwayMax: RUNWAY_MAX, runwayLeads: 285 };
|
||||
|
||||
const kpis = ref<Kpi[]>(MOCK_KPIS);
|
||||
const balance = ref<Balance>(MOCK_BALANCE);
|
||||
const activityPoints = ref<number[]>([16, 31, 27, 47, 39, 56, 50]);
|
||||
const activityLabels = ref<string[]>(['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'сегодня']);
|
||||
const activityMax = ref(60);
|
||||
const funnelCounts = ref<Record<string, number> | undefined>(undefined);
|
||||
const fetchError = ref(false);
|
||||
|
||||
/** Форматирует число с пробелами-разделителями тысяч ('14250.00' → '14 250'). */
|
||||
function formatRub(raw: string): string {
|
||||
const int = Math.round(parseFloat(raw)).toString();
|
||||
return int.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
}
|
||||
|
||||
function applySummary(s: DashboardSummary): void {
|
||||
kpis.value = [
|
||||
{
|
||||
label: 'Получено лидов',
|
||||
value: String(s.leads_received.value),
|
||||
delta: { dir: s.leads_received.delta_dir, text: `${s.leads_received.delta_pct}%` },
|
||||
sub: 'vs предыдущий период',
|
||||
},
|
||||
{
|
||||
label: 'Конверсия в оплату',
|
||||
value: String(s.conversion.value),
|
||||
unit: '%',
|
||||
delta: { dir: s.conversion.delta_dir, text: `${s.conversion.delta_pp}pp` },
|
||||
sub: 'vs предыдущий период',
|
||||
},
|
||||
{
|
||||
label: 'Активные проекты',
|
||||
value: String(s.active_projects.active),
|
||||
unit: `/ ${s.active_projects.limit}`,
|
||||
delta: { dir: 'neutral', text: '' },
|
||||
sub: 'лимит тарифа',
|
||||
},
|
||||
];
|
||||
balance.value = {
|
||||
amount: formatRub(s.balance.amount_rub),
|
||||
runwayDays: Math.min(s.balance.runway_days, RUNWAY_MAX),
|
||||
runwayMax: RUNWAY_MAX,
|
||||
runwayLeads: s.balance.runway_leads,
|
||||
};
|
||||
activityPoints.value = s.activity.points;
|
||||
activityLabels.value = s.activity.labels;
|
||||
activityMax.value = s.activity.max;
|
||||
funnelCounts.value = s.funnel;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const tenantId = auth.user?.tenant_id;
|
||||
if (!tenantId || range.value === 'custom') return;
|
||||
try {
|
||||
applySummary(await getDashboardSummary(tenantId, range.value));
|
||||
fetchError.value = false;
|
||||
} catch {
|
||||
fetchError.value = true; // оставляем последнее значение / mock
|
||||
}
|
||||
}
|
||||
|
||||
watch(range, load);
|
||||
load();
|
||||
</script>
|
||||
```
|
||||
|
||||
Шаблон `<template>` менять минимально — заменить статичные `:kpis="kpis"` / `:balance="balance"` на reactive-ref'ы (Vue разворачивает `.value` в шаблоне автоматически, синтаксис `:kpis="kpis"` не меняется) и пробросить чарт-props + funnel + degradation-alert. Заменить блок `<v-row class="charts-row …">` на:
|
||||
|
||||
```html
|
||||
<v-alert
|
||||
v-if="fetchError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-3"
|
||||
data-testid="dashboard-fetch-error"
|
||||
>
|
||||
Не удалось обновить данные дашборда — показаны последние известные значения.
|
||||
</v-alert>
|
||||
|
||||
<v-row class="charts-row mt-4">
|
||||
<v-col cols="12" md="7">
|
||||
<ActivityChart :points="activityPoints" :labels="activityLabels" :max="activityMax" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="5">
|
||||
<FunnelChart :counts="funnelCounts" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
```
|
||||
|
||||
(`FunnelChart` prop `counts` опциональный — `undefined` оставит его mock-default; при успехе API передаст реальные counts.)
|
||||
|
||||
- [ ] **Step 5: Запустить тест — убедиться, что проходит**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/DashboardView.spec.ts`
|
||||
Expected: PASS — 4 теста зелёные.
|
||||
|
||||
- [ ] **Step 6: type-check + lint**
|
||||
|
||||
Run (из `app/`): `npm run type-check` и `npm run lint:vue`
|
||||
Expected: 0 ошибок. `ActivityChart` prop `points` non-optional при передаче — ок; `FunnelChart` `counts?: Record<string,number>` принимает `undefined`.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/api/dashboard.ts app/resources/js/views/DashboardView.vue app/tests/Frontend/DashboardView.spec.ts
|
||||
git commit -m "feat(dashboard): DashboardView на real API /api/dashboard/summary (audit C1)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: C8 + F3 — deep-link `/deals?openId=`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/DealsView.vue`
|
||||
- Modify: `app/resources/js/views/RemindersView.vue`
|
||||
- Modify: `app/resources/js/components/layout/AppTopbar.vue`
|
||||
- Test: `app/tests/Frontend/DealsView.spec.ts`, `app/tests/Frontend/RemindersView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающие тесты**
|
||||
|
||||
**1a.** В `app/tests/Frontend/DealsView.spec.ts` добавить тест: при `route.query.openId` совпадающем с id сделки в `dealsState` — drawer открыт. Использовать установленный в файле паттерн mount'а DealsView с роутером; если файл монтирует без роутера — добавить memory-router с маршрутом `/deals` и `push('/deals?openId=<id>')` до mount. Тест (адаптировать имена под фактический mount-helper файла):
|
||||
|
||||
```ts
|
||||
it('route.query.openId открывает drawer соответствующей сделки', async () => {
|
||||
// mount DealsView на /deals?openId=<существующий id из MOCK_DEALS>
|
||||
// после loadDeals() (flushPromises) — drawerOpen=true, selectedDeal.id === openId
|
||||
const openId = MOCK_DEALS[0].id;
|
||||
const wrapper = await mountDealsViewAt(`/deals?openId=${openId}`);
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { drawerOpen: boolean; selectedDeal: { id: number } | null };
|
||||
expect(vm.drawerOpen).toBe(true);
|
||||
expect(vm.selectedDeal?.id).toBe(openId);
|
||||
});
|
||||
|
||||
it('openId не найден среди сделок — drawer не открывается, без ошибки', async () => {
|
||||
const wrapper = await mountDealsViewAt('/deals?openId=99999999');
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { drawerOpen: boolean };
|
||||
expect(vm.drawerOpen).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
`drawerOpen` и `selectedDeal` добавить в `defineExpose` DealsView (Step 3).
|
||||
|
||||
**1b.** В `app/tests/Frontend/RemindersView.spec.ts` добавить тест: `openDeal(42)` вызывает `router.push` с `{ path: '/deals', query: { openId: 42 } }`. Использовать `vi.spyOn(router, 'push')` по паттерну файла.
|
||||
|
||||
- [ ] **Step 2: Запустить тесты — убедиться, что падают**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/DealsView.spec.ts tests/Frontend/RemindersView.spec.ts`
|
||||
Expected: FAIL — DealsView не реагирует на `openId`; RemindersView пушит `/deals` без query.
|
||||
|
||||
- [ ] **Step 3: DealsView — читать `route.query.openId`**
|
||||
|
||||
В `app/resources/js/views/DealsView.vue`:
|
||||
|
||||
**3a.** В импортах добавить `useRoute`:
|
||||
|
||||
```ts
|
||||
import { useRoute } from 'vue-router';
|
||||
```
|
||||
|
||||
и в `<script setup>` рядом с другими const'ами:
|
||||
|
||||
```ts
|
||||
const route = useRoute();
|
||||
```
|
||||
|
||||
**3b.** Добавить функцию открытия по id и вызвать её после загрузки. После `function openDeal(deal: MockDeal) { … }` добавить:
|
||||
|
||||
```ts
|
||||
/** Audit C8/F3: deep-link — открыть drawer сделки по ?openId= из URL. */
|
||||
function openDealFromQuery(): void {
|
||||
const raw = route.query.openId;
|
||||
const id = Number(Array.isArray(raw) ? raw[0] : raw);
|
||||
if (!Number.isInteger(id) || id <= 0) return;
|
||||
const deal = dealsState.find((d) => d.id === id);
|
||||
if (deal) openDeal(deal);
|
||||
}
|
||||
```
|
||||
|
||||
**3c.** В `onMounted` — вызвать после загрузки. Заменить:
|
||||
|
||||
```ts
|
||||
onMounted(() => {
|
||||
void leadStatusesStore.load();
|
||||
void loadDeals();
|
||||
});
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
onMounted(async () => {
|
||||
void leadStatusesStore.load();
|
||||
await loadDeals();
|
||||
openDealFromQuery();
|
||||
});
|
||||
```
|
||||
|
||||
И реагировать на смену query (навигация на `/deals?openId=` когда DealsView уже смонтирован) — добавить watch рядом с другими watch'ами:
|
||||
|
||||
```ts
|
||||
watch(
|
||||
() => route.query.openId,
|
||||
() => openDealFromQuery(),
|
||||
);
|
||||
```
|
||||
|
||||
**3d.** В `defineExpose({ … })` добавить `drawerOpen`, `selectedDeal`, `openDealFromQuery` (для тестов).
|
||||
|
||||
- [ ] **Step 4: RemindersView — deep-link openDeal**
|
||||
|
||||
В `app/resources/js/views/RemindersView.vue` заменить:
|
||||
|
||||
```ts
|
||||
async function openDeal(dealId: number): Promise<void> {
|
||||
void dealId; // на MVP — без deep-link на конкретный drawer.
|
||||
await router.push('/deals');
|
||||
}
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
async function openDeal(dealId: number): Promise<void> {
|
||||
// Audit C8: deep-link на конкретный drawer через ?openId=.
|
||||
await router.push({ path: '/deals', query: { openId: dealId } });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: AppTopbar — deep-link bell**
|
||||
|
||||
В `app/resources/js/components/layout/AppTopbar.vue` заменить:
|
||||
|
||||
```ts
|
||||
async function handleNotificationClick(id: number, dealId: number | null): Promise<void> {
|
||||
await notifications.markRead(id);
|
||||
if (dealId !== null) {
|
||||
// На MVP — push на DealsView (deep-link на конкретный drawer — отдельный коммит).
|
||||
await router.push('/deals');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
async function handleNotificationClick(id: number, dealId: number | null): Promise<void> {
|
||||
await notifications.markRead(id);
|
||||
if (dealId !== null) {
|
||||
// Audit F3: deep-link на конкретный drawer через ?openId=.
|
||||
await router.push({ path: '/deals', query: { openId: dealId } });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Запустить тесты — убедиться, что проходят**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/DealsView.spec.ts tests/Frontend/RemindersView.spec.ts`
|
||||
Expected: PASS — все тесты зелёные.
|
||||
|
||||
- [ ] **Step 7: Полный sweep**
|
||||
|
||||
Run (из `app/`): `npm run test:vue`, `npm run type-check`, `npm run lint:vue`
|
||||
Expected: Vitest 0 failed (выписать точные счётчики), type-check 0, lint 0.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/DealsView.vue app/resources/js/views/RemindersView.vue app/resources/js/components/layout/AppTopbar.vue app/tests/Frontend/DealsView.spec.ts app/tests/Frontend/RemindersView.spec.ts
|
||||
git commit -m "feat(deals): deep-link /deals?openId= из напоминаний и колокольчика (audit C8/F3)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- **J3:** `GET /api/dashboard/summary?tenant_id=N&range=…` возвращает контракт выше; Pest ≥7 кейсов зелёные; Pint/Larastan 0.
|
||||
- **C1:** DashboardView грузит summary на mount + при смене range; KPI/баланс/активность/воронка — из API; ошибка → degradation-alert + последние/mock-значения.
|
||||
- **C8/F3:** клик по напоминанию и по уведомлению-колокольчику с `deal_id` ведёт на `/deals?openId=<id>`; DealsView открывает drawer найденной сделки; openId не найден → no-op без ошибки.
|
||||
- `npm run test:vue` 0 failed; `npm run type-check` 0; `npm run lint:vue` 0; `php artisan test --filter=DashboardSummaryTest` 0 failed.
|
||||
- 3 атомарных коммита (J3 / C1 / C8+F3). Без push до явного запроса.
|
||||
|
||||
## Self-Review (выполнено при написании плана)
|
||||
|
||||
**Spec coverage:** C1 → Task 2; J3 → Task 1; C8 → Task 3 (RemindersView + DealsView consumer); F3 → Task 3 (AppTopbar + DealsView consumer). Все 4 ID Sprint 3B покрыты.
|
||||
|
||||
**Placeholder scan:** код приведён полностью. Единственная инструкция-без-кода — Task 1 Step 1 (изучить factory-паттерн существующих Feature-тестов) — это обязательная сверка фактического API фабрик, т.к. точный API `Deal::factory()` не в контексте автора плана; и Task 3 Step 1 (адаптировать mount-helper под фактический DealsView.spec) — оба требуют чтения одного reference-файла, не выдумывания.
|
||||
|
||||
**Type consistency:** `DashboardSummary` (api/dashboard.ts) ↔ контракт эндпоинта J3 ↔ `applySummary` в DashboardView совпадают по полям. `Kpi`/`Balance` импортируются из существующих компонентов без изменения. `getDashboardSummary(tenantId, range)` — единая сигнатура в клиенте, тесте и view.
|
||||
|
||||
**Риск:** партиционирование `deals` по `received_at` — тестовые даты должны попадать в существующие партиции (май-окт 2026 уже в schema.sql) либо `partitions:create-months` (отмечено в Task 1 Step 2/6).
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,240 +0,0 @@
|
||||
# Sprint 3E — Settings placeholder-tabs (D6/D7) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Убрать из `SettingsView` 4 placeholder-вкладки («Проекты», «Команда», «Интеграции», «Тихие часы»), которые показывают «В разработке» — UI не должен обещать нереализованный функционал.
|
||||
|
||||
**Architecture:** Чистое удаление. `SettingsView` оставляет 4 рабочие вкладки (Профиль, Безопасность, API и Webhook, Уведомления). Компонент `PlaceholderTab.vue` удаляется целиком. Spec-тест приводится к 4-вкладочному состоянию + добавляется регрессионная проверка, что placeholder'ы пропали.
|
||||
|
||||
**Tech Stack:** Vue 3 (`<script setup>` + TypeScript), Vuetify 3, Vitest 4 + @vue/test-utils.
|
||||
|
||||
---
|
||||
|
||||
## Контекст и per-tab решение (audit D6/D7)
|
||||
|
||||
Аудит портала ([docs/superpowers/specs/2026-05-15-portal-audit-design.md](../specs/2026-05-15-portal-audit-design.md)):
|
||||
|
||||
- **D6** — «PlaceholderTab × 4 — реализовать или скрыть (decide per-tab)».
|
||||
- **D7** — «SettingsView left-rail: 8 tab'ов, 4 заглушки — Hide-if-not-implemented».
|
||||
|
||||
**Per-tab решение — скрыть все 4** (реализация каждой = отдельный эпик, вне scope Sprint 3E):
|
||||
|
||||
| Вкладка | Решение | Обоснование |
|
||||
|---|---|---|
|
||||
| Проекты | скрыть | Полноценный `/projects` view уже есть — вкладка чистый дубль. |
|
||||
| Команда | скрыть | Нет ни `/team`-маршрута, ни backend; реализация = отдельный L-эпик со schema-работой, не в графике спринтов. |
|
||||
| Интеграции | скрыть | Telegram/1С/JivoSite/Yandex SSO — все внешне-блокированы (Б-1 и пр.). |
|
||||
| Тихие часы | скрыть | `quiet_hours` отсутствует в `db/schema.sql`; ТЗ §17.8 спецификацию даёт, но колонок/backend нет — отдельный эпик. |
|
||||
|
||||
«Импорт»-вкладка из предложения D7 — это H8 (Sprint 4, миграция §6), **вне scope Sprint 3E**.
|
||||
|
||||
Скрытие не отменяет ТЗ-требования (Команда / Тихие часы §17.8) — вкладки вернутся при реальной реализации соответствующих модулей.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `app/resources/js/views/SettingsView.vue` — убрать 4 placeholder-вкладки, `placeholderProps` computed, импорт и использование `PlaceholderTab`, неиспользуемый импорт `computed`; обновить docblock.
|
||||
- Delete: `app/resources/js/views/settings/PlaceholderTab.vue` — компонент больше не используется.
|
||||
- Test: `app/tests/Frontend/SettingsView.spec.ts` — 8 → 4 вкладки, убрать placeholder-тест, добавить регрессию.
|
||||
|
||||
**НЕ трогать:** `app/dev-indices.json` (авто-генерируемый временной DevIndex-фичей, уже `M` в git status — не стейджить, не коммитить); `SettingsView.story.vue` (ссылается только на `SettingsView`, не на `PlaceholderTab` — изменений не требует).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Скрыть 4 placeholder-вкладки в SettingsView
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/SettingsView.vue`
|
||||
- Delete: `app/resources/js/views/settings/PlaceholderTab.vue`
|
||||
- Test: `app/tests/Frontend/SettingsView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Привести spec-тест к 4-вкладочному состоянию (failing test first)**
|
||||
|
||||
Заменить весь файл `app/tests/Frontend/SettingsView.spec.ts` на:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import SettingsView from '../../resources/js/views/SettingsView.vue';
|
||||
|
||||
describe('SettingsView.vue', () => {
|
||||
const factory = () =>
|
||||
mount(SettingsView, {
|
||||
global: { plugins: [createPinia(), createVuetify()] },
|
||||
});
|
||||
|
||||
it('монтируется и содержит заголовок «Настройки»', () => {
|
||||
const wrapper = factory();
|
||||
expect(wrapper.find('h1').text()).toBe('Настройки');
|
||||
});
|
||||
|
||||
it('содержит ровно 4 nav-tabs (placeholder-вкладки убраны, audit D6/D7)', () => {
|
||||
const wrapper = factory();
|
||||
const items = wrapper.findAll('.tabs-rail .v-list-item');
|
||||
expect(items.length).toBe(4);
|
||||
});
|
||||
|
||||
it('содержит все 4 названия рабочих вкладок', () => {
|
||||
const wrapper = factory();
|
||||
const text = wrapper.text();
|
||||
const labels = ['Профиль', 'Безопасность', 'API и Webhook', 'Уведомления'];
|
||||
labels.forEach((l) => expect(text).toContain(l));
|
||||
});
|
||||
|
||||
it('не содержит placeholder-вкладок и текста «В разработке»', () => {
|
||||
const wrapper = factory();
|
||||
const railText = wrapper.find('.tabs-rail').text();
|
||||
['Команда', 'Интеграции', 'Тихие часы'].forEach((l) => expect(railText).not.toContain(l));
|
||||
expect(wrapper.text()).not.toContain('В разработке');
|
||||
});
|
||||
|
||||
it('по умолчанию показывает вкладку «Профиль»', () => {
|
||||
const wrapper = factory();
|
||||
const text = wrapper.text();
|
||||
// ProfileTab содержит поля Имя / Фамилия (split из «Полное имя» в audit D1) и Тайм-зона.
|
||||
expect(text).toContain('Имя');
|
||||
expect(text).toContain('Фамилия');
|
||||
expect(text).toContain('Тайм-зона');
|
||||
});
|
||||
|
||||
it('переключение на «Уведомления» показывает матрицу 8×3', async () => {
|
||||
const wrapper = factory();
|
||||
const items = wrapper.findAll('.tabs-rail .v-list-item');
|
||||
const notifItem = items.find((i) => i.text().includes('Уведомления'));
|
||||
await notifItem!.trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('События × каналы');
|
||||
// 8 типов событий из schema users.notification_preferences.
|
||||
['Новый лид', 'Напоминание', 'Низкий баланс', 'Нулевой баланс', 'Анонсы и промо'].forEach((e) =>
|
||||
expect(text).toContain(e),
|
||||
);
|
||||
});
|
||||
|
||||
it('переключение на «Безопасность» показывает 2FA и сессии', async () => {
|
||||
const wrapper = factory();
|
||||
const items = wrapper.findAll('.tabs-rail .v-list-item');
|
||||
const secItem = items.find((i) => i.text().includes('Безопасность'));
|
||||
await secItem!.trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Двухфакторная авторизация');
|
||||
expect(text).toContain('Активные сессии');
|
||||
});
|
||||
|
||||
it('переключение на «API и Webhook» показывает API-ключ и signing secret', async () => {
|
||||
const wrapper = factory();
|
||||
const items = wrapper.findAll('.tabs-rail .v-list-item');
|
||||
const apiItem = items.find((i) => i.text().includes('API'));
|
||||
await apiItem!.trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('API-ключ');
|
||||
expect(text).toContain('Signing secret');
|
||||
expect(text).toContain('HMAC');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Изменения относительно текущего файла: тест «ровно 8 nav-tabs» → 4; «8 названий вкладок» → 4 рабочих; тест «placeholder-вкладки показывают „В разработке"» удалён, вместо него — регрессия «не содержит placeholder-вкладок».
|
||||
|
||||
- [ ] **Step 2: Прогнать тест — убедиться, что падает**
|
||||
|
||||
Run: `cd app && npm run test:vue -- --run SettingsView`
|
||||
Expected: FAIL — текущий `SettingsView.vue` рендерит 8 вкладок, тесты «ровно 4 nav-tabs» и «не содержит placeholder-вкладок» красные.
|
||||
|
||||
- [ ] **Step 3: Удалить 4 placeholder-вкладки из `SettingsView.vue`**
|
||||
|
||||
Заменить блок `<script setup>` (строки 1–61) на:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Settings — настройки тенанта/пользователя. 4 рабочие вкладки.
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_settings.html.
|
||||
* Полностью реализованы (с UI-разводкой): Профиль, Безопасность, API и Webhook,
|
||||
* Уведомления (матрица 8×3 по schema v8.7 §4 users.notification_preferences).
|
||||
*
|
||||
* Аудит D6/D7 (Sprint 3E, 2026-05-16): placeholder-вкладки Проекты/Команда/
|
||||
* Интеграции/Тихие часы убраны — UI не должен обещать «в разработке».
|
||||
* «Проекты» дублировали /projects; «Команда» и «Тихие часы» (ТЗ §17.8)
|
||||
* требуют schema+backend (отдельные эпики); «Интеграции» внешне-блокированы (Б-1).
|
||||
* Вкладки вернутся при реальной реализации соответствующих модулей.
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import ApiTab from './settings/ApiTab.vue';
|
||||
import NotificationsTab from './settings/NotificationsTab.vue';
|
||||
import ProfileTab from './settings/ProfileTab.vue';
|
||||
import SecurityTab from './settings/SecurityTab.vue';
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: 'profile', label: 'Профиль', icon: 'mdi-account-outline' },
|
||||
{ id: 'security', label: 'Безопасность', icon: 'mdi-shield-lock-outline' },
|
||||
{ id: 'api', label: 'API и Webhook', icon: 'mdi-api' },
|
||||
{ id: 'notifications', label: 'Уведомления', icon: 'mdi-bell-outline' },
|
||||
];
|
||||
|
||||
const activeTab = ref('profile');
|
||||
</script>
|
||||
```
|
||||
|
||||
В `<template>` заменить блок `<v-card variant="outlined" class="tab-pane pa-6">…</v-card>` (строки 89–99) на:
|
||||
|
||||
```vue
|
||||
<v-card variant="outlined" class="tab-pane pa-6">
|
||||
<ProfileTab v-if="activeTab === 'profile'" />
|
||||
<SecurityTab v-else-if="activeTab === 'security'" />
|
||||
<ApiTab v-else-if="activeTab === 'api'" />
|
||||
<NotificationsTab v-else-if="activeTab === 'notifications'" />
|
||||
</v-card>
|
||||
```
|
||||
|
||||
`<style scoped>` — без изменений. Удаляются: импорт `PlaceholderTab`, импорт `computed` (становится неиспользуемым — остаётся только `ref`), `placeholderProps` computed, 4 строки placeholder-вкладок в `tabs`, `<PlaceholderTab>` в шаблоне.
|
||||
|
||||
- [ ] **Step 4: Удалить `PlaceholderTab.vue`**
|
||||
|
||||
Удалить файл `app/resources/js/views/settings/PlaceholderTab.vue` (`git rm`). Компонент больше нигде не импортируется (grep `PlaceholderTab` по `app/resources/js` → только `SettingsView.vue`, который мы уже почистили).
|
||||
|
||||
- [ ] **Step 5: Прогнать тест — убедиться, что зелёный**
|
||||
|
||||
Run: `cd app && npm run test:vue -- --run SettingsView`
|
||||
Expected: PASS — все 8 тестов SettingsView зелёные.
|
||||
|
||||
- [ ] **Step 6: Проверить vue-tsc и ESLint**
|
||||
|
||||
Run: `cd app && npm run type-check` → 0 ошибок (важно: неиспользуемый импорт `computed` удалён, иначе vue-tsc/ESLint ругнётся).
|
||||
Run: `cd app && npm run lint:vue` → 0 ошибок.
|
||||
|
||||
- [ ] **Step 7: Полный прогон Vitest (регрессия)**
|
||||
|
||||
Run: `cd app && npm run test:vue`
|
||||
Expected: 0 failed. Базовый объём перед изменением — 100 файлов / 838 passed / 3 skipped; после Sprint 3E удалён 1 тест → ожидается 100 файлов / 837 passed / 3 skipped (точное число — из реального вывода, не экстраполировать).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/SettingsView.vue app/tests/Frontend/SettingsView.spec.ts
|
||||
git rm app/resources/js/views/settings/PlaceholderTab.vue
|
||||
git commit -m "feat(settings): D6/D7 — убрать placeholder-вкладки SettingsView"
|
||||
```
|
||||
|
||||
**НЕ стейджить** `app/dev-indices.json` (авто-генерируемый, pre-existing `M`).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: D6 (4 placeholder-вкладки убраны) ✅; D7 (left-rail 8→4) ✅. «Импорт»-вкладка из D7 — H8/Sprint 4, явно вне scope.
|
||||
- Placeholder scan: нет TODO/TBD; весь код приведён дословно.
|
||||
- Type consistency: `tabs` остаётся `Tab[]`; `activeTab` — `ref('profile')`; `computed` удалён вместе с единственным потребителем `placeholderProps`.
|
||||
@@ -1,355 +0,0 @@
|
||||
# Sprint 3F — API middleware (J1/J2) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Закрыть аудит-находки J1 (auth+tenant middleware на `/api/deals*`) и J2 (стаб-гейт SaaS-admin зоны `/api/admin/*`) — убрать незащищённые API-эндпоинты, где tenant подставляется параметром запроса.
|
||||
|
||||
**Architecture:** J1 — на 8 роутов `/api/deals*` навешивается `['auth:sanctum','tenant']`; три контроллера (`DealController`, `DealBulkActionController`, `DealExportController`) перестают читать `tenant_id` из запроса и берут его из `auth()->user()->tenant_id`; 8 Pest-файлов мигрируют с `?tenant_id=` на `actingAs($user)`. J2 — новый middleware `EnsureSaasAdmin` (стаб: dev/testing пропускает, production fail-closed 503) вешается на весь блок `/api/admin/*`; реальная Yandex 360 SSO-авторизация — TODO под Б-1+DO-4.
|
||||
|
||||
**Tech Stack:** PHP 8.3 + Laravel 13, Sanctum SPA session auth, PostgreSQL 16 (RLS), Pest 4.
|
||||
|
||||
---
|
||||
|
||||
## Контекст (audit J1/J2)
|
||||
|
||||
Аудит портала ([docs/superpowers/specs/2026-05-15-portal-audit-design.md](../specs/2026-05-15-portal-audit-design.md)), раздел J:
|
||||
|
||||
- **J1** — «CTO-18 — auth+tenant middleware на `/api/deals` (требует Б-1 для prod)». Сейчас 8 роутов `/api/deals*` идут **без middleware**: `tenant_id` берётся параметром. Любой клиент читает/пишет сделки чужого тенанта, подставив `tenant_id`. `auth:sanctum`+`tenant` уже используются на `/api/reminders`, `/api/reports/*`, `/api/billing/*`, `/api/projects` — на dev работают, Б-1 их **не блокирует** (Б-1 блокирует только production-deploy). J1 = применить тот же middleware к `/api/deals*`.
|
||||
- **J2** — «`/api/admin/*` — auth:saas-admin middleware (требует Б-1 + DO-4)». Гварда `saas-admin` в `config/auth.php` **нет** (только `web`); реальный гвард = Yandex 360 SSO, аудит явно пишет «**после Б-1+DO-4**» — оба registry-блокера открыты. Полноценный J2 невозможен. Решение заказчика (2026-05-16): **заготовка-стаб** — middleware-гейт, на dev пропускает, на production fail-closed; production SSO — TODO.
|
||||
|
||||
**Scope J1 — backend + Pest.** Фронтенд (`app/resources/js/api/deals.ts` и 3 view) НЕ трогаем: после рефактора backend игнорирует клиентский `tenant_id` (Laravel `validate()` молча отбрасывает лишние ключи; лишний query-параметр игнорируется), фронт продолжает слать сессионную cookie и работает без изменений. Клиентский `tenant_id` становится вестигиальным безвредным параметром — его удаление косметическое, в аудите J1 не значится, вне scope Sprint 3F. Это устраняет дублирующий риск (8 frontend-файлов + 11 Vitest-спеков) при нулевом выигрыше для безопасности: backend, игнорируя клиентский `tenant_id`, уже закрывает кросс-tenant утечку.
|
||||
|
||||
**Регистровые items.** J1 связан с CTO-18, J2 — с Б-1+DO-4 (все открыты). Sprint 3F реализует **код** находок J1/J2 (что заказчик авторизовал командой «делай 3f»), но **не закрывает** CTO-18/Б-1/DO-4 в реестре `Открытые_вопросы` — закрытие требует явного «закрываем» от заказчика. Реестр в этом спринте не трогаем.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Task 1 (J2):**
|
||||
|
||||
- Create: `app/app/Http/Middleware/EnsureSaasAdmin.php` — стаб-гейт SaaS-admin зоны.
|
||||
- Modify: `app/bootstrap/app.php` — alias `'saas-admin'` в `$middleware->alias([...])`.
|
||||
- Modify: `app/routes/web.php` — обернуть блок `/api/admin/*` (impersonation/tenants/billing/incidents/system-settings/pricing-tiers/suppliers) в `Route::middleware('saas-admin')->group(...)`.
|
||||
- Test: `app/tests/Feature/SaasAdminMiddlewareTest.php` — passthrough на testing + fail-closed 503 на production.
|
||||
|
||||
**Task 2 (J1):**
|
||||
|
||||
- Modify: `app/routes/web.php` — 8 роутов `/api/deals*` в `Route::middleware(['auth:sanctum','tenant'])->group(...)`.
|
||||
- Modify: `app/app/Http/Controllers/Api/DealController.php` — `index/show/store/update`: tenant из `auth()->user()`.
|
||||
- Modify: `app/app/Http/Controllers/Api/DealBulkActionController.php` — `transition/destroy/restore`: то же.
|
||||
- Modify: `app/app/Http/Controllers/Api/DealExportController.php` — `export`: то же.
|
||||
- Test (migrate): `app/tests/Feature/DealIndexTest.php`, `DealShowTest.php`, `DealCreateTest.php`, `DealUpdateTest.php`, `DealTransitionTest.php`, `DealDestroyTest.php`, `DealRestoreTest.php`, `LookupsTest.php` (только 3 `/api/deals`-теста).
|
||||
|
||||
**НЕ трогать:** `app/dev-indices.json` (авто-генерируемый, pre-existing `M` — не стейджить); фронтенд `deals.ts` и deal-views (см. Scope выше); `DealModelTest.php` (модельный unit-тест, HTTP не вызывает); lookup-эндпоинты `/api/managers` и `/api/lead-statuses` (в аудит-находке J1 не значатся — остаются без middleware; `/api/managers`-тесты в `LookupsTest` не трогать).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: J2 — стаб-гейт `EnsureSaasAdmin` на `/api/admin/*`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Http/Middleware/EnsureSaasAdmin.php`
|
||||
- Modify: `app/bootstrap/app.php`
|
||||
- Modify: `app/routes/web.php`
|
||||
- Test: `app/tests/Feature/SaasAdminMiddlewareTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать failing-тест `SaasAdminMiddlewareTest.php`**
|
||||
|
||||
Создать `app/tests/Feature/SaasAdminMiddlewareTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* J2 (Sprint 3F) — стаб-гейт SaaS-admin зоны.
|
||||
*
|
||||
* EnsureSaasAdmin на /api/admin/*: dev/testing пропускает (admin-панель
|
||||
* работает на dev), прочие окружения — fail-closed 503 до подключения
|
||||
* реального Yandex 360 SSO (TODO под Б-1+DO-4).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
test('/api/admin/* пропускается на testing-окружении (стаб permissive)', function () {
|
||||
// Дефолтное тестовое окружение = testing → middleware пропускает.
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(200);
|
||||
});
|
||||
|
||||
test('/api/admin/* возвращает 503 вне dev/testing (стаб fail-closed)', function () {
|
||||
$this->app->detectEnvironment(fn () => 'production');
|
||||
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(503);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать тест — убедиться, что падает**
|
||||
|
||||
Run: `cd app && composer test -- --filter=SaasAdminMiddlewareTest`
|
||||
Expected: FAIL — middleware `EnsureSaasAdmin` ещё не существует, alias `saas-admin` не зарегистрирован, на роуты не навешан; тест «503 вне dev/testing» получит 200.
|
||||
|
||||
- [ ] **Step 3: Создать middleware `EnsureSaasAdmin.php`**
|
||||
|
||||
Создать `app/app/Http/Middleware/EnsureSaasAdmin.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Гейт SaaS-admin зоны (/api/admin/*) — audit-находка J2.
|
||||
*
|
||||
* СТАБ (Sprint 3F): полноценная авторизация saas-admin требует Yandex 360
|
||||
* SSO-входа, который гейтится Б-1 (регистрация ООО) + DO-4. До их закрытия
|
||||
* реального механизма аутентификации нет.
|
||||
*
|
||||
* Поведение стаба:
|
||||
* - dev / testing (local, testing) → пропускаем. Admin-панель работает на
|
||||
* dev; admin_user_id передаётся параметром (трейт ResolvesAdminUserId).
|
||||
* - прочие окружения (production / staging) → fail-closed 503: зона
|
||||
* закрыта до подключения реального SSO. Явный 503 лучше, чем тихо
|
||||
* открытый /api/admin/* в проде.
|
||||
*
|
||||
* TODO (после Б-1 + DO-4): заменить на проверку Yandex 360 SSO-сессии
|
||||
* saas-admin (отдельный guard) + роль (compliance и т.п. где требуется).
|
||||
*/
|
||||
class EnsureSaasAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! app()->environment('local', 'testing')) {
|
||||
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Зарегистрировать alias `saas-admin` в `bootstrap/app.php`**
|
||||
|
||||
В `app/bootstrap/app.php` добавить импорт и расширить `$middleware->alias([...])`:
|
||||
|
||||
Импорт (после `use App\Http\Middleware\SetTenantContext;`):
|
||||
|
||||
```php
|
||||
use App\Http\Middleware\EnsureSaasAdmin;
|
||||
```
|
||||
|
||||
Блок alias заменить на:
|
||||
|
||||
```php
|
||||
$middleware->alias([
|
||||
'tenant' => SetTenantContext::class,
|
||||
'saas-admin' => EnsureSaasAdmin::class,
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Навесить `saas-admin` на блок `/api/admin/*` в `routes/web.php`**
|
||||
|
||||
В `app/routes/web.php` весь блок admin-роутов (от комментария `// SaaS-admin impersonation flow (Ю-1)...` до строки с `AdminSuppliersController@update` включительно — это группы impersonation/tenants/billing/incidents/system-settings/pricing-tiers/suppliers) обернуть в `Route::middleware('saas-admin')->group(...)`. Структура:
|
||||
|
||||
```php
|
||||
// J2 (Sprint 3F): стаб-гейт SaaS-admin зоны. EnsureSaasAdmin — dev/testing
|
||||
// пропускает, production fail-closed 503. Реальный Yandex 360 SSO — TODO под
|
||||
// Б-1+DO-4. admin_user_id внутри контроллеров (трейт ResolvesAdminUserId)
|
||||
// стаб не меняет — это отдельная зона ответственности.
|
||||
Route::middleware('saas-admin')->group(function () {
|
||||
// SaaS-admin impersonation flow (Ю-1). ...
|
||||
Route::prefix('/api/admin/impersonation')->group(function () {
|
||||
// ... без изменений ...
|
||||
});
|
||||
|
||||
// ... все остальные admin-роуты без изменений, только с отступом +4 ...
|
||||
|
||||
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
|
||||
->where('id', '[0-9]+');
|
||||
});
|
||||
```
|
||||
|
||||
Содержимое роутов внутри — без изменений (только индентация +4; `composer pint` в Step 7 выровняет, но писать сразу корректно). Роуты `/api/billing/charges`, `/api/billing/*`, `/api/api-keys`, `/api/tenants/me/webhook-settings`, `/api/dashboard/summary` и далее — **вне** этой группы (это tenant-зона, не admin).
|
||||
|
||||
- [ ] **Step 6: Прогнать тест — убедиться, что зелёный**
|
||||
|
||||
Run: `cd app && composer test -- --filter=SaasAdminMiddlewareTest`
|
||||
Expected: PASS — 2/2.
|
||||
|
||||
- [ ] **Step 7: Pint + Larastan + регрессия admin-тестов**
|
||||
|
||||
Run: `cd app && composer pint` → 0 правок или авто-формат применён.
|
||||
Run: `cd app && composer stan` → 0 ошибок (новый файл middleware типизирован; тест использует только реальные методы `TestCase`, динамических свойств нет — baseline regen не требуется).
|
||||
Run: `cd app && composer test -- --filter="Admin"` → 0 failed. Все существующие admin-тесты (AdminBilling/AdminIncidents/AdminTenants/AdminSystemSettings/AdminPricingTiers/AdminSuppliers/Impersonation) проходят: на `testing`-окружении `EnsureSaasAdmin` прозрачен.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Middleware/EnsureSaasAdmin.php app/bootstrap/app.php app/routes/web.php app/tests/Feature/SaasAdminMiddlewareTest.php
|
||||
git commit -m "feat(api): J2 — стаб-гейт EnsureSaasAdmin на /api/admin/*"
|
||||
```
|
||||
|
||||
**НЕ стейджить** `app/dev-indices.json`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: J1 — `auth:sanctum`+`tenant` middleware на `/api/deals*`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/routes/web.php`
|
||||
- Modify: `app/app/Http/Controllers/Api/DealController.php`
|
||||
- Modify: `app/app/Http/Controllers/Api/DealBulkActionController.php`
|
||||
- Modify: `app/app/Http/Controllers/Api/DealExportController.php`
|
||||
- Test (migrate): `DealIndexTest.php`, `DealShowTest.php`, `DealCreateTest.php`, `DealUpdateTest.php`, `DealTransitionTest.php`, `DealDestroyTest.php`, `DealRestoreTest.php`, `LookupsTest.php`
|
||||
|
||||
> **NB про порядок шагов:** миграция атомарна — добавление middleware немедленно «краснит» все 8 deal-тест-файлов (они не аутентифицируются). Поэтому routes+контроллеры+тесты мигрируют в одной задаче/одном коммите; промежуточный red — внутри задачи (Step 3 это фиксирует как TDD-red), green — в Step 6.
|
||||
|
||||
- [ ] **Step 1: Навесить middleware на 8 роутов `/api/deals*` в `routes/web.php`**
|
||||
|
||||
В `app/routes/web.php` блок из 8 deal-роутов (`GET /api/deals`, `GET /api/deals/{id}`, `POST /api/deals`, `POST /api/deals/export`, `POST /api/deals/transition`, `PATCH /api/deals/{id}`, `DELETE /api/deals`, `POST /api/deals/restore`) обернуть в группу. Заменить docblock-комментарий и роуты на:
|
||||
|
||||
```php
|
||||
// Сделки — single-resource CRUD + bulk + export. J1 (Sprint 3F, audit):
|
||||
// auth:sanctum + tenant. tenant_id берётся из auth()->user()->tenant_id
|
||||
// (SetTenantContext), НЕ из параметра запроса — закрывает кросс-tenant утечку.
|
||||
//
|
||||
// Sprint 3 Phase A (audit O-refactor-01): single-resource CRUD в
|
||||
// DealController, bulk (transition/destroy/restore) — в
|
||||
// DealBulkActionController, export — в DealExportController.
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::get('/api/deals', 'App\Http\Controllers\Api\DealController@index');
|
||||
Route::get('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@show')->where('id', '[0-9]+');
|
||||
Route::post('/api/deals', 'App\Http\Controllers\Api\DealController@store');
|
||||
Route::post('/api/deals/export', 'App\Http\Controllers\Api\DealExportController@export');
|
||||
Route::post('/api/deals/transition', 'App\Http\Controllers\Api\DealBulkActionController@transition');
|
||||
Route::patch('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@update')->where('id', '[0-9]+');
|
||||
Route::delete('/api/deals', 'App\Http\Controllers\Api\DealBulkActionController@destroy');
|
||||
Route::post('/api/deals/restore', 'App\Http\Controllers\Api\DealBulkActionController@restore');
|
||||
});
|
||||
```
|
||||
|
||||
Lookup-роуты `/api/managers` и `/api/lead-statuses` (идут сразу после) — **вне** группы, без изменений.
|
||||
|
||||
- [ ] **Step 2: Рефактор контроллеров — tenant из `auth()->user()`**
|
||||
|
||||
Универсальное правило для всех 4 методов `DealController` + 3 методов `DealBulkActionController` + `export` `DealExportController`:
|
||||
|
||||
1. Убрать чтение `tenant_id` из запроса: для `index`/`show` — строку `$tenantId = (int) $request->query('tenant_id', '0');` и следующий за ней блок `if ($tenantId < 1) { return ... 422; }`. Для `store`/`update`/`transition`/`destroy`/`restore`/`export` — ключ `'tenant_id' => 'required|integer|min:1',` из массива правил `$request->validate([...])`.
|
||||
2. Убрать резолюцию `Tenant` + 404: блок `$tenant = Tenant::find(...); if ($tenant === null) { return ... 404; }` (в `export` — `abort(404, ...)`).
|
||||
3. Добавить `$tenantId = (int) $request->user()->tenant_id;` (для `index`/`show` — на месте удалённого; для остальных — сразу после `$validated = $request->validate([...]);`).
|
||||
4. Заменить все `$tenant->id` на `$tenantId`, в `use (...)` замыканий `$tenant` → `$tenantId`.
|
||||
5. Убрать `use App\Models\Tenant;` (станет неиспользуемым; `composer pint` подчистит, но убрать явно).
|
||||
6. Внутренние `DB::transaction(...)` + `DB::statement('SET LOCAL app.current_tenant_id = ...')` — **оставить без изменений**. Для write-методов это атомарность; для `DealExportController::export` это **обязательно** — StreamedResponse-замыкание выполняется уже после commit'а транзакции `tenant`-middleware (см. комментарий в `export()` строки про «после Laravel-response pipeline»), tenant-контекст middleware streaming НЕ покрывает.
|
||||
7. Обновить docblock-и: убрать абзацы «На MVP без auth-middleware… `tenant_id` параметром… Production: middleware» — заменить на «J1 (Sprint 3F): `auth:sanctum`+`tenant`, `tenant_id` из `auth()->user()`.»
|
||||
|
||||
Конкретно по `DealController`:
|
||||
|
||||
- `index(Request $request)`: удалить строки `$tenantId = (int) $request->query('tenant_id', '0');` + `if ($tenantId < 1) {...422}` + `$tenant = Tenant::find($tenantId);` + `if ($tenant === null) {...404}`. На их место: `$tenantId = (int) $request->user()->tenant_id;`. Остальное (уже использует `$tenantId`) — без изменений.
|
||||
- `show(Request $request, int $id)`: то же — удалить query/422/Tenant::find/404, поставить `$tenantId = (int) $request->user()->tenant_id;`.
|
||||
- `store(Request $request)`: из `validate` убрать `'tenant_id' => 'required|integer|min:1',`; убрать `$tenant = Tenant::find($validated['tenant_id']); if (...404)`; добавить `$tenantId = (int) $request->user()->tenant_id;`; заменить `$tenant->id` → `$tenantId` (manager-guard, `use (...)` замыкания, `SET LOCAL`, `Project::firstOrCreate`, `Deal::create`, `ActivityLog::create`).
|
||||
- `update(Request $request, int $id)`: из `validate` убрать `'tenant_id' => 'required|integer|min:1',`; убрать `$tenant = Tenant::find($validated['tenant_id']); if (...404)`; добавить `$tenantId = (int) $request->user()->tenant_id;`; заменить `$tenant->id` → `$tenantId` (manager-guard, `use (...)`, `SET LOCAL`, оба `where('tenant_id', ...)`, три `ActivityLog::create(['tenant_id' => ...])`).
|
||||
|
||||
`DealBulkActionController` — `transition`/`destroy`/`restore` идентично: убрать `tenant_id` из `validate`, убрать `Tenant::find`+404, `$tenantId = (int) $request->user()->tenant_id;`, `$tenant->id` → `$tenantId`, `use ($validated, $tenant)` → `use ($validated, $tenantId)`.
|
||||
|
||||
`DealExportController::export` — убрать `tenant_id` из `validate`, убрать `Tenant::find`+`abort(404)`, `$tenantId = (int) $request->user()->tenant_id;`, в `use (...)` StreamedResponse-замыкания `$tenant` → `$tenantId`, `$tenant->id` → `$tenantId`.
|
||||
|
||||
- [ ] **Step 3: Прогнать deal-тесты — убедиться в массовом red**
|
||||
|
||||
Run: `cd app && composer test -- --filter="Deal"`
|
||||
Expected: FAIL — `DealIndexTest`/`DealShowTest`/`DealCreateTest`/`DealUpdateTest`/`DealTransitionTest`/`DealDestroyTest`/`DealRestoreTest` массово красные: запросы без `actingAs` теперь получают `401`. Это подтверждает, что auth-гейт активен (TDD-red).
|
||||
|
||||
- [ ] **Step 4: Мигрировать 8 тест-файлов на `actingAs`**
|
||||
|
||||
Универсальный рецепт для каждого HTTP-теста на `/api/deals*`:
|
||||
|
||||
**(R1) `beforeEach`** — после создания `$this->tenant` добавить:
|
||||
|
||||
```php
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
```
|
||||
|
||||
(`use App\Models\User;` — добавить в импорты файла, если ещё нет.)
|
||||
|
||||
**(R2) URL** — убрать `?tenant_id=...` / `&tenant_id=...`: `'/api/deals?tenant_id='.$this->tenant->id` → `'/api/deals'`; `'/api/deals?tenant_id='.$id.'&status_in[]=new'` → `'/api/deals?status_in[]=new'` (если параметр был первым — следующий `&` становится `?`).
|
||||
|
||||
**(R3) Body** — убрать ключ `'tenant_id' => ...,` из массивов `postJson`/`patchJson`/`deleteJson`.
|
||||
|
||||
**(R4) Тесты «404 unknown tenant_id»** — **удалить целиком**. После J1 tenant берётся из `auth()->user()->tenant_id` (FK-гарантированно валиден), пути «unknown tenant» больше нет. Удаляются: `DealIndexTest` «404 для unknown tenant_id», `DealShowTest` «404 для unknown tenant», `DealUpdateTest` «404 unknown tenant», `DealTransitionTest` «404 на unknown tenant», `DealDestroyTest` «404 на unknown tenant», `DealRestoreTest` «404 на unknown tenant», `DealCreateTest` «404 при unknown tenant_id» и «POST /api/deals/export 404 unknown tenant».
|
||||
|
||||
**(R5) Тесты «422 без tenant_id»** — конвертировать в «401 без auth»: тело — запрос **без** `actingAs` → `401`. Пример (`DealIndexTest`):
|
||||
|
||||
```php
|
||||
test('GET /api/deals возвращает 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$this->getJson('/api/deals')->assertStatus(401);
|
||||
});
|
||||
```
|
||||
|
||||
Конвертируются: `DealIndexTest` «422 без tenant_id», `DealShowTest` «422 без tenant_id», `DealUpdateTest` «422 без tenant_id».
|
||||
|
||||
**(R6) Endpoints без теста «422 без tenant_id»** (transition/destroy/restore/store/export) — добавить по одному новому тесту «401 без auth» (запрос без `actingAs`), чтобы каждый из 8 endpoint'ов имел 401-покрытие. Пример (`DealTransitionTest`):
|
||||
|
||||
```php
|
||||
test('POST /api/deals/transition возвращает 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$this->postJson('/api/deals/transition', ['ids' => [1], 'status' => 'new'])->assertStatus(401);
|
||||
});
|
||||
```
|
||||
|
||||
**(R7) Тесты пустого body «422»** (`DealTransitionTest`/`DealDestroyTest`/`DealRestoreTest`: `postJson('/api/deals/transition', [])->assertStatus(422)` и аналоги) — остаются `422` (поля `ids`/`status` по-прежнему `required`); `actingAs` обеспечивается через `beforeEach` (R1), иначе был бы `401`. Если имя теста содержит «без tenant_id» — переименовать (например «422 на пустой body»).
|
||||
|
||||
**(R8) `DealCreateTest` «422 без обязательных полей»** — остаётся `422` (`project_name`/`phone` по-прежнему `required`), но `assertJson`-проверка ключей: `toHaveKeys(['tenant_id', 'project_name', 'phone'])` → `toHaveKeys(['project_name', 'phone'])` (`tenant_id` больше не валидируемое поле).
|
||||
|
||||
**(R9) Кросс-tenant RLS-тесты** (`DealIndexTest` «не возвращает сделки чужого tenant'а», «изолирует чужие удалённые»; `DealShowTest` «404 чужая сделка»; `DealUpdateTest` «404 чужая сделка»; и т.п.) — **оставить логику**: чужие данные сеются через `DB::statement('SET app.current_tenant_id = ...')`, `actingAs` — пользователь `$this->tenant`. Применить только R2/R3 (убрать `tenant_id` из запроса). Изоляция продолжает проверяться: backend берёт tenant из auth-пользователя.
|
||||
|
||||
**(R10) `LookupsTest.php`** — содержит тесты `/api/managers` (НЕ трогать — endpoint без middleware) и 3 теста `POST /api/deals` (manager-guard). `beforeEach` НЕ менять (тесты `/api/managers` чувствительны к числу users тенанта — лишний `$this->user` сломал бы `toHaveCount(2)`). Вместо этого в каждый из 3 `/api/deals`-тестов («422 если manager_id не принадлежит tenant'у», «422 если manager_id не активен», «принимает manager_id из своего tenant'а») первой строкой добавить `$this->actingAs(User::factory()->for($this->tenant)->create());` и применить R3 (убрать `tenant_id` из body). Файл использует `RefreshDatabase` — created user не протекает между тестами.
|
||||
|
||||
- [ ] **Step 5: Прогнать deal-тесты — убедиться в green**
|
||||
|
||||
Run: `cd app && composer test -- --filter="Deal"`
|
||||
Run: `cd app && composer test -- --filter="LookupsTest"`
|
||||
Expected: PASS — 0 failed в обоих. Точное число тестов — из реального вывода (R4 удалил 8 тестов, R5/R6 добавил/конвертировал 401-тесты).
|
||||
|
||||
- [ ] **Step 6: Pint + Larastan (regen baseline) + полная регрессия Pest**
|
||||
|
||||
Run: `cd app && composer pint` → авто-формат применён (в т.ч. удаление неиспользуемого `use App\Models\Tenant;`).
|
||||
|
||||
Run: `cd app && composer stan` → ожидаются НОВЫЕ ошибки от `$this->user` (новое динамическое свойство в тест-файлах) + сдвиг номеров строк → регенерировать baseline (quirk 25, 3 шага):
|
||||
|
||||
1. В `app/phpstan.neon` временно закомментировать строку `- phpstan-baseline.neon` в `includes:`.
|
||||
2. Run: `cd app && vendor/bin/phpstan analyse --generate-baseline`
|
||||
3. Раскомментировать `- phpstan-baseline.neon` в `app/phpstan.neon`.
|
||||
|
||||
После — повторно `cd app && composer stan` → 0 ошибок.
|
||||
|
||||
Run: `cd app && composer test` → 0 failed (полная регрессия Pest). Базовый объём перед Sprint 3F (origin/main `ca0c4d9`) — 853 tests / 850 passed / 3 skipped / 0 failed; после Sprint 3F число изменится (J2 +2 теста; J1 −8 удалённых «404 unknown» +5..8 «401 без auth») — **точное число из реального вывода, не экстраполировать**.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add app/routes/web.php app/app/Http/Controllers/Api/DealController.php app/app/Http/Controllers/Api/DealBulkActionController.php app/app/Http/Controllers/Api/DealExportController.php app/app/Http/Controllers/Api/Concerns app/tests/Feature/DealIndexTest.php app/tests/Feature/DealShowTest.php app/tests/Feature/DealCreateTest.php app/tests/Feature/DealUpdateTest.php app/tests/Feature/DealTransitionTest.php app/tests/Feature/DealDestroyTest.php app/tests/Feature/DealRestoreTest.php app/tests/Feature/LookupsTest.php app/phpstan-baseline.neon
|
||||
git commit -m "feat(api): J1 — auth:sanctum+tenant middleware на /api/deals*"
|
||||
```
|
||||
|
||||
(`app/app/Http/Controllers/Api/Concerns` в `git add` — на случай, если рефактор ничего там не создаст, путь просто проигнорируется; основное — 4 backend-файла + 8 тестов + baseline.)
|
||||
|
||||
**НЕ стейджить** `app/dev-indices.json`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** J1 (auth+tenant на `/api/deals*` — 8 роутов, 3 контроллера, 8 тест-файлов) ✅; J2 (стаб-гейт `/api/admin/*`) ✅. CTO-18/Б-1/DO-4 в реестре `Открытые_вопросы` не закрываются (нет «закрываем» от заказчика) — реализуется только код находок.
|
||||
- **Placeholder scan:** нет TODO/TBD в коде, кроме намеренного docblock-`TODO` в `EnsureSaasAdmin` (фиксирует, что стаб ждёт реального SSO под Б-1+DO-4 — это документация контракта, не пропуск работы). Тест-миграция задана точным рецептом R1–R10 (механическое преобразование, не placeholder).
|
||||
- **Type consistency:** `$tenantId` — `int` во всех 8 методах (`(int) $request->user()->tenant_id`); alias `'saas-admin'` и класс `EnsureSaasAdmin` совпадают между `bootstrap/app.php` и `routes/web.php`; middleware-массив `['auth:sanctum','tenant']` — порядок как в существующих группах (`/api/reminders` и др.).
|
||||
- **Атомарность J1:** middleware и миграция тестов — один коммит (Step 1–7 одной задачи); промежуточный red зафиксирован Step 3 как TDD-проверка активности auth-гейта.
|
||||
- **Регрессия admin/deal:** J2 на `testing` прозрачен → admin-тесты зелёные без изменений; J1 мигрирует все потребители `/api/deals*` (8 Pest-файлов, включая частично `LookupsTest`) — фронтенд не потребитель backend-тестов, его `tenant_id` backend молча игнорирует.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,567 +0,0 @@
|
||||
# Sprint 5A — Auth polish Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Закрыть 5 P2-эпиков подсистемы Auth из portal-wide аудита (A1, A4, A5, A6, A8) — Sprint 5, под-план A.
|
||||
|
||||
**Architecture:** Точечные правки 4 auth-view'ов (Vue 3 + Vuetify 3) + DemoSeeder-тулинг. Каждый эпик — отдельная атомарная задача с TDD-циклом (failing test → impl → green → commit). A5 — задача-характеризация (regression-тест), т.к. находка аудита не воспроизводится против текущего кода.
|
||||
|
||||
**Tech Stack:** Vue 3.5 + Vuetify 3.12 + TypeScript, Vitest 4 (frontend), Pest 4 (backend), Laravel 13.
|
||||
|
||||
**Источник:** [docs/superpowers/specs/2026-05-15-portal-audit-design.md](../specs/2026-05-15-portal-audit-design.md) §3 Sprint 5 + §4 раздел A.
|
||||
|
||||
**Исполнять в изолированном worktree** off `origin/main` (`c64be74`): текущая ветка `feat/sprint3f-api-middleware` отстала от origin/main и содержит несвязанный staged WIP (`automation-graph.html`, `dev-indices.json`) — его НЕ трогать. Ветка спринта: `feat/sprint5a-auth-polish`.
|
||||
|
||||
---
|
||||
|
||||
## Эпики (из аудита §4.A)
|
||||
|
||||
| ID | Объект | Находка | Решение |
|
||||
|---|---|---|---|
|
||||
| A1 | `LoginView.vue` Yandex 360 SSO | dead stub без `:disabled` | disabled + tooltip «после Б-1» |
|
||||
| A4 | `ResetPasswordView.vue` поле подтверждения | нет `:error-messages` на несовпадение | computed-ошибка «Пароли не совпадают» |
|
||||
| A5 | `ForgotPasswordView.vue` catch | аудит: «fallback недостижим» | **не воспроизводится** → regression-тест, фиксирующий достижимость |
|
||||
| A6 | `TwoFactorView.vue` таймер | хардкод «02:34» | реальный обратный отсчёт TOTP-окна (30 с) |
|
||||
| A8 | DemoSeeder | «422 при логине» (не пере-сидирован) | `composer demo:seed` + раздел в README + idempotency-тест |
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Ответственность | Задача |
|
||||
|---|---|---|
|
||||
| `app/resources/js/views/auth/LoginView.vue` | экран входа — SSO-кнопка в disabled-tooltip обёртке | T1 (A1) |
|
||||
| `app/resources/js/views/auth/ResetPasswordView.vue` | экран нового пароля — computed-ошибка подтверждения | T2 (A4) |
|
||||
| `app/resources/js/views/auth/ForgotPasswordView.vue` | без правок — только regression-тест | T3 (A5) |
|
||||
| `app/resources/js/views/auth/TwoFactorView.vue` | экран 2FA — таймер TOTP-окна на `setInterval` | T4 (A6) |
|
||||
| `app/composer.json` | +script `demo:seed` | T5 (A8) |
|
||||
| `app/README.md` | +раздел «Демо-данные» | T5 (A8) |
|
||||
| `app/tests/Feature/DemoSeederTest.php` | новый — idempotency DemoSeeder | T5 (A8) |
|
||||
| `app/tests/Frontend/{LoginView,ResetPasswordView,ForgotPasswordView,TwoFactorView}.spec.ts` | +по 1 тесту на эпик | T1-T4 |
|
||||
|
||||
**Команды (запускать из `app/`):**
|
||||
|
||||
- Один Vitest-файл: `npx vitest run tests/Frontend/<File>.spec.ts`
|
||||
- Один Vitest-тест: `npx vitest run tests/Frontend/<File>.spec.ts -t "<имя>"`
|
||||
- Pest-файл: `php artisan test tests/Feature/DemoSeederTest.php`
|
||||
- Полная регрессия: `npm run test:vue`, `php artisan test`, `npm run type-check`, `npm run lint:vue`, `composer pint`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: A1 — LoginView Yandex 360 SSO disabled + tooltip
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/auth/LoginView.vue:107` (SSO-кнопка) + `<style>` блок
|
||||
- Test: `app/tests/Frontend/LoginView.spec.ts` (+1 тест)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
Добавить в `app/tests/Frontend/LoginView.spec.ts` внутрь `describe('LoginView.vue', ...)` после последнего `it`:
|
||||
|
||||
```ts
|
||||
it('A1: SSO Yandex 360 — кнопка disabled до подключения Б-1', async () => {
|
||||
const wrapper = await mountLoginView();
|
||||
const ssoBtn = wrapper.findAll('button').find((b) => b.text().includes('Yandex 360'));
|
||||
expect(ssoBtn).toBeDefined();
|
||||
expect(ssoBtn!.classes()).toContain('v-btn--disabled');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — убедиться, что падает**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/LoginView.spec.ts -t "A1"`
|
||||
Expected: FAIL — кнопка не disabled, класс `v-btn--disabled` отсутствует.
|
||||
|
||||
- [ ] **Step 3: Реализация**
|
||||
|
||||
В `app/resources/js/views/auth/LoginView.vue` заменить строку 107:
|
||||
|
||||
```html
|
||||
<v-btn block size="large" variant="outlined"> Войти через Yandex 360 </v-btn>
|
||||
```
|
||||
|
||||
на (disabled-кнопка не ловит hover — tooltip вешаем на обёртку-`div`):
|
||||
|
||||
```html
|
||||
<v-tooltip
|
||||
text="Вход через Yandex 360 станет доступен после регистрации юр. лица (Б-1)."
|
||||
location="top"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<div v-bind="props" class="yandex-sso-wrap">
|
||||
<v-btn block size="large" variant="outlined" disabled>
|
||||
Войти через Yandex 360
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
```
|
||||
|
||||
В `<style scoped>` добавить после блока `.login-form { ... }`:
|
||||
|
||||
```css
|
||||
.yandex-sso-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/LoginView.spec.ts`
|
||||
Expected: PASS — все тесты файла (старые 6 + новый A1). Старый тест «содержит ... Yandex 360 SSO» проходит — текст кнопки сохранён.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/auth/LoginView.vue app/tests/Frontend/LoginView.spec.ts
|
||||
git commit -m "feat(auth): A1 — Yandex 360 SSO disabled + tooltip (Sprint 5A)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: A4 — ResetPasswordView ошибка несовпадения паролей
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/auth/ResetPasswordView.vue` (script + поле подтверждения, строки 110-118)
|
||||
- Test: `app/tests/Frontend/ResetPasswordView.spec.ts` (+1 тест)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
Добавить в `app/tests/Frontend/ResetPasswordView.spec.ts` внутрь `describe('ResetPasswordView.vue', ...)` после последнего `it`:
|
||||
|
||||
```ts
|
||||
it('A4: показывает ошибку при несовпадении пароля и подтверждения', async () => {
|
||||
const wrapper = await mountReset();
|
||||
const pwInputs = wrapper.findAll('input[type="password"]');
|
||||
await pwInputs[0].setValue('new-strong-pass-1234');
|
||||
await pwInputs[1].setValue('different-pass-9999');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.text()).toContain('Пароли не совпадают');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — убедиться, что падает**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/ResetPasswordView.spec.ts -t "A4"`
|
||||
Expected: FAIL — текст «Пароли не совпадают» отсутствует (у поля подтверждения нет `:error-messages`).
|
||||
|
||||
- [ ] **Step 3: Реализация**
|
||||
|
||||
В `app/resources/js/views/auth/ResetPasswordView.vue` в `<script setup>` добавить после объявления `canSubmit` (после строки 38, перед `async function handleSubmit`):
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Ошибка поля подтверждения: client-side проверка совпадения +
|
||||
* проброс backend-ошибки `password_confirmation` если придёт с 422.
|
||||
*/
|
||||
const confirmationError = computed<string[]>(() => {
|
||||
if (passwordConfirmation.value.length > 0 && password.value !== passwordConfirmation.value) {
|
||||
return ['Пароли не совпадают'];
|
||||
}
|
||||
return errors.value.password_confirmation ?? [];
|
||||
});
|
||||
```
|
||||
|
||||
В `<template>` заменить блок поля «Повторите пароль» (строки 110-118):
|
||||
|
||||
```html
|
||||
<v-text-field
|
||||
v-model="passwordConfirmation"
|
||||
label="Повторите пароль"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
required
|
||||
/>
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```html
|
||||
<v-text-field
|
||||
v-model="passwordConfirmation"
|
||||
label="Повторите пароль"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
required
|
||||
:error-messages="confirmationError"
|
||||
/>
|
||||
```
|
||||
|
||||
(`computed` уже импортирован — строка 17: `import { computed, ref } from 'vue';`.)
|
||||
|
||||
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/ResetPasswordView.spec.ts`
|
||||
Expected: PASS — старые 5 тестов + новый A4. Тест «успешный submit» проходит: при совпадающих паролях `confirmationError` пустой.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/auth/ResetPasswordView.vue app/tests/Frontend/ResetPasswordView.spec.ts
|
||||
git commit -m "feat(auth): A4 — ResetPassword ошибка несовпадения паролей (Sprint 5A)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: A5 — ForgotPasswordView regression-тест generic fallback
|
||||
|
||||
> **NB:** Находка аудита A5 «fallback недостижим» **не воспроизводится** против текущего кода (`extractValidationErrors` возвращает строго `Record|null`; store сбрасывает `lockoutSeconds=null` в начале запроса). Эта задача — не TDD-фикс, а **характеризационный regression-тест**, фиксирующий, что generic-fallback показывается на не-валидационной/не-429 ошибке. Код view НЕ меняется.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: `app/tests/Frontend/ForgotPasswordView.spec.ts` (+1 тест)
|
||||
- (правок в `ForgotPasswordView.vue` не предполагается)
|
||||
|
||||
- [ ] **Step 1: Написать характеризационный тест**
|
||||
|
||||
Добавить в `app/tests/Frontend/ForgotPasswordView.spec.ts` внутрь `describe('ForgotPasswordView.vue', ...)` после последнего `it`:
|
||||
|
||||
```ts
|
||||
it('A5: при не-валидационной ошибке (500/network) показывает generic fallback', async () => {
|
||||
// forgotPassword отклоняется обычной ошибкой; extractValidationErrors и
|
||||
// extractRateLimitRetry замоканы → null (см. vi.mock в шапке файла).
|
||||
vi.mocked(authApi.forgotPassword).mockRejectedValue(new Error('Network Error'));
|
||||
|
||||
const wrapper = await mountForgot();
|
||||
await wrapper.find('input[type="email"]').setValue('user@example.ru');
|
||||
await wrapper.find('form').trigger('submit.prevent');
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain('Произошла ошибка. Попробуйте позже.');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/ForgotPasswordView.spec.ts -t "A5"`
|
||||
Expected: **PASS** — поведение fallback корректно, находка не воспроизводится.
|
||||
|
||||
- [ ] **Step 3: Развилка по результату Step 2**
|
||||
|
||||
- **Если PASS** (ожидаемо) → A5 классифицируется как «verified — not reproduced»; код view не меняется; переходим к Step 4.
|
||||
- **Если FAIL** (неожиданно — реальный баг) → применить фикс в `app/resources/js/views/auth/ForgotPasswordView.vue`, заменив блок `catch` (строки 32-39):
|
||||
|
||||
```ts
|
||||
} catch (error: unknown) {
|
||||
const validationErrors = extractValidationErrors(error);
|
||||
if (validationErrors && Object.keys(validationErrors).length > 0) {
|
||||
errors.value = validationErrors;
|
||||
} else if (auth.lockoutSeconds === null) {
|
||||
errors.value = { email: ['Произошла ошибка. Попробуйте позже.'] };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Перезапустить Step 2 — добиться PASS, и `git add` также файл view.
|
||||
|
||||
- [ ] **Step 4: Запустить весь файл — убедиться, что все проходят**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/ForgotPasswordView.spec.ts`
|
||||
Expected: PASS — старые 5 тестов + новый A5.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/tests/Frontend/ForgotPasswordView.spec.ts
|
||||
git commit -m "test(auth): A5 — regression generic fallback ForgotPassword (Sprint 5A)"
|
||||
```
|
||||
|
||||
(При срабатывании развилки FAIL — добавить в `git add` также `app/resources/js/views/auth/ForgotPasswordView.vue` и заменить тип коммита на `fix(auth): A5 — ...`.)
|
||||
|
||||
---
|
||||
|
||||
## Task 4: A6 — TwoFactorView реальный обратный отсчёт TOTP-окна
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/auth/TwoFactorView.vue` (script: import + countdown-логика + onMounted/onUnmounted; template строка 129)
|
||||
- Test: `app/tests/Frontend/TwoFactorView.spec.ts` (+1 тест с fake timers)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
Добавить в `app/tests/Frontend/TwoFactorView.spec.ts` внутрь `describe('TwoFactorView.vue', ...)` после последнего `it`:
|
||||
|
||||
```ts
|
||||
it('A6: показывает реальный обратный отсчёт TOTP-окна (30 с)', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(10_000)); // epoch 10 c → 30 - (10 % 30) = 20
|
||||
try {
|
||||
const wrapper = await mountTwoFactor();
|
||||
const el = wrapper.find('[data-testid="totp-countdown"]');
|
||||
expect(el.exists()).toBe(true);
|
||||
expect(el.text()).toBe('00:20');
|
||||
|
||||
vi.setSystemTime(new Date(15_000)); // epoch 15 c → 30 - 15 = 15
|
||||
vi.advanceTimersByTime(1000);
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(el.text()).toBe('00:15');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
В первой строке файла дополнить импорт vitest — `vi` сейчас не импортируется:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
```
|
||||
|
||||
заменить на:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — убедиться, что падает**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/TwoFactorView.spec.ts -t "A6"`
|
||||
Expected: FAIL — элемент `[data-testid="totp-countdown"]` не существует (сейчас хардкод `02:34` без testid).
|
||||
|
||||
- [ ] **Step 3: Реализация**
|
||||
|
||||
В `app/resources/js/views/auth/TwoFactorView.vue` заменить строку 14 (импорт):
|
||||
|
||||
```ts
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
|
||||
```
|
||||
|
||||
Заменить блок (строки 28-36) — `userEmail` + `onMounted`:
|
||||
|
||||
```ts
|
||||
const userEmail = computed(() => auth.user?.email ?? 'аккаунт');
|
||||
|
||||
// Если попали на /2fa без pending state (requires2fa=false и не залогинен) —
|
||||
// прямой URL без login → отправляем на /login.
|
||||
onMounted(() => {
|
||||
if (!auth.requires2fa && !auth.isAuthenticated) {
|
||||
router.replace('/login');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
const userEmail = computed(() => auth.user?.email ?? 'аккаунт');
|
||||
|
||||
/**
|
||||
* TOTP-окно: код в приложении-аутентификаторе меняется каждые 30 секунд.
|
||||
* Показываем честный обратный отсчёт до смены кода (заменяет хардкод «02:34»).
|
||||
* Значение 30..1 секунд, формат «00:NN».
|
||||
*/
|
||||
function totpWindowLeft(): number {
|
||||
return 30 - (Math.floor(Date.now() / 1000) % 30);
|
||||
}
|
||||
const totpSecondsLeft = ref(totpWindowLeft());
|
||||
const totpCountdown = computed(() => `00:${String(totpSecondsLeft.value).padStart(2, '0')}`);
|
||||
let totpTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
// Если попали на /2fa без pending state (requires2fa=false и не залогинен) —
|
||||
// прямой URL без login → отправляем на /login.
|
||||
onMounted(() => {
|
||||
if (!auth.requires2fa && !auth.isAuthenticated) {
|
||||
router.replace('/login');
|
||||
}
|
||||
totpTimer = setInterval(() => {
|
||||
totpSecondsLeft.value = totpWindowLeft();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (totpTimer) clearInterval(totpTimer);
|
||||
});
|
||||
```
|
||||
|
||||
В `<template>` заменить строку 129:
|
||||
|
||||
```html
|
||||
<span class="text-caption text-medium-emphasis font-mono">02:34</span>
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```html
|
||||
<span
|
||||
class="text-caption text-medium-emphasis font-mono"
|
||||
:title="`До смены кода в приложении: ${totpCountdown}`"
|
||||
data-testid="totp-countdown"
|
||||
>{{ totpCountdown }}</span
|
||||
>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
|
||||
|
||||
Run: `npx vitest run tests/Frontend/TwoFactorView.spec.ts`
|
||||
Expected: PASS — старые 3 теста + новый A6. Старые тесты используют реальные таймеры; `setInterval` чистится через `onUnmounted` при teardown.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/auth/TwoFactorView.vue app/tests/Frontend/TwoFactorView.spec.ts
|
||||
git commit -m "feat(auth): A6 — реальный обратный отсчёт TOTP-окна в 2FA (Sprint 5A)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: A8 — DemoSeeder re-seed script + README + idempotency-тест
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/tests/Feature/DemoSeederTest.php`
|
||||
- Modify: `app/composer.json` (блок `scripts`)
|
||||
- Modify: `app/README.md` (+раздел «Демо-данные»)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
Создать `app/tests/Feature/DemoSeederTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DemoSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('DemoSeeder идемпотентен — повторный запуск не дублирует demo-tenant и admin', function () {
|
||||
$this->seed(DemoSeeder::class);
|
||||
$this->seed(DemoSeeder::class);
|
||||
|
||||
expect(Tenant::query()->where('subdomain', 'demo')->count())->toBe(1)
|
||||
->and(User::query()->where('email', 'admin@demo.local')->count())->toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — убедиться, что он есть и проходит/падает осознанно**
|
||||
|
||||
Run: `php artisan test tests/Feature/DemoSeederTest.php`
|
||||
Expected: **PASS** — DemoSeeder уже идемпотентен (`updateOrCreate`/`updateOrInsert`). Тест фиксирует это как регрессионную защиту «re-seed скрипта».
|
||||
Если FAIL — значит сидер не идемпотентен; это реальный баг, исправить `DemoSeeder.php` (привести вставки к `updateOrInsert` с ключом `tenant_id`+`name`) и добиться PASS.
|
||||
|
||||
- [ ] **Step 3: Добавить composer-script `demo:seed`**
|
||||
|
||||
В `app/composer.json` в блоке `"scripts"` добавить строку после `"audit-offline"`:
|
||||
|
||||
Заменить:
|
||||
|
||||
```json
|
||||
"audit-offline": "@composer audit --locked",
|
||||
"ide-helper": [
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```json
|
||||
"audit-offline": "@composer audit --locked",
|
||||
"demo:seed": "@php artisan db:seed --class=DemoSeeder --force",
|
||||
"ide-helper": [
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Добавить раздел в `app/README.md`**
|
||||
|
||||
Дописать в конец файла `app/README.md` раздел:
|
||||
|
||||
```markdown
|
||||
## Демо-данные (dev)
|
||||
|
||||
Демо-tenant создаётся `DemoSeeder` автоматически при `composer setup` /
|
||||
`php artisan migrate --seed` в окружениях `local` и `testing`
|
||||
(см. `DatabaseSeeder` — в `production` DemoSeeder не запускается).
|
||||
|
||||
**Учётные данные демо-входа:**
|
||||
|
||||
- URL: `/login`
|
||||
- Email: `admin@demo.local`
|
||||
- Пароль: `password`
|
||||
|
||||
Что создаётся: demo-tenant (`subdomain=demo`, баланс 1000 ₽ / 100 лидов),
|
||||
admin-пользователь, 3 проекта (сайт/звонок/СМС) и ~14 демо-сделок.
|
||||
|
||||
**Пере-сидировать демо-данные** (идемпотентно, существующие записи обновляются,
|
||||
дублей не создаётся):
|
||||
|
||||
```bash
|
||||
composer demo:seed
|
||||
```
|
||||
|
||||
Эквивалент: `php artisan db:seed --class=DemoSeeder --force`.
|
||||
|
||||
Если при логине демо-аккаунта возвращается 422 — демо-данные не засеяны
|
||||
на текущей dev-БД (например, после `migrate:fresh`); запустите `composer demo:seed`.
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Проверить `demo:seed` вручную + запустить тест**
|
||||
|
||||
Run: `composer demo:seed`
|
||||
Expected: вывод содержит `Demo tenant id=... subdomain=demo` и `Login: admin@demo.local / password`.
|
||||
|
||||
Run: `php artisan test tests/Feature/DemoSeederTest.php`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/tests/Feature/DemoSeederTest.php app/composer.json app/README.md
|
||||
git commit -m "feat(dev): A8 — composer demo:seed + README демо-данные + idempotency-тест (Sprint 5A)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Регрессия Sprint 5A
|
||||
|
||||
**Files:** нет правок — финальный gate.
|
||||
|
||||
- [ ] **Step 1: Полный Vitest**
|
||||
|
||||
Run (из `app/`): `npm run test:vue`
|
||||
Expected: все файлы зелёные, 0 failed (4 новых теста A1/A4/A5/A6 + дельта).
|
||||
|
||||
- [ ] **Step 2: Полный Pest**
|
||||
|
||||
Run (из `app/`): `php artisan test`
|
||||
Expected: 0 failed (новый `DemoSeederTest` зелёный).
|
||||
|
||||
- [ ] **Step 3: Type-check + lint + формат**
|
||||
|
||||
Run (из `app/`):
|
||||
|
||||
```
|
||||
npm run type-check
|
||||
npm run lint:vue
|
||||
composer pint
|
||||
```
|
||||
|
||||
Expected: vue-tsc 0 ошибок; ESLint 0 ошибок; Pint без изменений (или авто-формат закоммитить отдельным `style:`-коммитом).
|
||||
|
||||
- [ ] **Step 4: Зафиксировать результат**
|
||||
|
||||
Выписать в финальный отчёт фактические числа Pest/Vitest (passed/failed/skipped) с указанием дельты. Если что-то красное — НЕ заявлять Sprint 5A закрытым; чинить по `superpowers:systematic-debugging`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage:** A1 ✅ T1 / A4 ✅ T2 / A5 ✅ T3 (re-classified verify) / A6 ✅ T4 / A8 ✅ T5. Все 5 эпиков раздела A из аудита §3 Sprint 5 покрыты.
|
||||
|
||||
**2. Placeholder scan:** код приведён полностью в каждом шаге; команды и Expected — конкретны. Развилка T3 Step 3 содержит готовый фикс-код, не «TODO». Раздел README — полный текст.
|
||||
|
||||
**3. Type consistency:** `confirmationError` (T2) — `computed<string[]>`, совместим с `:error-messages`. `totpWindowLeft()`/`totpSecondsLeft`/`totpCountdown`/`totpTimer` (T4) — имена консистентны между script и template (`data-testid="totp-countdown"` ↔ тест T4 Step 1). `mountLoginView`/`mountReset`/`mountForgot`/`mountTwoFactor` — существующие хелперы spec-файлов, не переопределяются.
|
||||
|
||||
**Известное ограничение:** A5 — задача-характеризация, не фикс (находка аудита не воспроизводится). При закрытии Sprint 5A зафиксировать статус A5 как «verified — not reproduced» в отчёте/реестре.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,700 +0,0 @@
|
||||
# Sprint 5C — Billing/Admin Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to
|
||||
> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Закрыть 5 P2-эпиков подсистемы Billing/Admin портального аудита — E2, E4, G3, G7, G10.
|
||||
|
||||
**Architecture:** Frontend Vue 3.5 + Vuetify 3.12 + TypeScript (Composition API, `<script setup>`);
|
||||
backend Laravel 13. Изменения локализованы в 4 view/компонентах биллинга/админки + 1 контроллере +
|
||||
1 api-модуле. БД не трогаем (schema без изменений). Decide-эпики E2/E4 разрешены заказчиком
|
||||
2026-05-17: E2 → `disabled` + tooltip (паттерн 5A A1); E4 → убрать mock-баннер целиком.
|
||||
|
||||
**Tech Stack:** Vue 3.5, Vuetify 3.12, TypeScript, Pinia, vue-router 4, Pest 4, Vitest 4, Laravel 13.
|
||||
|
||||
**Порядок задач:** T1 (E2) и T2 (E4) независимы. T3→T4→T5 (G3→G7→G10) — последовательны, все три
|
||||
правят `AdminPricingTiersView.vue`; T3 — фундамент (вынос API), T4 и T5 строятся поверх.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: E2 — BalanceCard «Автопополнение» + «Сменить тариф» → `disabled` + tooltip
|
||||
|
||||
**Контекст:** `BalanceCard.vue` — три wallet-карты в `BillingView`. Кнопка «Пополнить» подвязана
|
||||
(`@click="$emit('topup')"`). Кнопки «Автопополнение» (рекуррентный биллинг) и «Сменить тариф»
|
||||
(self-service смена тарифа) — без обработчиков; backend-endpoint'ов под них нет, реализация вне
|
||||
scope P2. Решение заказчика — `disabled` + tooltip (как 5A A1 Yandex SSO). Disabled `v-btn` не
|
||||
ловит pointer-события → активатор tooltip навешивается на оборачивающий `<span>`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/components/billing/BalanceCard.vue`
|
||||
- Create: `app/tests/Frontend/BalanceCard.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест** — `app/tests/Frontend/BalanceCard.spec.ts`
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import BalanceCard from '../../resources/js/components/billing/BalanceCard.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
function factory() {
|
||||
return mount(BalanceCard, {
|
||||
global: { plugins: [vuetify] },
|
||||
props: {
|
||||
walletRub: 14250,
|
||||
leadsBalance: 285,
|
||||
tariffName: 'Про',
|
||||
tariffPrice: '990.00',
|
||||
tariffFeatures: ['Webhook', 'Канбан'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('BalanceCard.vue', () => {
|
||||
it('кнопка «Пополнить» активна и эмитит topup', async () => {
|
||||
const wrapper = factory();
|
||||
const btn = wrapper.findAll('button').find((b) => b.text().includes('Пополнить'));
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn!.attributes('disabled')).toBeUndefined();
|
||||
await btn!.trigger('click');
|
||||
expect(wrapper.emitted('topup')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('кнопка «Автопополнение» disabled (E2 — нет backend)', () => {
|
||||
const wrapper = factory();
|
||||
const btn = wrapper.findAll('button').find((b) => b.text().includes('Автопополнение'));
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn!.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('кнопка «Сменить тариф» disabled (E2 — нет backend)', () => {
|
||||
const wrapper = factory();
|
||||
const btn = wrapper.findAll('button').find((b) => b.text().includes('Сменить тариф'));
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn!.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать тест — убедиться, что падает**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/BalanceCard.spec.ts`
|
||||
Expected: FAIL — «Автопополнение» и «Сменить тариф» сейчас не disabled.
|
||||
|
||||
- [ ] **Step 3: Обернуть обе кнопки в `v-tooltip` с `disabled`**
|
||||
|
||||
В `BalanceCard.vue` заменить кнопку «Автопополнение» (сейчас `<v-btn variant="outlined"
|
||||
prepend-icon="mdi-autorenew" size="small"> Автопополнение </v-btn>`) на:
|
||||
|
||||
```vue
|
||||
<v-tooltip text="Автопополнение будет доступно после подключения платёжного шлюза.">
|
||||
<template #activator="{ props: tipProps }">
|
||||
<span v-bind="tipProps" class="d-inline-flex">
|
||||
<v-btn variant="outlined" prepend-icon="mdi-autorenew" size="small" disabled>
|
||||
Автопополнение
|
||||
</v-btn>
|
||||
</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
```
|
||||
|
||||
Заменить кнопку «Сменить тариф →» (сейчас `<v-btn variant="outlined" size="small"
|
||||
class="mt-auto">Сменить тариф →</v-btn>`) на:
|
||||
|
||||
```vue
|
||||
<v-tooltip text="Самостоятельная смена тарифа появится после запуска биллинга.">
|
||||
<template #activator="{ props: tipProps }">
|
||||
<span v-bind="tipProps" class="mt-auto d-inline-flex">
|
||||
<v-btn variant="outlined" size="small" disabled>Сменить тариф →</v-btn>
|
||||
</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
```
|
||||
|
||||
Примечание: класс `mt-auto` перенесён с кнопки на `<span>`-обёртку — обёртка теперь flex-ребёнок
|
||||
карты, ей нужен `mt-auto` для прижатия книзу.
|
||||
|
||||
- [ ] **Step 4: Прогнать тест — убедиться, что проходит**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/BalanceCard.spec.ts`
|
||||
Expected: PASS (3/3).
|
||||
|
||||
- [ ] **Step 5: Lint + type-check**
|
||||
|
||||
Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json && npm run lint:vue`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/billing/BalanceCard.vue app/tests/Frontend/BalanceCard.spec.ts
|
||||
git commit -m "feat(billing): E2 — disabled+tooltip на кнопках Автопополнение/Сменить тариф"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: E4 — убрать mock pending-баннер в BillingView + удалить mockBilling.ts
|
||||
|
||||
**Контекст:** `BillingView.vue` рисует `v-alert` «1 платёж в обработке» по хардкоду `MOCK_PENDING`
|
||||
из `composables/mockBilling.ts`. Платёжного шлюза нет (заблокирован Б-1), `POST /api/billing/topup`
|
||||
кредитует баланс мгновенно — состояния «платёж в обработке» в БД не существует и не появится до
|
||||
Б-1. Хардкод-баннер с фейковым «TX-89421 ЮKassa 14:21» вводит в заблуждение в проде. Решение
|
||||
заказчика — убрать баннер и файл `mockBilling.ts` целиком.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/BillingView.vue`
|
||||
- Delete: `app/resources/js/composables/mockBilling.ts`
|
||||
- Modify: `app/tests/Frontend/BillingView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Проверить, что `mockBilling` нигде больше не используется**
|
||||
|
||||
Run: `cd app && npx --yes grep -rn "mockBilling\|MOCK_PENDING" resources tests` (либо Grep-тул)
|
||||
Expected: совпадения только в `views/BillingView.vue` и `composables/mockBilling.ts`. Если есть
|
||||
другие — остановиться и эскалировать контроллеру.
|
||||
|
||||
- [ ] **Step 2: Написать падающий тест** — добавить в `app/tests/Frontend/BillingView.spec.ts`
|
||||
внутрь `describe('BillingView.vue', ...)`:
|
||||
|
||||
```ts
|
||||
it('не показывает pending-баннер (E4 — mock убран)', async () => {
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).not.toContain('в обработке');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Прогнать тест — убедиться, что падает**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/BillingView.spec.ts`
|
||||
Expected: FAIL — баннер «1 платёж в обработке» сейчас рендерится (`MOCK_PENDING` truthy).
|
||||
|
||||
- [ ] **Step 4: Убрать баннер и импорт из `BillingView.vue`**
|
||||
|
||||
1. Удалить строку импорта: `import { MOCK_PENDING } from '../composables/mockBilling';`
|
||||
2. Удалить блок `v-alert` (`<v-alert v-if="MOCK_PENDING" ...>...</v-alert>` — целиком, ~12 строк
|
||||
внутри `<template v-else-if="wallet">` перед `<BalanceCard ...>`).
|
||||
3. В doc-комментарии (строки 8–12) убрать абзац про «Pending-баннер остаётся mock (MOCK_PENDING) —
|
||||
это отдельный эпик E4». Заменить на одну строку:
|
||||
`Sprint 5C (E4): pending-баннер убран — платёжного шлюза нет (Б-1), реального состояния «платёж в обработке» в БД не существует.`
|
||||
|
||||
- [ ] **Step 5: Удалить файл `mockBilling.ts`**
|
||||
|
||||
```bash
|
||||
git rm app/resources/js/composables/mockBilling.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Прогнать тесты — убедиться, что проходят**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/BillingView.spec.ts`
|
||||
Expected: PASS (все, включая новый тест). `formatPlain`/`featureLabel` импорты остаются — они из
|
||||
`billingFormatters`, не из `mockBilling`.
|
||||
|
||||
- [ ] **Step 7: Lint + type-check**
|
||||
|
||||
Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json && npm run lint:vue`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/BillingView.vue app/tests/Frontend/BillingView.spec.ts
|
||||
git commit -m "feat(billing): E4 — убрать mock pending-баннер (нет платёжного шлюза до Б-1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: G3 — AdminPricingTiersView + AdminSupplierPricesView → типизированный api/admin.ts
|
||||
|
||||
**Контекст:** Обе админ-вьюхи уже на `<script setup lang="ts">` с интерфейсами (находка аудита
|
||||
«plain JS» устарела — Sprint 1 уже типизировал). Реальный остаток G3 — вьюхи дёргают сырой
|
||||
`import axios from 'axios'` напрямую, минуя `apiClient` (без `withCredentials`/`withXSRFToken`/
|
||||
CSRF — латентный баг для прода). Остальные админ-вьюхи (`AdminBillingView`) ходят через типизо-
|
||||
ванный `api/admin.ts` + `apiClient`. Задача — вынести вызовы pricing-tiers/suppliers в `api/admin.ts`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/api/admin.ts`
|
||||
- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue`
|
||||
- Modify: `app/resources/js/views/admin/AdminSupplierPricesView.vue`
|
||||
- Modify: `app/tests/Frontend/AdminPricingTiersView.spec.ts`
|
||||
- Modify: `app/tests/Frontend/AdminSupplierPricesView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Добавить функции и типы в `api/admin.ts`** (в конец файла)
|
||||
|
||||
```ts
|
||||
// === SaaS-admin → Тарифная сетка (Plan 4 / Sprint 5C G3) ===
|
||||
|
||||
export interface AdminPricingTier {
|
||||
tier_no: number;
|
||||
leads_in_tier: number | null;
|
||||
price_per_lead_kopecks: number;
|
||||
effective_from: string;
|
||||
}
|
||||
|
||||
export interface PricingTiersResponse {
|
||||
active: AdminPricingTier[];
|
||||
scheduled: Record<string, AdminPricingTier[]>;
|
||||
}
|
||||
|
||||
export interface PricingTierEditorRow {
|
||||
tier_no: number;
|
||||
leads_in_tier: number | null;
|
||||
price_rub: string;
|
||||
}
|
||||
|
||||
export async function getPricingTiers(): Promise<PricingTiersResponse> {
|
||||
const { data } = await apiClient.get<{ data: PricingTiersResponse }>('/api/admin/pricing-tiers');
|
||||
return { active: data.data.active, scheduled: data.data.scheduled ?? {} };
|
||||
}
|
||||
|
||||
export async function createPricingTiers(tiers: PricingTierEditorRow[]): Promise<{ effective_from: string }> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<{ effective_from: string }>('/api/admin/pricing-tiers', { tiers });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteScheduledPricingTier(effectiveFrom: string): Promise<void> {
|
||||
await ensureCsrfCookie();
|
||||
await apiClient.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
|
||||
}
|
||||
|
||||
// === SaaS-admin → Цены поставщиков (Plan 4 / Sprint 5C G3) ===
|
||||
|
||||
export interface AdminSupplier {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
cost_rub: string;
|
||||
quality_score: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export async function getAdminSuppliers(): Promise<AdminSupplier[]> {
|
||||
const { data } = await apiClient.get<{ data: AdminSupplier[] }>('/api/admin/suppliers');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateAdminSupplier(
|
||||
id: number,
|
||||
payload: { cost_rub: string; quality_score: string; is_active: boolean },
|
||||
): Promise<AdminSupplier> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.patch<{ data: AdminSupplier }>(`/api/admin/suppliers/${id}`, payload);
|
||||
return data.data;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Переписать `AdminPricingTiersView.vue` на api/admin**
|
||||
|
||||
1. Удалить `import axios from 'axios';`.
|
||||
2. Добавить:
|
||||
`import { getPricingTiers, createPricingTiers, deleteScheduledPricingTier, type AdminPricingTier, type PricingTierEditorRow } from '../../api/admin';`
|
||||
3. Удалить локальные интерфейсы `Tier` и `EditorRow`; заменить их использования на `AdminPricingTier`
|
||||
и `PricingTierEditorRow` соответственно (`active: ref<AdminPricingTier[]>([])`,
|
||||
`scheduled: ref<Record<string, AdminPricingTier[]>>({})`, `editor: ref<PricingTierEditorRow[]>(...)`,
|
||||
`defaultEditor: PricingTierEditorRow[]`).
|
||||
4. `load()` — заменить тело:
|
||||
|
||||
```ts
|
||||
const data = await getPricingTiers();
|
||||
active.value = data.active;
|
||||
scheduled.value = data.scheduled;
|
||||
```
|
||||
|
||||
5. `submit()` — заменить `await axios.post('/api/admin/pricing-tiers', { tiers: editor.value });` на
|
||||
`await createPricingTiers(editor.value);`.
|
||||
6. `confirmDelete()` — заменить `await axios.delete(\`/api/admin/pricing-tiers/scheduled/${effectiveFrom}\`);`
|
||||
на `await deleteScheduledPricingTier(effectiveFrom);`.
|
||||
7. `extractErrorMessage` остаётся (импорт из `../../api/client`).
|
||||
|
||||
- [ ] **Step 3: Переписать `AdminSupplierPricesView.vue` на api/admin**
|
||||
|
||||
1. Удалить `import axios from 'axios';`.
|
||||
2. Добавить: `import { getAdminSuppliers, updateAdminSupplier, type AdminSupplier } from '../../api/admin';`
|
||||
3. Удалить локальный интерфейс `SupplierRow`; заменить использования на `AdminSupplier`
|
||||
(`suppliers: ref<AdminSupplier[]>([])`, параметр `save(s: AdminSupplier)`).
|
||||
4. `load()` — заменить тело: `suppliers.value = await getAdminSuppliers();`.
|
||||
5. `save()` — заменить `axios.patch(...)` на:
|
||||
`await updateAdminSupplier(s.id, { cost_rub: s.cost_rub, quality_score: s.quality_score, is_active: s.is_active });`
|
||||
|
||||
- [ ] **Step 4: Переписать `AdminPricingTiersView.spec.ts` на мок api/admin**
|
||||
|
||||
Эталон паттерна — `app/tests/Frontend/AdminBillingViewActions.spec.ts`. Ключевые правки:
|
||||
|
||||
1. Убрать `import axios from 'axios';` и `vi.mock('axios');`.
|
||||
2. Добавить partial-мок:
|
||||
|
||||
```ts
|
||||
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
|
||||
return { ...orig, getPricingTiers: vi.fn(), createPricingTiers: vi.fn(), deleteScheduledPricingTier: vi.fn() };
|
||||
});
|
||||
const adminApi = await import('../../resources/js/api/admin');
|
||||
```
|
||||
|
||||
3. Добавить хелпер ошибки (копия из эталона):
|
||||
|
||||
```ts
|
||||
function makeAxiosError(message: string, status = 422): unknown {
|
||||
return Object.assign(new Error(message), { isAxiosError: true, response: { status, data: { message } } });
|
||||
}
|
||||
```
|
||||
|
||||
4. `mockTiers` — оставить (это `AdminPricingTier[]`).
|
||||
5. Первый `describe` `beforeEach`:
|
||||
|
||||
```ts
|
||||
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
|
||||
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
|
||||
vi.mocked(adminApi.deleteScheduledPricingTier).mockResolvedValue(undefined);
|
||||
```
|
||||
|
||||
6. Тест `submits POST ...` → `expect(adminApi.createPricingTiers).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ tier_no: 7, leads_in_tier: null })]));`
|
||||
7. Тест `confirmDelete triggers DELETE ...` → `expect(adminApi.deleteScheduledPricingTier).toHaveBeenCalledWith('2026-06-01');` (`window.confirm = vi.fn(() => true)` — оставить, T5 уберёт).
|
||||
8. `describe` error handling — убрать `axios.isAxiosError` блок; в каждом тесте заменить
|
||||
`(axios.X as any).mockRejectedValue({response:...})` на `vi.mocked(adminApi.fn).mockRejectedValue(makeAxiosError('...', status))`,
|
||||
а `(axios.get as any).mockResolvedValue(...)` на `vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} })`.
|
||||
`afterEach(() => vi.clearAllMocks())` — оставить.
|
||||
|
||||
- [ ] **Step 5: Переписать `AdminSupplierPricesView.spec.ts` на мок api/admin**
|
||||
|
||||
Аналогично Step 4:
|
||||
|
||||
1. Убрать axios; `vi.mock('../../resources/js/api/admin', ...)` с `getAdminSuppliers`/`updateAdminSupplier` как `vi.fn()`.
|
||||
2. `makeAxiosError` хелпер.
|
||||
3. `beforeEach`: `vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue(mockSuppliers);`
|
||||
`vi.mocked(adminApi.updateAdminSupplier).mockResolvedValue(mockSuppliers[0]);`
|
||||
4. Тест `save() fires PATCH ...` → `expect(adminApi.updateAdminSupplier).toHaveBeenCalledWith(1, { cost_rub: '2.00', quality_score: '1.00', is_active: true });`
|
||||
5. Error-тесты → `mockRejectedValue(makeAxiosError(...))`; `load() ... rejects` → `vi.mocked(adminApi.getAdminSuppliers).mockRejectedValue(makeAxiosError('Database connection lost', 500))`.
|
||||
|
||||
- [ ] **Step 6: Прогнать оба spec-файла**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/AdminPricingTiersView.spec.ts tests/Frontend/AdminSupplierPricesView.spec.ts`
|
||||
Expected: PASS (все тесты обоих файлов).
|
||||
|
||||
- [ ] **Step 7: Lint + type-check**
|
||||
|
||||
Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json && npm run lint:vue`
|
||||
Expected: 0 ошибок (в т.ч. `import axios` удалён из обеих вьюх).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/api/admin.ts app/resources/js/views/admin/AdminPricingTiersView.vue \
|
||||
app/resources/js/views/admin/AdminSupplierPricesView.vue \
|
||||
app/tests/Frontend/AdminPricingTiersView.spec.ts app/tests/Frontend/AdminSupplierPricesView.spec.ts
|
||||
git commit -m "refactor(admin): G3 — pricing-tiers/suppliers вьюхи на типизированный api/admin.ts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: G7 — AdminPricingTiers effective_from через date-picker
|
||||
|
||||
**Контекст:** Сейчас `effective_from` новой сетки жёстко = 1-е число следующего месяца (МСК):
|
||||
backend `AdminPricingTiersController@store:92` хардкодит `startOfMonth()->addMonth()`, frontend
|
||||
показывает `nextMonthStart` в кнопке и заголовке диалога. G7 — дать админу выбрать дату.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/AdminPricingTiersController.php`
|
||||
- Modify: `app/resources/js/api/admin.ts`
|
||||
- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue`
|
||||
- Modify: `app/tests/Feature/Admin/AdminPricingTiersControllerTest.php`
|
||||
- Modify: `app/tests/Frontend/AdminPricingTiersView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающий Pest-тест** — добавить в `AdminPricingTiersControllerTest.php`
|
||||
|
||||
```php
|
||||
it('store accepts a custom effective_from date', function (): void {
|
||||
$custom = \Illuminate\Support\Carbon::now('Europe/Moscow')->addMonths(3)->toDateString();
|
||||
|
||||
$response = $this->postJson('/api/admin/pricing-tiers', [
|
||||
'tiers' => validTiersPayload(),
|
||||
'effective_from' => $custom,
|
||||
]);
|
||||
|
||||
$response->assertCreated()->assertJson(['effective_from' => $custom]);
|
||||
expect(\App\Models\PricingTier::where('effective_from', $custom)->count())->toBe(7);
|
||||
});
|
||||
|
||||
it('store rejects effective_from in the past', function (): void {
|
||||
$past = \Illuminate\Support\Carbon::now('Europe/Moscow')->subDay()->toDateString();
|
||||
|
||||
$this->postJson('/api/admin/pricing-tiers', [
|
||||
'tiers' => validTiersPayload(),
|
||||
'effective_from' => $past,
|
||||
])->assertStatus(422);
|
||||
});
|
||||
```
|
||||
|
||||
Примечание: если в файле нет хелпера `validTiersPayload()` — переиспользовать массив тиров из
|
||||
существующего теста store (7 строк `tier_no`/`leads_in_tier`/`price_rub`); вынести в локальную
|
||||
функцию-хелпер в начале файла либо инлайнить массив в обоих новых тестах.
|
||||
|
||||
- [ ] **Step 2: Прогнать — убедиться, что падает**
|
||||
|
||||
Run: `cd app && php artisan test --filter=AdminPricingTiersControllerTest`
|
||||
Expected: FAIL — `effective_from` сейчас игнорируется (первый тест: дата = next-month, не custom;
|
||||
второй: 201 вместо 422).
|
||||
|
||||
- [ ] **Step 3: Backend — принять `effective_from` в `store()`**
|
||||
|
||||
В `AdminPricingTiersController@store`:
|
||||
|
||||
1. Перед `$request->validate([...])` вычислить `$todayMsk = Carbon::now('Europe/Moscow')->toDateString();`
|
||||
2. В массив правил добавить:
|
||||
`'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after:'.$todayMsk],`
|
||||
3. Заменить строку `$effectiveFrom = Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();` на:
|
||||
|
||||
```php
|
||||
$effectiveFrom = $request->input('effective_from')
|
||||
?? Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();
|
||||
```
|
||||
|
||||
(`$todayMsk` из шага 1 переиспользуется правилом валидации; вычислять до `validate`.)
|
||||
|
||||
- [ ] **Step 4: Прогнать Pest — убедиться, что проходит**
|
||||
|
||||
Run: `cd app && php artisan test --filter=AdminPricingTiersControllerTest`
|
||||
Expected: PASS (старые тесты store без `effective_from` → дефолт next-month; 2 новых → custom/422).
|
||||
|
||||
- [ ] **Step 5: api/admin.ts — `createPricingTiers` принимает `effectiveFrom`**
|
||||
|
||||
Изменить сигнатуру (расширение T3-функции):
|
||||
|
||||
```ts
|
||||
export async function createPricingTiers(
|
||||
tiers: PricingTierEditorRow[],
|
||||
effectiveFrom?: string,
|
||||
): Promise<{ effective_from: string }> {
|
||||
await ensureCsrfCookie();
|
||||
const payload: { tiers: PricingTierEditorRow[]; effective_from?: string } = { tiers };
|
||||
if (effectiveFrom) payload.effective_from = effectiveFrom;
|
||||
const { data } = await apiClient.post<{ effective_from: string }>('/api/admin/pricing-tiers', payload);
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Frontend — date-picker в редакторе сетки**
|
||||
|
||||
В `AdminPricingTiersView.vue`:
|
||||
|
||||
1. Добавить ref после `nextMonthStart` computed:
|
||||
`const effectiveFrom = ref<string>(nextMonthStart.value);`
|
||||
2. Добавить computed для `min` (завтра):
|
||||
|
||||
```ts
|
||||
const minEffectiveFrom = computed(() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
});
|
||||
```
|
||||
|
||||
3. В диалоге-редакторе перед `<table class="editor-table">` добавить поле:
|
||||
|
||||
```vue
|
||||
<v-text-field
|
||||
v-model="effectiveFrom"
|
||||
type="date"
|
||||
label="Дата вступления в силу"
|
||||
:min="minEffectiveFrom"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
style="max-width: 240px"
|
||||
data-testid="effective-from-input"
|
||||
/>
|
||||
```
|
||||
|
||||
4. Заголовок диалога: `Новая сетка (effective_from = {{ effectiveFrom }})` (вместо `nextMonthStart`).
|
||||
Кнопку открытия редактора `Редактировать сетку (с {{ nextMonthStart }})` — оставить
|
||||
`nextMonthStart` (это дефолтная подсказка до открытия диалога).
|
||||
5. `submit()` — передать дату: `await createPricingTiers(editor.value, effectiveFrom.value);`.
|
||||
6. `successMessage` в `submit()` — использовать `effectiveFrom.value` вместо `nextMonthStart.value`.
|
||||
7. `defineExpose` — добавить `effectiveFrom`.
|
||||
|
||||
- [ ] **Step 7: Написать Vitest для date-picker** — добавить в первый `describe` `AdminPricingTiersView.spec.ts`:
|
||||
|
||||
```ts
|
||||
it('редактор содержит поле даты effective_from с дефолтом = след. месяц', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(wrapper.vm as any).editorOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="effective-from-input"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('submit передаёт выбранную effective_from в createPricingTiers', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(wrapper.vm as any).effectiveFrom = '2026-09-01';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (wrapper.vm as any).submit();
|
||||
expect(adminApi.createPricingTiers).toHaveBeenCalledWith(expect.any(Array), '2026-09-01');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Прогнать FE-тест**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/AdminPricingTiersView.spec.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Lint + type-check + Pint**
|
||||
|
||||
Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json && npm run lint:vue && composer pint -- --dirty`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Controllers/Api/AdminPricingTiersController.php app/resources/js/api/admin.ts \
|
||||
app/resources/js/views/admin/AdminPricingTiersView.vue \
|
||||
app/tests/Feature/Admin/AdminPricingTiersControllerTest.php app/tests/Frontend/AdminPricingTiersView.spec.ts
|
||||
git commit -m "feat(admin): G7 — выбор effective_from тарифной сетки через date-picker"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: G10 — AdminPricingTiers `window.confirm` → `v-dialog`
|
||||
|
||||
**Контекст:** `AdminPricingTiersView@confirmDelete` использует браузерный `window.confirm()` для
|
||||
подтверждения удаления запланированного набора тиров. Браузерный `confirm` блокирует UI и не
|
||||
доступен ассистивным технологиям — заменить на `v-dialog`. (Аудит назвал эпик «AdminBilling
|
||||
confirm()», но в `AdminBillingView` `confirm()` уже нет — Sprint 3D G4 заменил row-actions на
|
||||
`v-dialog`'и; фактический оставшийся браузерный confirm в админ-биллинге — здесь, в pricing-tiers.)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue`
|
||||
- Modify: `app/tests/Frontend/AdminPricingTiersView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающие тесты** — заменить в первом `describe` тест
|
||||
`confirmDelete triggers DELETE to /scheduled/{date}` на два теста:
|
||||
|
||||
```ts
|
||||
it('confirmDelete открывает диалог подтверждения, DELETE не вызывается сразу', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.confirmDelete('2026-06-01');
|
||||
expect(vm.deleteDialogOpen).toBe(true);
|
||||
expect(vm.deleteTarget).toBe('2026-06-01');
|
||||
expect(adminApi.deleteScheduledPricingTier).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('performDelete вызывает deleteScheduledPricingTier для выбранной даты', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.confirmDelete('2026-06-01');
|
||||
await vm.performDelete();
|
||||
expect(adminApi.deleteScheduledPricingTier).toHaveBeenCalledWith('2026-06-01');
|
||||
expect(vm.deleteDialogOpen).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
В error-handling `describe` тест `confirmDelete() shows errorMessage when ... rejects` —
|
||||
переименовать вызов: после `vm.confirmDelete('2026-06-01')` вызывать `await vm.performDelete()`
|
||||
(ошибку проверять после `performDelete`). Убрать `window.confirm = vi.fn(() => true)` из всех
|
||||
тестов этого файла (больше не нужен).
|
||||
|
||||
- [ ] **Step 2: Прогнать — убедиться, что падает**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/AdminPricingTiersView.spec.ts`
|
||||
Expected: FAIL — `deleteDialogOpen`/`deleteTarget`/`performDelete` ещё не существуют.
|
||||
|
||||
- [ ] **Step 3: Заменить `window.confirm` на `v-dialog`-flow**
|
||||
|
||||
В `AdminPricingTiersView.vue`:
|
||||
|
||||
1. Добавить state: `const deleteDialogOpen = ref(false);` и `const deleteTarget = ref<string | null>(null);`
|
||||
2. Заменить функцию `confirmDelete` — теперь только открывает диалог:
|
||||
|
||||
```ts
|
||||
function confirmDelete(effectiveFrom: string): void {
|
||||
deleteTarget.value = effectiveFrom;
|
||||
deleteDialogOpen.value = true;
|
||||
}
|
||||
```
|
||||
|
||||
3. Добавить `performDelete` — фактическое удаление (тело — бывший `confirmDelete` без `window.confirm`):
|
||||
|
||||
```ts
|
||||
async function performDelete(): Promise<void> {
|
||||
const effectiveFrom = deleteTarget.value;
|
||||
if (effectiveFrom === null) return;
|
||||
deleteDialogOpen.value = false;
|
||||
errorMessage.value = null;
|
||||
successMessage.value = null;
|
||||
try {
|
||||
await deleteScheduledPricingTier(effectiveFrom);
|
||||
successMessage.value = `Удалено: запланированный набор с ${effectiveFrom}.`;
|
||||
successToastOpen.value = true;
|
||||
await load();
|
||||
} catch (err) {
|
||||
errorMessage.value = extractErrorMessage(err, 'Не удалось удалить запланированный набор.');
|
||||
} finally {
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. В `<template>` после диалога-редактора добавить confirm-диалог:
|
||||
|
||||
```vue
|
||||
<v-dialog v-model="deleteDialogOpen" max-width="440">
|
||||
<v-card>
|
||||
<v-card-title>Удалить запланированный набор?</v-card-title>
|
||||
<v-card-text>
|
||||
Запланированная сетка с <strong>{{ deleteTarget }}</strong> будет удалена.
|
||||
Действие необратимо.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="deleteDialogOpen = false">Отмена</v-btn>
|
||||
<v-btn color="error" data-testid="confirm-delete-btn" @click="performDelete">Удалить</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
```
|
||||
|
||||
5. `defineExpose` — добавить `deleteDialogOpen`, `deleteTarget`, `performDelete`.
|
||||
|
||||
- [ ] **Step 4: Прогнать FE-тест — убедиться, что проходит**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/AdminPricingTiersView.spec.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Lint + type-check**
|
||||
|
||||
Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json && npm run lint:vue`
|
||||
Expected: 0 ошибок (в т.ч. `window.confirm` удалён из вьюхи).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/admin/AdminPricingTiersView.vue app/tests/Frontend/AdminPricingTiersView.spec.ts
|
||||
git commit -m "feat(admin): G10 — браузерный confirm() удаления сетки → v-dialog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Финал
|
||||
|
||||
После всех 5 задач — финальный holistic review всей реализации, затем полная регрессия
|
||||
(`/regression full`: Pest --parallel, Vitest, Vite build, vue-tsc, ESLint, Pint, Larastan,
|
||||
markdownlint, cspell, lychee, gitleaks) и `superpowers:finishing-a-development-branch`.
|
||||
|
||||
**Ожидаемые изменения относительно базы `345d14d`:** 5 feat/refactor-коммитов + этот plan-коммит.
|
||||
Файлы: `BalanceCard.vue`, `BillingView.vue`, `mockBilling.ts` (удалён), `api/admin.ts`,
|
||||
`AdminPricingTiersView.vue`, `AdminSupplierPricesView.vue`, `AdminPricingTiersController.php`,
|
||||
|
||||
- 5 spec-файлов (1 новый `BalanceCard.spec.ts`). БД/schema — без изменений.
|
||||
@@ -1,480 +0,0 @@
|
||||
# Sprint 5D — Cleanup dev-артефактов (I3 + I4) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Убрать production fake-data fallback (вьюхи рендерят выдуманные сделки/тенантов/инциденты при сбое API) и DEV-gate баннер `_dev_plain_code` в impersonation-диалоге.
|
||||
|
||||
**Architecture:** Аудит портала, находки I3 + I4 (под-план Sprint 5D). 8 production-файлов инициализируют reactive-state mock-данными из `composables/mock*.ts` и держат их как fallback при ошибке API — в проде юзер при 500/network видит фейковые данные. Фикс: state инициализируется пустым (`[]` / нули); при ошибке показывается error-alert (уже есть `v-alert v-if="fetchError"` в каждом файле — меняется только текст). Файлы `mock*.ts` **остаются на месте** (типы + UI-константы там реальные; mock-массивы используются только тестами как фикстуры) — решение заказчика «убрать fake-fallback», без переезда файлов. I4: баннер `_dev_plain_code` гейтится за `import.meta.env.DEV`.
|
||||
|
||||
**Tech Stack:** Vue 3.5 + Vuetify 3.12 + TypeScript, Vitest 4 (`@vue/test-utils` + jsdom).
|
||||
|
||||
**Scope (I1 — отложен):** I1 (DevIndexBadge/DevIndexOverlay removal) решением заказчика отложен до заморозки UI — не в этом плане.
|
||||
|
||||
**НЕ в scope (discovered minor):** `DealsView.vue` строка `const newToday = 3; // mock` — инлайн-литерал (не `mock*.ts`-композабл), показывает фейковое «+3 новых лида с утра». Требует реальной backend-метрики — отдельная находка, в 5D не трогается.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
Production (8 файлов — убрать mock-fallback):
|
||||
|
||||
- `app/resources/js/views/DealsView.vue` — `dealsState` init из `MOCK_DEALS`.
|
||||
- `app/resources/js/views/KanbanView.vue` — `dealsByStatus` + `totalDeals` init из `MOCK_DEALS`.
|
||||
- `app/resources/js/components/deals/NewDealDialog.vue` — `projectOptions`/`managerOptions` init из `MOCK_PROJECTS`/`MOCK_MANAGERS`.
|
||||
- `app/resources/js/components/deals/DealDetailDrawer.vue` — `events` init из `MOCK_EVENTS`, fallback на 2 catch-путях.
|
||||
- `app/resources/js/views/admin/AdminBillingView.vue` — `rowsState`/`summary` init из `ADMIN_BILLING_TENANTS`/`ADMIN_BILLING_SUMMARY`.
|
||||
- `app/resources/js/views/admin/AdminIncidentsView.vue` — `rowsState` + initial `stats` init из `ADMIN_INCIDENTS`.
|
||||
- `app/resources/js/views/admin/AdminSystemView.vue` — `settingsState` init из `ADMIN_SYSTEM_SETTINGS` (тип `AdminSystemSetting` — оставить).
|
||||
- `app/resources/js/views/admin/AdminTenantsView.vue` — `tenantsState`/`stats` init из `MOCK_TENANTS`/`MOCK_STATS`.
|
||||
|
||||
Production (1 файл — I4):
|
||||
|
||||
- `app/resources/js/components/admin/ImpersonationDialog.vue` — баннер `dev-code-banner` за `import.meta.env.DEV`.
|
||||
|
||||
Не трогаются: `composables/mock*.ts` (типы/константы реальны, mock-массивы — тест-фикстуры).
|
||||
|
||||
Tests (обновить — спеки писались вокруг mock-initial-state):
|
||||
|
||||
- `app/tests/Frontend/DealsView.spec.ts`, `DealsViewRedesign.spec.ts`, `KanbanView.spec.ts`
|
||||
- `app/tests/Frontend/NewDealDialog*.spec.ts`, `DealDetailDrawer*.spec.ts` (имена уточнить через `ls`)
|
||||
- `app/tests/Frontend/AdminBillingView.spec.ts`, `AdminBillingViewApi.spec.ts`
|
||||
- `app/tests/Frontend/AdminIncidentsView.spec.ts`, `AdminIncidentsViewApi.spec.ts`
|
||||
- `app/tests/Frontend/AdminSystemView.spec.ts`, `AdminTenantsView.spec.ts`, `AdminTenantsViewApi.spec.ts`
|
||||
|
||||
---
|
||||
|
||||
## Общий тест-принцип (для всех задач T1–T4)
|
||||
|
||||
Init state → `[]` ломает существующие тесты двух типов. Стратегия:
|
||||
|
||||
1. **Тесты, использующие mock как фикстуру** (рендер строк, bulk-actions, фильтры). Mock-композабл импортируется **в spec-файле** как фикстура (это разрешено — тесты могут использовать mock-данные). Два варианта вернуть данные в state — выбрать минимально-инвазивный для конкретного спека:
|
||||
- **Вариант A (предпочтительно для `*Api.spec.ts` и где API уже `vi.mock`'нут):** мок data-API резолвится фикстурой → success-путь `loadX()` наполняет state, `fetchError=false`.
|
||||
- **Вариант B (для smoke-спеков без `vi.mock`):** после `mount` + `flushPromises()` засеять exposed-state: `vm.<stateRef>.push(...<MOCK_FIXTURE>.map(clone))`. Для seed может потребоваться расширить `defineExpose` (см. задачи).
|
||||
2. **Тесты, явно ассертящие fake-fallback как поведение** (напр. `AdminBillingViewApi.spec.ts:96-106` — `expect(vm.rowsState.length).toBeGreaterThan(0)` после reject, заголовок «MOCK fallback остаётся»). **Инвертировать**: `toBe(0)`, заголовок → «state пустой».
|
||||
3. **Новый regression-тест на каждый production-файл:** API/loadX отклоняется → state пустой (`length === 0`) **и** error-видим (`fetchError`/alert). См. red-green в шагах задач.
|
||||
|
||||
Каждая задача завершается полным зелёным прогоном `npm run test:vue` (0 fail) + `npm run lint:vue` + `npm run type-check`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: DealsView + KanbanView — убрать MOCK_DEALS fallback
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/DealsView.vue`
|
||||
- Modify: `app/resources/js/views/KanbanView.vue`
|
||||
- Test: `app/tests/Frontend/DealsView.spec.ts`, `DealsViewRedesign.spec.ts`, `KanbanView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Regression-тест на пустой state при ошибке (DealsView) — должен упасть**
|
||||
|
||||
В `DealsView.spec.ts` добавить (рядом с C3-тестами в конце файла):
|
||||
|
||||
```ts
|
||||
test('I3: loadDeals reject → dealsState пустой + fetchError', async () => {
|
||||
vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500'));
|
||||
const wrapper = await mountDeals();
|
||||
const auth = useAuthStore();
|
||||
auth.user = { id: 1, tenant_id: 42, email: 't@t.io' } as AuthUser;
|
||||
const vm = wrapper.vm as unknown as { loadDeals: () => Promise<void>; dealsState: unknown[]; fetchError: boolean };
|
||||
await vm.loadDeals();
|
||||
await flushPromises();
|
||||
expect(vm.dealsState.length).toBe(0);
|
||||
expect(vm.fetchError).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — убедиться, что падает**
|
||||
|
||||
Run: `cd app && npm run test:vue -- DealsView.spec.ts`
|
||||
Expected: новый тест FAIL (`dealsState.length` = длина `MOCK_DEALS`, не 0).
|
||||
|
||||
- [ ] **Step 3: DealsView.vue — init пустой**
|
||||
|
||||
Импорт (строка 18) — убрать `MOCK_DEALS`:
|
||||
|
||||
```ts
|
||||
import { DEALS_TABS, type MockDeal } from '../composables/mockDeals';
|
||||
```
|
||||
|
||||
Init `dealsState` (строка 113):
|
||||
|
||||
```ts
|
||||
// Локальная reactive-копия. Наполняется через API (см. loadDeals/onMounted).
|
||||
// До загрузки и при ошибке — пустой массив; ошибка показывается через fetchError.
|
||||
const dealsState = reactive<MockDeal[]>([]);
|
||||
```
|
||||
|
||||
Catch в `loadDeals` (строка 133) — комментарий:
|
||||
|
||||
```ts
|
||||
} catch {
|
||||
fetchError.value = true; // state остаётся пустым — показываем error-alert
|
||||
}
|
||||
```
|
||||
|
||||
Шаблон, alert `fetch-error-alert` (строки ~714-724) — текст:
|
||||
|
||||
```
|
||||
Не удалось загрузить сделки. Попробуйте обновить.
|
||||
```
|
||||
|
||||
Doc-комментарий вверху файла — строку `MVP: page-head + chiprow со срезами + поиск + v-data-table с mock'ами.` поправить на `... + v-data-table (данные из API).`
|
||||
|
||||
- [ ] **Step 4: KanbanView.vue — init пустой**
|
||||
|
||||
Импорт (строка 23):
|
||||
|
||||
```ts
|
||||
import { type MockDeal } from '../composables/mockDeals';
|
||||
```
|
||||
|
||||
Init `dealsByStatus` (строки 49-54):
|
||||
|
||||
```ts
|
||||
const dealsByStatus = reactive<Record<string, MockDeal[]>>(
|
||||
LEAD_STATUSES.reduce<Record<string, MockDeal[]>>((acc, s) => {
|
||||
acc[s.slug] = [];
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
```
|
||||
|
||||
`totalDeals` (строка 111):
|
||||
|
||||
```ts
|
||||
const totalDeals = ref(0);
|
||||
```
|
||||
|
||||
Catch в `loadDeals` (строка 142) — комментарий: `fetchError.value = true; // state остаётся пустым — показываем error-alert`
|
||||
Alert `fetch-error-alert` (строки ~199-209) — текст: `Не удалось загрузить сделки. Попробуйте обновить.`
|
||||
|
||||
- [ ] **Step 5: Аналогичный regression-тест для KanbanView**
|
||||
|
||||
В `KanbanView.spec.ts` добавить тест: замокать `dealsApi.listDeals` reject, выставить `auth.user` с `tenant_id`, вызвать `vm.loadDeals()`, проверить что все массивы `dealsByStatus` пусты (`Object.values(vm.dealsByStatus).every(c => c.length === 0)`) и `vm.fetchError === true`.
|
||||
|
||||
- [ ] **Step 6: Починить существующие тесты (DealsView.spec.ts, DealsViewRedesign.spec.ts, KanbanView.spec.ts)**
|
||||
|
||||
Многие тесты используют `MOCK_DEALS` как фикстуру (рендер строк, `applyBulkStatus`, фильтры «Окна Москва»/«Иван П.», `route.query.openId=MOCK_DEALS[0].id`). Применить **Вариант B**: в mount-хелперах (`mountDeals`, `mountDealsViewAt`, аналог в KanbanView) после `mount` засеять state из mock-фикстуры:
|
||||
|
||||
```ts
|
||||
const wrapper = mount(DealsView, { /* ... */ });
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { dealsState: MockDeal[] };
|
||||
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
```
|
||||
|
||||
`MOCK_DEALS` уже импортируется в `DealsView.spec.ts`. Для KanbanView — засеять `dealsByStatus` по slug'ам. Тесты на новый-deal/bulk/фильтры/openId после seed работают как раньше. Прогонять после правки каждого спека.
|
||||
|
||||
- [ ] **Step 7: Полный прогон + линт**
|
||||
|
||||
Run: `cd app && npm run test:vue -- DealsView KanbanView && npm run lint:vue && npm run type-check`
|
||||
Expected: все DealsView/KanbanView спеки PASS (0 fail), ESLint 0, vue-tsc 0.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/DealsView.vue app/resources/js/views/KanbanView.vue app/tests/Frontend/DealsView.spec.ts app/tests/Frontend/DealsViewRedesign.spec.ts app/tests/Frontend/KanbanView.spec.ts
|
||||
git commit -m "fix(deals): I3 — убрать MOCK_DEALS fallback в DealsView/KanbanView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: NewDealDialog + DealDetailDrawer — убрать mock-fallback
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/components/deals/NewDealDialog.vue`
|
||||
- Modify: `app/resources/js/components/deals/DealDetailDrawer.vue`
|
||||
- Test: spec-файлы NewDealDialog / DealDetailDrawer (уточнить `ls tests/Frontend | grep -E "NewDeal|DealDetailDrawer"`)
|
||||
|
||||
- [ ] **Step 1: NewDealDialog.vue — пустые опции**
|
||||
|
||||
Импорт (строка 17):
|
||||
|
||||
```ts
|
||||
import { type MockDeal, type MockManager } from '../../composables/mockDeals';
|
||||
```
|
||||
|
||||
`projectOptions`/`managerOptions` (строки 24-25):
|
||||
|
||||
```ts
|
||||
const projectOptions = ref<string[]>([]);
|
||||
const managerOptions = ref<MockManager[]>([]);
|
||||
```
|
||||
|
||||
Doc-комментарий блока (строки 19-23) — переписать без «fallback на MOCK»:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Списки проектов и менеджеров грузятся с backend через GET /api/projects,
|
||||
* /api/managers при открытии диалога (если передан tenantId). На fail —
|
||||
* списки пустые + degradation-alter (lookupsFailed), создание блокируется
|
||||
* до повторной успешной загрузки.
|
||||
*/
|
||||
```
|
||||
|
||||
Комментарий строки 80 → `// Audit C6: loadLookups упал → показываем degradation-alert (списки пусты).`
|
||||
Alert `lookups-error-alert` (строки ~207-210) — текст:
|
||||
|
||||
```
|
||||
Не удалось загрузить списки проектов и менеджеров — попробуйте позже.
|
||||
```
|
||||
|
||||
`defineExpose` (строка 178) — добавить `projectOptions`, `managerOptions` для seed в тестах:
|
||||
|
||||
```ts
|
||||
defineExpose({ lookupsFailed, projectOptions, managerOptions });
|
||||
```
|
||||
|
||||
- [ ] **Step 2: DealDetailDrawer.vue — пустой timeline**
|
||||
|
||||
Импорт (строка 23):
|
||||
|
||||
```ts
|
||||
import { type DealEvent } from '../../composables/mockDealEvents';
|
||||
```
|
||||
|
||||
`events` init (строка 60):
|
||||
|
||||
```ts
|
||||
const events = ref<DealEvent[]>([]);
|
||||
```
|
||||
|
||||
`loadEvents` — путь без deal/tenantId (строка 119): `events.value = [];`
|
||||
`loadEvents` — catch (строка 131): `events.value = [];`
|
||||
Комментарий строки 59 → `// показываем реальные events. На fail / без tenant_id — events пуст + eventsFetchError.`
|
||||
|
||||
- [ ] **Step 3: Regression-тесты (red-green)**
|
||||
|
||||
NewDealDialog spec: тест — открыть диалог **без** `tenantId`, проверить `vm.projectOptions.length === 0` и `vm.managerOptions.length === 0` (раньше были mock). DealDetailDrawer spec: тест — `loadEvents` без tenantId / при reject `getDeal` → `vm.events.length === 0`. Сначала прогнать (увидеть fail на старом коде — но код уже правится в Step 1-2, поэтому: написать тест → если spec'и проверяют наличие mock-данных, они упадут до правки; порядок — допустимо написать тест и правку вместе, ключ: после правки тест зелёный и проверяет именно пустоту).
|
||||
|
||||
- [ ] **Step 4: Починить существующие тесты**
|
||||
|
||||
NewDealDialog: тесты submit-flow выбирают проект/менеджера — засеять `vm.projectOptions`/`vm.managerOptions` после mount (Вариант B), либо замокать `dealsApi.listProjects`/`listManagers` резолвом с фикстурой (Вариант A) при открытии с `tenantId`. DealDetailDrawer: тесты timeline — замокать `dealsApi.getDeal` резолвом с events, либо засеять `vm.events`. Тесты на `events-fetch-error-alert` (Sprint 5B C7) — events уже пуст при ошибке, проверить что alert по-прежнему рендерится.
|
||||
|
||||
- [ ] **Step 5: Полный прогон + линт**
|
||||
|
||||
Run: `cd app && npm run test:vue -- NewDealDialog DealDetailDrawer && npm run lint:vue && npm run type-check`
|
||||
Expected: PASS 0 fail, ESLint 0, vue-tsc 0.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/deals/NewDealDialog.vue app/resources/js/components/deals/DealDetailDrawer.vue app/tests/Frontend/
|
||||
git commit -m "fix(deals): I3 — убрать mock-fallback в NewDealDialog/DealDetailDrawer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: AdminBillingView + AdminIncidentsView — убрать mockAdmin fallback
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/admin/AdminBillingView.vue`
|
||||
- Modify: `app/resources/js/views/admin/AdminIncidentsView.vue`
|
||||
- Test: `app/tests/Frontend/AdminBillingView.spec.ts`, `AdminBillingViewApi.spec.ts`, `AdminIncidentsView.spec.ts`, `AdminIncidentsViewApi.spec.ts`
|
||||
|
||||
- [ ] **Step 1: AdminBillingView.vue — init пустой**
|
||||
|
||||
Удалить импорт (строка 11) `import { ADMIN_BILLING_SUMMARY as MOCK_SUMMARY, ADMIN_BILLING_TENANTS } from '../../composables/mockAdmin';` целиком.
|
||||
`rowsState` (строки 37-49):
|
||||
|
||||
```ts
|
||||
const rowsState = reactive<BillingRow[]>([]);
|
||||
```
|
||||
|
||||
`summary` (строки 51-56):
|
||||
|
||||
```ts
|
||||
const summary = reactive({
|
||||
total_mrr_rub: 0,
|
||||
monthly_revenue_rub: 0,
|
||||
overdue_count: 0,
|
||||
refunds_count_30d: 0,
|
||||
});
|
||||
```
|
||||
|
||||
Doc-комментарий вверху (строки 8-9): `MVP — только display-вьюха с mock-данными.` → `Данные грузятся с backend GET /api/admin/billing.`
|
||||
Комментарий строки 20-24 (над `BillingRow`) — убрать упоминание «initial = MOCK».
|
||||
Alert `fetch-error-alert` (строки ~249-259) — текст: `Не удалось загрузить биллинг. Попробуйте обновить.`
|
||||
|
||||
- [ ] **Step 2: AdminIncidentsView.vue — init пустой**
|
||||
|
||||
Удалить импорт (строка 12) `import { ADMIN_INCIDENTS } from '../../composables/mockAdmin';`.
|
||||
`rowsState` (строки 77-90):
|
||||
|
||||
```ts
|
||||
const rowsState = reactive<IncidentRow[]>([]);
|
||||
```
|
||||
|
||||
Удалить блок initial-stats из mock (строки 96-100 — `stats.open = rowsState.filter(...)` ×3). `stats` остаётся инициализированным нулями на строке 91.
|
||||
Комментарий строки 76 (`// Reactive — initial = MOCK; replace на API на mount.`) → `// Reactive — наполняется через loadIncidents (API).`
|
||||
Комментарий строки 95 (`// Initial stats из mock ...`) — удалить вместе с блоком.
|
||||
Doc-комментарий (строки 8-9): `MVP — display + фильтр ...` → `Display + фильтр по статусу/severity. Данные с backend GET /api/admin/incidents.`
|
||||
Alert `fetch-error-alert` (строки ~170-180) — текст: `Не удалось загрузить инциденты. Попробуйте обновить.`
|
||||
|
||||
- [ ] **Step 3: Тесты — инвертировать fake-fallback ассерты + regression**
|
||||
|
||||
`AdminBillingViewApi.spec.ts:96-106` — тест `'reject → fetchError=true + alert виден + MOCK fallback остаётся'`:
|
||||
|
||||
- заголовок → `'reject → fetchError=true + alert виден + rowsState пустой'`
|
||||
- `expect(vm.rowsState.length).toBeGreaterThan(0);` → `expect(vm.rowsState.length).toBe(0);`
|
||||
Аналогично проверить `AdminIncidentsViewApi.spec.ts` на наличие «MOCK fallback»-ассертов — инвертировать.
|
||||
Smoke-спеки `AdminBillingView.spec.ts` / `AdminIncidentsView.spec.ts`: если рендерят строки из mock-init — применить Вариант A (замокать `adminApi.listAdminBilling`/`listAdminIncidents` резолвом фикстуры — фикстуру взять из `composables/mockAdmin` импортом в спеке) или Вариант B (seed `vm.rowsState`). Добавить regression-тест где ещё нет: reject → `rowsState.length === 0` + `fetchError`.
|
||||
|
||||
- [ ] **Step 4: Полный прогон + линт**
|
||||
|
||||
Run: `cd app && npm run test:vue -- AdminBilling AdminIncidents && npm run lint:vue && npm run type-check`
|
||||
Expected: PASS 0 fail, ESLint 0, vue-tsc 0.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/admin/AdminBillingView.vue app/resources/js/views/admin/AdminIncidentsView.vue app/tests/Frontend/
|
||||
git commit -m "fix(admin): I3 — убрать mockAdmin fallback в Billing/Incidents"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: AdminSystemView + AdminTenantsView — убрать mock fallback
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/admin/AdminSystemView.vue`
|
||||
- Modify: `app/resources/js/views/admin/AdminTenantsView.vue`
|
||||
- Test: `app/tests/Frontend/AdminSystemView.spec.ts`, `AdminTenantsView.spec.ts`, `AdminTenantsViewApi.spec.ts`
|
||||
|
||||
- [ ] **Step 1: AdminSystemView.vue — init пустой**
|
||||
|
||||
Удалить импорт mock-данных (строка 11) `import { ADMIN_SYSTEM_SETTINGS } from '../../composables/mockAdmin';`.
|
||||
**Оставить** импорт типа (строка 12) `import type { AdminSystemSetting } from '../../composables/mockAdmin';` — тип используется.
|
||||
`settingsState` (строка 30):
|
||||
|
||||
```ts
|
||||
const settingsState = reactive<AdminSystemSetting[]>([]);
|
||||
```
|
||||
|
||||
Комментарий строки 23-29 (над `settingsState`) — убрать «Инициируется mock-данными (fallback...)»:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Settings-state. Наполняется на mount через `adminApi.listSystemSettings()`.
|
||||
* До загрузки и при ошибке — пустой; ошибка показывается через fetchError-banner.
|
||||
*/
|
||||
```
|
||||
|
||||
Catch в `loadSettings` (строка 41) — текст fallback в `extractErrorMessage`:
|
||||
|
||||
```ts
|
||||
fetchError.value = extractErrorMessage(err, 'Не удалось загрузить настройки с сервера. Попробуйте обновить.');
|
||||
```
|
||||
|
||||
Комментарий строки 39-40 (`// На fail оставляем mock ...`) → `// На fail — settingsState пустой, показываем error-banner.`
|
||||
Doc-комментарий (строки 8-9): `MVP — display + read-only edit-режим.` → `Display + edit-режим. Данные с backend GET /api/admin/system-settings.`
|
||||
|
||||
- [ ] **Step 2: AdminTenantsView.vue — init пустой**
|
||||
|
||||
Импорт (строка 18) — убрать `MOCK_STATS`, `MOCK_TENANTS`:
|
||||
|
||||
```ts
|
||||
import { type AdminTenant, type TenantStatus } from '../../composables/mockTenants';
|
||||
```
|
||||
|
||||
`tenantsState` (строка 32):
|
||||
|
||||
```ts
|
||||
const tenantsState = reactive<AdminTenant[]>([]);
|
||||
```
|
||||
|
||||
`stats` (строка 33) — заменить `{ ...MOCK_STATS }` объектом с теми же ключами в нулях. **Сверить точную форму `MOCK_STATS` в `composables/mockTenants.ts`** (`loadTenants` пишет `total/active/trial/overdue`):
|
||||
|
||||
```ts
|
||||
const stats = reactive({ total: 0, active: 0, trial: 0, overdue: 0 });
|
||||
```
|
||||
|
||||
Alert `fetch-error-alert` (строки ~117-127) — текст: `Не удалось загрузить тенантов. Попробуйте обновить.`
|
||||
|
||||
- [ ] **Step 3: Тесты — починить + regression**
|
||||
|
||||
`AdminTenantsViewApi.spec.ts` — проверить на «MOCK fallback»-ассерты после reject, инвертировать на `length === 0`.
|
||||
Smoke-спеки `AdminSystemView.spec.ts` / `AdminTenantsView.spec.ts` — рендер строк из mock-init: Вариант A (мок `adminApi.listSystemSettings`/`listAdminTenants` резолвом фикстуры) или B (seed `vm.settingsState`/`vm.tenantsState`). Regression-тест: reject → state пустой + ошибка видна (`AdminSystemView.fetchError` — это `string|null`, при ошибке непустая строка; `AdminTenantsView.fetchError` — boolean).
|
||||
|
||||
- [ ] **Step 4: Полный прогон + линт**
|
||||
|
||||
Run: `cd app && npm run test:vue -- AdminSystem AdminTenants && npm run lint:vue && npm run type-check`
|
||||
Expected: PASS 0 fail, ESLint 0, vue-tsc 0.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/admin/AdminSystemView.vue app/resources/js/views/admin/AdminTenantsView.vue app/tests/Frontend/
|
||||
git commit -m "fix(admin): I3 — убрать mock fallback в System/Tenants"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: I4 — ImpersonationDialog devPlainCode за DEV-gate
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/components/admin/ImpersonationDialog.vue`
|
||||
- Test: `app/tests/Frontend/ImpersonationDialog*.spec.ts` (уточнить `ls`)
|
||||
|
||||
Контекст: баннер `data-testid="dev-code-banner"` (строки ~218-228) показывает `_dev_plain_code` (плейн-код impersonation) при `v-if="devPlainCode"`. Сейчас гейт — только наличие данных (backend на prod не отдаёт `_dev_plain_code`). Аудит I4: добавить явный frontend DEV-gate, чтобы баннер не отрисовывался в prod-сборке даже если бэк случайно отдаст код.
|
||||
|
||||
- [ ] **Step 1: Regression-тест (red) — баннер скрыт в prod**
|
||||
|
||||
В spec ImpersonationDialog добавить тест: `vi.stubEnv('DEV', false)` **до** mount → пройти flow до step `verify` с непустым `devPlainCode` (замокать `adminApi.impersonationInit` резолвом с `_dev_plain_code: '123456'`) → `expect(wrapper.find('[data-testid="dev-code-banner"]').exists()).toBe(false)`. `afterEach(() => vi.unstubAllEnvs())`.
|
||||
|
||||
- [ ] **Step 2: Прогон — упадёт**
|
||||
|
||||
Run: `cd app && npm run test:vue -- ImpersonationDialog`
|
||||
Expected: новый тест FAIL (баннер рендерится — гейт только по `devPlainCode`).
|
||||
|
||||
- [ ] **Step 3: ImpersonationDialog.vue — DEV-gate**
|
||||
|
||||
В `<script setup>` после `const devPlainCode = ref<string | null>(null);` (строка 49) добавить:
|
||||
|
||||
```ts
|
||||
// I4: явный frontend DEV-gate. import.meta.env.DEV статически заменяется Vite —
|
||||
// в prod-сборке = false, баннер с плейн-кодом tree-shake'ится.
|
||||
const isDevEnv = import.meta.env.DEV;
|
||||
```
|
||||
|
||||
`defineExpose` отсутствует — не добавлять (тест проверяет через DOM).
|
||||
Шаблон, баннер (строка 219) — гейт:
|
||||
|
||||
```html
|
||||
<v-alert
|
||||
v-if="isDevEnv && devPlainCode"
|
||||
```
|
||||
|
||||
Doc-комментарий (строка 8) — уточнить: `На dev показывается _dev_plain_code (за import.meta.env.DEV; на prod — баннер не рендерится).`
|
||||
|
||||
- [ ] **Step 4: Прогон — зелёный**
|
||||
|
||||
Run: `cd app && npm run test:vue -- ImpersonationDialog`
|
||||
Expected: новый тест PASS. Существующие тесты (в Vitest `import.meta.env.DEV === true`) — баннер по-прежнему виден при `devPlainCode`, PASS.
|
||||
|
||||
- [ ] **Step 5: Линт + type-check**
|
||||
|
||||
Run: `cd app && npm run lint:vue && npm run type-check`
|
||||
Expected: ESLint 0, vue-tsc 0.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/admin/ImpersonationDialog.vue app/tests/Frontend/
|
||||
git commit -m "fix(admin): I4 — devPlainCode-баннер за import.meta.env.DEV"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (контроллер, после всех задач)
|
||||
|
||||
- **Покрытие спека:** I3 — 8 production-файлов init→пусто + error-текст (✓ T1-T4). I4 — DEV-gate (✓ T5). I1 — отложен (вне scope).
|
||||
- **Нет fake-data в prod-путях:** grep `MOCK_|ADMIN_` по `resources/js/views` + `resources/js/components/deals` + `ImpersonationDialog` — 0 совпадений в production-импортах (только типы). `mock*.ts` не удалены — типы/константы (`MockDeal`, `DEALS_TABS`, `AdminTenant`...) живы.
|
||||
- **Тесты:** полный `npm run test:vue` зелёный, 0 fail; новые regression-тесты на каждый файл; инвертированы явные «MOCK fallback» ассерты.
|
||||
- **Регрессия:** `npm run lint:vue` 0, `npm run type-check` 0, Pest не затронут (только frontend).
|
||||
@@ -1,387 +0,0 @@
|
||||
# Sprint 6 — P3 Polish + Cleanup Tail Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Закрыть P3-эпики финального спринта portal-audit (`docs/superpowers/specs/2026-05-15-portal-audit-design.md` §3 Sprint 6) — a11y-доводка, гигиена констант, снятие устаревшего dev-workaround'а.
|
||||
|
||||
**Architecture:** 5 независимых XS-эпиков, все — frontend-only (Vue 3.5 SFC + TypeScript), 0 backend / 0 schema / 0 миграций. Каждый эпик правит обособленную группу файлов — пересечений между задачами нет, порядок T1→T5 произвольный, конфликты исключены.
|
||||
|
||||
**Tech Stack:** Vue 3.5 + Vuetify 3.12 + TypeScript, Vitest 4 (тесты), `import.meta.env.DEV` (DEV-гейт, статически вырезается Vite в prod-сборке).
|
||||
|
||||
**Scope-решение по 3 эпикам Sprint 6, НЕ входящим в этот план:**
|
||||
|
||||
- **F5** (`new_device_login` через session-fingerprint) — XL, требует инфраструктуры сессий-фингерпринтов; вне MVP, остаётся в реестре.
|
||||
- **G8** (ImpersonationDialog two-person approval, CTO-15) — требует Б-1 (Yandex 360 SSO); блокирован внешним блокером.
|
||||
- **I2** (dev-indices.json — gitignore-решение) — **отложен вместе с I1**. I1 (снос DevIndexBadge/DevIndexOverlay) заказчик отложил в Sprint 5D до заморозки UI; `dev-indices.json` — генерируемый манифест этой же временной фичи. Решать его git-судьбу до сноса фичи — churn на артефакте, который всё равно удалится. Отложен синхронно с I1.
|
||||
|
||||
**Эпики в этом плане (5):** A9, B6, F4, G9, I5.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Задача | Ответственность |
|
||||
|---|---|---|
|
||||
| `app/resources/js/views/auth/LoginView.vue` | T1 (A9) | eye-toggle через `#append-inner` slot с accessible-name |
|
||||
| `app/resources/js/views/auth/RegisterView.vue` | T1 (A9) | то же |
|
||||
| `app/resources/js/views/auth/ResetPasswordView.vue` | T1 (A9) | то же (только первое поле пароля; поле «Повторите» eye-иконки не имеет) |
|
||||
| `app/resources/js/layouts/AdminLayout.vue` | T2 (B6) | DEV-only баннер о застабленном auth-gate |
|
||||
| `app/resources/js/constants/polling.ts` | T3 (F4) | **создаётся** — именованные интервалы polling |
|
||||
| `app/resources/js/composables/usePolling.ts` | T3 (F4) | дефолт интервала из константы |
|
||||
| `app/resources/js/layouts/AppLayout.vue` | T3 (F4) | call-site → константы |
|
||||
| `app/resources/js/components/admin/ImpersonationBanner.vue` | T3 (F4) | call-site → константа |
|
||||
| `app/resources/js/views/ReportsView.vue` | T3 (F4) | call-site → константа |
|
||||
| `app/resources/js/views/admin/AdminSystemView.vue` | T4 (G9) | aria-label на edit-кнопки |
|
||||
| `app/resources/js/views/ProjectsView.vue` | T5 (I5) | удаление устаревшего clearable-workaround'а из `<style>` |
|
||||
| spec-файлы в `app/tests/Frontend/` | T1/T2/T4 | failing-тесты + сверка существующих |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: A9 — accessible-name на eye-icon переключателях пароля
|
||||
|
||||
**Контекст:** В `LoginView`/`RegisterView`/`ResetPasswordView` поле пароля переключает видимость через Vuetify-проп `:append-inner-icon` + `@click:append-inner`. Иконка-переключатель кликабельна, но не имеет accessible-name и не доступна с клавиатуры → screen-reader пользователь не знает, что это кнопка. Фикс — заменить проп на слот `#append-inner` с `<v-icon>` в роли кнопки: `role="button"` + `tabindex` + `:aria-label` + keyboard-обработчики.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/auth/LoginView.vue:81-93`
|
||||
- Modify: `app/resources/js/views/auth/RegisterView.vue:97-109`
|
||||
- Modify: `app/resources/js/views/auth/ResetPasswordView.vue:107-119`
|
||||
- Test: `app/tests/Frontend/LoginView.spec.ts`, `RegisterView.spec.ts`, `ResetPasswordView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Сверить существующие spec-файлы**
|
||||
|
||||
Прочитать 3 spec-файла. Найти тесты, взаимодействующие с переключателем (grep `append-inner`, `showPassword`, `eye`). Если тест триггерит `click:append-inner` — после рефактора это событие не возникнет (теперь клик по `<v-icon>` в слоте), такие тесты переписать на клик по иконке с aria-label. Запомнить mount-setup (плагины Vuetify/Pinia/router-стабы) для нового теста.
|
||||
|
||||
- [ ] **Step 2: Написать failing-тест (для каждой из 3 вью)**
|
||||
|
||||
В каждый spec добавить тест (mount-setup взять из существующих тестов файла):
|
||||
|
||||
```ts
|
||||
it('переключатель видимости пароля имеет accessible-name и работает', async () => {
|
||||
const wrapper = mount(LoginView, { /* global: из существующего setup файла */ });
|
||||
const toggle = wrapper.find('[aria-label="Показать пароль"]');
|
||||
expect(toggle.exists()).toBe(true);
|
||||
expect(toggle.attributes('role')).toBe('button');
|
||||
await toggle.trigger('click');
|
||||
expect(wrapper.find('[aria-label="Скрыть пароль"]').exists()).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
Для `RegisterView`/`ResetPasswordView` — аналогично, заменив компонент.
|
||||
|
||||
- [ ] **Step 3: Прогнать тесты — убедиться, что падают**
|
||||
|
||||
Run: `cd app && npm run test:vue -- LoginView RegisterView ResetPasswordView`
|
||||
Expected: 3 новых теста FAIL (`aria-label="Показать пароль"` не найден — сейчас проп `append-inner-icon`).
|
||||
|
||||
- [ ] **Step 4: Реализовать слот в LoginView.vue**
|
||||
|
||||
Заменить блок `app/resources/js/views/auth/LoginView.vue:81-93` (поле пароля):
|
||||
|
||||
```vue
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
label="Пароль"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
placeholder="Минимум 8 символов"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
required
|
||||
:error-messages="errors.password"
|
||||
>
|
||||
<template #append-inner>
|
||||
<v-icon
|
||||
:icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
:aria-label="showPassword ? 'Скрыть пароль' : 'Показать пароль'"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="showPassword = !showPassword"
|
||||
@keydown.enter.prevent="showPassword = !showPassword"
|
||||
@keydown.space.prevent="showPassword = !showPassword"
|
||||
/>
|
||||
</template>
|
||||
</v-text-field>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Реализовать слот в RegisterView.vue**
|
||||
|
||||
Заменить блок `app/resources/js/views/auth/RegisterView.vue:97-109` тем же паттерном (поле пароля, `autocomplete="new-password"` — сохранить, `placeholder="Минимум 8 символов"` — сохранить). Снять строки `:append-inner-icon="..."` и `@click:append-inner="..."`, добавить `#append-inner`-слот как в Step 4.
|
||||
|
||||
- [ ] **Step 6: Реализовать слот в ResetPasswordView.vue**
|
||||
|
||||
Заменить блок `app/resources/js/views/auth/ResetPasswordView.vue:107-119` (первое поле — «Новый пароль») тем же паттерном. **Поле «Повторите пароль» (`:122-130`) не трогать** — у него нет eye-иконки, оно наследует `showPassword`.
|
||||
|
||||
- [ ] **Step 7: Прогнать тесты — убедиться, что зелёные**
|
||||
|
||||
Run: `cd app && npm run test:vue -- LoginView RegisterView ResetPasswordView`
|
||||
Expected: PASS — новые 3 теста + все ранее существовавшие в этих файлах.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/auth/LoginView.vue app/resources/js/views/auth/RegisterView.vue app/resources/js/views/auth/ResetPasswordView.vue app/tests/Frontend/LoginView.spec.ts app/tests/Frontend/RegisterView.spec.ts app/tests/Frontend/ResetPasswordView.spec.ts
|
||||
git commit -m "fix(a11y): accessible eye-toggle на полях пароля — Sprint 6 A9"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: B6 — DEV-only баннер о застабленном auth-gate админки
|
||||
|
||||
**Контекст:** Sprint 3F (J2) поставил middleware `EnsureSaasAdmin` на `/api/admin/*` как стаб: в dev пропускает все запросы, в prod отдаёт 503. Комментарий в шапке `AdminLayout.vue:9-12` фиксирует, что полноценный auth-guard (`super_admin` role + 2FA через Yandex 360 SSO) ждёт Б-1. B6 — сделать этот auth-gap видимым в dev-UI баннером. Гейт — `import.meta.env.DEV` (Vite статически вырежет баннер в prod-сборке, паттерн I4 из Sprint 5D).
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/layouts/AdminLayout.vue` (script + template)
|
||||
- Test: `app/tests/Frontend/AdminLayout.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Сверить AdminLayout.spec.ts**
|
||||
|
||||
Прочитать spec — запомнить mount-setup. Проверить наличие `vi.unstubAllEnvs()` в `afterEach` (если нет — добавить в Step 2, иначе stub `DEV` протечёт в другие тесты).
|
||||
|
||||
- [ ] **Step 2: Написать failing-тест**
|
||||
|
||||
В `AdminLayout.spec.ts` добавить (mount-setup — из существующих тестов):
|
||||
|
||||
```ts
|
||||
it('B6: показывает DEV-баннер auth-gap в dev-режиме', () => {
|
||||
const wrapper = mount(AdminLayout, { /* global: из существующего setup */ });
|
||||
expect(wrapper.find('[data-testid="dev-auth-gap-banner"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('B6: скрывает DEV-баннер в production-режиме', () => {
|
||||
vi.stubEnv('DEV', false);
|
||||
const wrapper = mount(AdminLayout, { /* global: из существующего setup */ });
|
||||
expect(wrapper.find('[data-testid="dev-auth-gap-banner"]').exists()).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
Убедиться, что в файле есть `afterEach(() => { vi.unstubAllEnvs(); });` (добавить, если отсутствует).
|
||||
|
||||
- [ ] **Step 3: Прогнать тест — убедиться, что падает**
|
||||
|
||||
Run: `cd app && npm run test:vue -- AdminLayout`
|
||||
Expected: тест `показывает DEV-баннер` FAIL (`data-testid="dev-auth-gap-banner"` не найден).
|
||||
|
||||
- [ ] **Step 4: Добавить DEV-флаг в script**
|
||||
|
||||
В `app/resources/js/layouts/AdminLayout.vue` после `const auth = useAuthStore();` (`:39`) добавить:
|
||||
|
||||
```ts
|
||||
|
||||
/** DEV-режим: показываем баннер о застабленном auth-gate админки (B6). */
|
||||
const isDevEnv = import.meta.env.DEV;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Добавить баннер в template**
|
||||
|
||||
В `<v-main class="admin-main">` (`:133`) — перед `<ImpersonationBanner />` вставить:
|
||||
|
||||
```vue
|
||||
<v-alert
|
||||
v-if="isDevEnv"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="ma-4"
|
||||
data-testid="dev-auth-gap-banner"
|
||||
>
|
||||
DEV-режим: доступ к админке открыт без SSO-проверки — middleware
|
||||
<code>EnsureSaasAdmin</code> в dev пропускает все запросы. В production
|
||||
требуется вход через Yandex 360 + роль <code>super_admin</code> (Б-1);
|
||||
неавторизованные запросы получают 503.
|
||||
</v-alert>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Прогнать тест — убедиться, что зелёные**
|
||||
|
||||
Run: `cd app && npm run test:vue -- AdminLayout`
|
||||
Expected: PASS — оба новых теста + ранее существовавшие.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/layouts/AdminLayout.vue app/tests/Frontend/AdminLayout.spec.ts
|
||||
git commit -m "feat(admin): DEV-only баннер о застабленном auth-gate — Sprint 6 B6"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: F4 — вынести polling-интервалы в `constants/polling.ts`
|
||||
|
||||
**Контекст:** Дефолтный интервал `30_000` зашит в `usePolling.ts`, а call-site'ы `AppLayout`/`ImpersonationBanner`/`ReportsView` дублируют литералы `30_000`/`60_000`. F4 — собрать «магические» числа в один модуль. Чистый рефактор: поведение не меняется, защитная сетка — существующие тесты.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/resources/js/constants/polling.ts`
|
||||
- Modify: `app/resources/js/composables/usePolling.ts:18,25`
|
||||
- Modify: `app/resources/js/layouts/AppLayout.vue:17,60,61`
|
||||
- Modify: `app/resources/js/components/admin/ImpersonationBanner.vue:16,40`
|
||||
- Modify: `app/resources/js/views/ReportsView.vue:14,62`
|
||||
|
||||
- [ ] **Step 1: Создать `constants/polling.ts`**
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Интервалы polling-обновления view-данных — единый источник «магических»
|
||||
* чисел для usePolling. До приезда SSE/WebSocket в production это покрывает
|
||||
* «real-time»-паттерн (см. composables/usePolling.ts).
|
||||
*/
|
||||
|
||||
/** Базовый интервал авто-обновления (сделки, биллинг, инциденты, тенанты, отчёты). */
|
||||
export const POLLING_INTERVAL_MS = 30_000;
|
||||
|
||||
/** Интервал для менее срочных счётчиков (напоминания в сайдбаре). */
|
||||
export const POLLING_REMINDERS_INTERVAL_MS = 60_000;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Подключить константу в usePolling.ts**
|
||||
|
||||
В `app/resources/js/composables/usePolling.ts`:
|
||||
|
||||
- Первой строкой добавить импорт: `import { POLLING_INTERVAL_MS } from '../constants/polling';` (после `import { onBeforeUnmount, onMounted } from 'vue';`).
|
||||
- Строка `:18` doc-комментарий: `/** Период polling в миллисекундах. По умолчанию 30_000. */` → `/** Период polling в миллисекундах. По умолчанию POLLING_INTERVAL_MS (30 с). */`
|
||||
- Строка `:25`: `const intervalMs = options.intervalMs ?? 30_000;` → `const intervalMs = options.intervalMs ?? POLLING_INTERVAL_MS;`
|
||||
|
||||
- [ ] **Step 3: Обновить call-site'ы**
|
||||
|
||||
`AppLayout.vue` — добавить к импортам (`:17`): `import { POLLING_INTERVAL_MS, POLLING_REMINDERS_INTERVAL_MS } from '../constants/polling';`
|
||||
|
||||
- `:60` `usePolling(loadNotifications, { intervalMs: 30_000, enabled: true });` → `{ intervalMs: POLLING_INTERVAL_MS, enabled: true }`
|
||||
- `:61` `usePolling(loadReminderCounts, { intervalMs: 60_000, enabled: true });` → `{ intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabled: true }`
|
||||
|
||||
`ImpersonationBanner.vue` — добавить импорт `import { POLLING_INTERVAL_MS } from '../../constants/polling';`
|
||||
|
||||
- `:40` `usePolling(load, { intervalMs: 30_000 });` → `{ intervalMs: POLLING_INTERVAL_MS }`
|
||||
|
||||
`ReportsView.vue` — добавить импорт `import { POLLING_INTERVAL_MS } from '../constants/polling';`
|
||||
|
||||
- `:62` `usePolling(loadJobs, { intervalMs: 30_000 });` → `{ intervalMs: POLLING_INTERVAL_MS }`
|
||||
|
||||
Call-site'ы на дефолте (`DealsView`/`KanbanView`/`AdminBillingView`/`AdminIncidentsView`/`AdminTenantsView`) — **не трогать**, они уже получают значение через дефолт `usePolling`.
|
||||
|
||||
- [ ] **Step 4: Type-check + тесты (рефактор — поведение без изменений)**
|
||||
|
||||
Run: `cd app && npm run type-check && npm run test:vue -- usePolling AppLayout ImpersonationBanner ReportsView`
|
||||
Expected: vue-tsc 0 ошибок; все тесты PASS без изменений (интервалы численно те же — `30_000`/`60_000`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/constants/polling.ts app/resources/js/composables/usePolling.ts app/resources/js/layouts/AppLayout.vue app/resources/js/components/admin/ImpersonationBanner.vue app/resources/js/views/ReportsView.vue
|
||||
git commit -m "refactor(polling): вынести интервалы в constants/polling.ts — Sprint 6 F4"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: G9 — aria-label на edit-кнопки AdminSystemView
|
||||
|
||||
**Контекст:** В списке `system_settings` каждая строка имеет кнопку «Изменить» (`AdminSystemView.vue:166-175`). У всех кнопок одинаковый видимый текст «Изменить» — screen-reader пользователь, проходя список, слышит «Изменить, Изменить, Изменить» без контекста, какая настройка. Фикс — `:aria-label` с ключом настройки.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/admin/AdminSystemView.vue:166-175`
|
||||
- Test: `app/tests/Frontend/AdminSystemView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать failing-тест**
|
||||
|
||||
В `AdminSystemView.spec.ts` добавить (mount-setup — из существующих тестов файла; компонент стартует с mock-данными до `onMounted`-загрузки, либо использовать `defineExpose`d `settingsState`):
|
||||
|
||||
```ts
|
||||
it('G9: edit-кнопки имеют aria-label с ключом настройки', () => {
|
||||
const wrapper = mount(AdminSystemView, { /* global: из существующего setup */ });
|
||||
const editBtns = wrapper.findAll('[data-testid^="edit-"]');
|
||||
expect(editBtns.length).toBeGreaterThan(0);
|
||||
for (const btn of editBtns) {
|
||||
const label = btn.attributes('aria-label') ?? '';
|
||||
expect(label).toMatch(/^Изменить настройку .+/);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать тест — убедиться, что падает**
|
||||
|
||||
Run: `cd app && npm run test:vue -- AdminSystemView`
|
||||
Expected: FAIL — `aria-label` отсутствует на кнопках.
|
||||
|
||||
- [ ] **Step 3: Добавить aria-label**
|
||||
|
||||
В `app/resources/js/views/admin/AdminSystemView.vue` в `<v-btn>` (`:166-175`) добавить строку `:aria-label` между `prepend-icon` и `:data-testid`:
|
||||
|
||||
```vue
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
density="comfortable"
|
||||
prepend-icon="mdi-pencil"
|
||||
:aria-label="`Изменить настройку ${setting.key}`"
|
||||
:data-testid="`edit-${setting.key}-btn`"
|
||||
@click="openEdit(setting)"
|
||||
>
|
||||
Изменить
|
||||
</v-btn>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Прогнать тест — убедиться, что зелёный**
|
||||
|
||||
Run: `cd app && npm run test:vue -- AdminSystemView`
|
||||
Expected: PASS — новый тест + ранее существовавшие.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/admin/AdminSystemView.vue app/tests/Frontend/AdminSystemView.spec.ts
|
||||
git commit -m "fix(a11y): aria-label с ключом на edit-кнопках AdminSystem — Sprint 6 G9"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: I5 — снять устаревший clearable-workaround из ProjectsView
|
||||
|
||||
**Контекст:** `ProjectsView.vue:170-196` содержит CSS-workaround: у `clearable` `v-text-field` иконка `mdi-close-circle` делалась прозрачной, а вместо неё `::after`-псевдоэлементом рисовался Unicode-глиф `✕` — потому что MDI-шрифт не был подключён (Диз-4). CTO-19 (миграция на Lucide) закрыта: `app/resources/js/plugins/vuetify.ts:164` маппит `'mdi-close-circle': XCircle` — clearable-иконка теперь рендерится нативным Lucide-SVG. Workaround мёртв → удалить.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/ProjectsView.vue` (удаление CSS-блока `:170-196`)
|
||||
|
||||
- [ ] **Step 1: Проверить премису (фальсифицировать перед удалением)**
|
||||
|
||||
Подтвердить, что `app/resources/js/plugins/vuetify.ts` содержит `'mdi-close-circle': XCircle` в Lucide IconSet-маппинге и `XCircle` импортирован из `lucide-vue-next`. Если маппинга нет — задача **BLOCKED**, эскалировать (workaround снимать нельзя без замены).
|
||||
|
||||
- [ ] **Step 2: Удалить CSS-блок workaround'а**
|
||||
|
||||
В `app/resources/js/views/ProjectsView.vue` удалить строки `:170-196` целиком — комментарий-заголовок `/* Workaround: MDI-шрифт... */` и 4 CSS-правила: `.projects-view :deep(.v-field__clearable)`, `.projects-view :deep(.v-field__clearable .v-icon)`, `.projects-view :deep(.v-field--dirty .v-field__clearable)::after`, `.projects-view :deep(.v-field--dirty .v-field__clearable:hover)::after`. Соседние блоки (`.projects-grid` выше, `.toolbar-check` ниже) не трогать.
|
||||
|
||||
- [ ] **Step 3: Type-check + сборка + существующие тесты**
|
||||
|
||||
Run: `cd app && npm run type-check && npm run test:vue -- ProjectsView`
|
||||
Expected: vue-tsc 0; `ProjectsView.spec.ts` PASS без изменений (правка чисто CSS, JS-поведение не затронуто).
|
||||
|
||||
- [ ] **Step 4: Визуальный smoke (Playwright)**
|
||||
|
||||
Запустить dev-сервер, открыть `/projects`, ввести текст в поле поиска проектов (`clearable`). Подтвердить: иконка очистки (Lucide `XCircle`) **видима** справа в поле и клик по ней очищает значение. Сделать скриншот. Если иконка не рендерится — premise опровергнута, `git revert` Step 2 и эскалировать.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/ProjectsView.vue
|
||||
git commit -m "chore(cleanup): снять устаревший MDI clearable-workaround (CTO-19 tail) — Sprint 6 I5"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Финальная верификация (после всех 5 задач)
|
||||
|
||||
- [ ] **Полная регрессия Vitest** — `cd app && npm run test:vue -- --maxWorkers=2` (full-suite без `--maxWorkers=2` OOM'ит в worktree — квирк 98). Ожидаемо: 0 fail; число passed ≥ baseline + новые тесты (T1 ×3, T2 ×2, T4 ×1).
|
||||
- [ ] **vue-tsc** — `cd app && npm run type-check` → 0 ошибок.
|
||||
- [ ] **ESLint** — `cd app && npm run lint:vue` → известная pre-existing ошибка `tests/Frontend/ImportView.spec.ts:4` (Sprint 4 долг, вне scope Sprint 6); 0 новых.
|
||||
- [ ] **Vite build** — `cd app && npm run build` → OK (подтверждает, что DEV-гейт B6 валиден для prod-сборки).
|
||||
- [ ] **Pest** опционально — Sprint 6 не трогает PHP-файлы (0 backend-изменений), backend-регрессия структурно невозможна; полный прогон Pest — belt, не обязателен.
|
||||
- [ ] **Финальный holistic code-review** всего диффа Sprint 6.
|
||||
- [ ] **Pre-push:** gitleaks-full-history + lychee (lefthook не в PATH worktree — прогонять вручную).
|
||||
|
||||
## Self-Review (выполнено при написании плана)
|
||||
|
||||
- **Spec coverage:** Sprint 6 §3 спека = 8 эпиков. A9/B6/F4/G9/I5 — в плане (5 задач). F5/G8 — внешне блокированы (инфра/Б-1). I2 — отложен с I1 (решение заказчика Sprint 5D). Покрытие полное и обоснованное.
|
||||
- **Placeholder-скан:** весь production-код приведён дословно (file:line); тест-код — с конкретными ассертами, mount-setup берётся из существующих spec-файлов (они прочитаны на Step 1 каждой задачи).
|
||||
- **Type consistency:** `POLLING_INTERVAL_MS` / `POLLING_REMINDERS_INTERVAL_MS` — единые имена в T3 (создание + 4 call-site). `data-testid="dev-auth-gap-banner"` — единое имя в T2 (template + 2 теста). `isDevEnv` — единое имя в T2.
|
||||
@@ -1,885 +0,0 @@
|
||||
# Deals drawer + project source edit — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Привести drawer-«легенду» сделки и карточку проекта к запросу заказчика 18.05.2026 — статус-picker, корректные параметры (Тип/Источник), selected-driven видимость drawer/bulk-полосы, редактирование источника проекта.
|
||||
|
||||
**Architecture:** 5 атомарных задач (1 коммит = 1 task). Frontend-only задачи 1-4 (Vue/TS). Задача 5 расширяет backend (UpdateProjectRequest + ProjectController) + UI ProjectDetailsDrawer. TDD per task: failing test → minimal impl → vitest/pest → commit.
|
||||
|
||||
**Tech Stack:** Vue 3 + Vuetify 3 + Pinia, Laravel 13 + Pest 4, axios + ApiClient.
|
||||
|
||||
**Источник истины** для решений: AskUserQuestion ответы 18.05.2026:
|
||||
|
||||
- п.1: «при выборе 1 сделки она не нужна, нужна только легенда справа»
|
||||
- п.2: «при выборе 2-х и более легенда не нужна а полоса нужна»
|
||||
- п.3: статус в drawer кликабельный, dropdown статусов
|
||||
- п.4: убрать «Менеджер»/«Не назначен»
|
||||
- п.5: B-префикс уже убран (commit `36ea9cd`)
|
||||
- п.6: формат «отправитель + (ключевое слово как в карточке создания)» = `signal_identifier` для site/call; для sms — `sms_senders[0]` + `(${sms_keyword})` если есть
|
||||
- п.7: «Тип» (Сайт/Звонок/СМС) вместо «Менеджер»
|
||||
- п.8: подпись «Источник» над полями на 3 табах NewProjectDialog
|
||||
- п.9: редактировать источник **только в карточке проекта** (ProjectDetailsDrawer на /projects); в drawer сделки источник read-only
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Что делает |
|
||||
|---|---|
|
||||
| `app/resources/js/views/DealsView.vue` | Selected-driven: drawer hidden при ≥2 selected; auto-open при selected=1 |
|
||||
| `app/resources/js/components/deals/DealDetailHero.vue` | StatusPill → inline statuspicker (`v-menu` со списком статусов) |
|
||||
| `app/resources/js/components/deals/DealDetailBody.vue` | Убрать «Менеджер», добавить «Тип» + «Источник» (read-only) |
|
||||
| `app/resources/js/composables/mockDeals.ts` | +поля projectSignalType / projectSignalIdentifier / projectSmsSenders / projectSmsKeyword |
|
||||
| `app/resources/js/composables/dealsApiMapper.ts` | Маппинг новых API-полей |
|
||||
| `app/resources/js/api/deals.ts` | Расширить ApiDeal интерфейс новыми полями |
|
||||
| `app/app/Http/Controllers/Api/DealController.php` | Eager-load + отдавать новые поля проекта в payload |
|
||||
| `app/resources/js/views/projects/NewProjectDialog.vue` | Подпись «Источник» над полями на 3 табах |
|
||||
| `app/resources/js/components/projects/ProjectDetailsDrawer.vue` | Добавить редактирование signal_identifier (site/call) + sms_senders/keyword (sms) |
|
||||
| `app/app/Http/Requests/UpdateProjectRequest.php` | +правила валидации signal_identifier по signal_type проекта |
|
||||
| `app/app/Http/Controllers/Api/ProjectController.php` | update() — пропустить signal_identifier в Project::update |
|
||||
| `app/resources/js/stores/projectsStore.ts` | Project type — поле signal_identifier ОК; проверить только |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Selected-driven drawer visibility (пп. 1+2)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/DealsView.vue` (полная логика panelOpen ↔ selected.length)
|
||||
- Test: `app/tests/Frontend/DealsView.spec.ts` (расширить существующий)
|
||||
|
||||
**Логика:**
|
||||
|
||||
- `selected.length === 0` → row-click открывает drawer (как сейчас)
|
||||
- `selected.length === 1` → drawer **авто-открыт для этой сделки**, bulk-полоса **скрыта**
|
||||
- `selected.length >= 2` → drawer **закрыт**, bulk-полоса видна
|
||||
|
||||
DealsBulkBar уже показывается только при `selectedCount > 0` (нужно перепроверить — возможно показать при `>= 2` only).
|
||||
|
||||
- [ ] **Step 1: Failing test для авто-открытия при selected=1**
|
||||
|
||||
В `app/tests/Frontend/DealsView.spec.ts` добавить:
|
||||
|
||||
```ts
|
||||
it('при selected=1 drawer авто-открывается на выбранной сделке, bulk-полоса скрыта', async () => {
|
||||
const w = mount(DealsView, { global: { plugins: [vuetify, createPinia()] } });
|
||||
await flushPromises();
|
||||
w.vm.dealsState.push({ id: 42, name: 'X', phone: '+79991234567', statusSlug: 'new', project: 'p', manager: { initials: 'A', name: 'A' }, cost: 0, receivedMinutesAgo: 0 } as never);
|
||||
w.vm.selected = [42];
|
||||
await nextTick();
|
||||
expect(w.vm.panelOpen).toBe(true);
|
||||
expect(w.vm.selectedDeal?.id).toBe(42);
|
||||
});
|
||||
|
||||
it('при selected>=2 drawer закрывается', async () => {
|
||||
const w = mount(DealsView, { global: { plugins: [vuetify, createPinia()] } });
|
||||
await flushPromises();
|
||||
w.vm.dealsState.push({ id: 42, name: 'X', phone: '+1', statusSlug: 'new', project: 'p', manager: { initials: 'A', name: 'A' }, cost: 0, receivedMinutesAgo: 0 } as never);
|
||||
w.vm.dealsState.push({ id: 43, name: 'Y', phone: '+2', statusSlug: 'new', project: 'p', manager: { initials: 'A', name: 'A' }, cost: 0, receivedMinutesAgo: 0 } as never);
|
||||
w.vm.panelOpen = true;
|
||||
w.vm.selectedDeal = w.vm.dealsState[0];
|
||||
w.vm.selected = [42, 43];
|
||||
await nextTick();
|
||||
expect(w.vm.panelOpen).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — должен FAIL**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/DealsView.spec.ts -t "при selected" --reporter=verbose`
|
||||
|
||||
- [ ] **Step 3: Добавить watcher в DealsView.vue**
|
||||
|
||||
Найти `watch([filterStatus, filterProject, receivedFrom, receivedTo, perPage], …)` (~строка 108) и **после** добавить:
|
||||
|
||||
```ts
|
||||
// Selected-driven drawer visibility (18.05.2026 ux-request):
|
||||
// 0 selected → drawer по row-click; 1 selected → авто-открыт для этой сделки;
|
||||
// ≥2 selected → закрыт (показывается bulk-полоса).
|
||||
watch(selected, (ids) => {
|
||||
if (ids.length === 1) {
|
||||
const deal = dealsState.find((d) => d.id === ids[0]);
|
||||
if (deal) {
|
||||
selectedDeal.value = deal;
|
||||
panelOpen.value = true;
|
||||
}
|
||||
} else if (ids.length >= 2) {
|
||||
panelOpen.value = false;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
И **скрыть bulk-полосу при selected=1** — изменить отображение DealsBulkBar:
|
||||
|
||||
```vue
|
||||
<DealsBulkBar
|
||||
v-if="selected.length >= 2"
|
||||
v-model:status-menu-open="statusMenuOpen"
|
||||
:selected-count="selected.length"
|
||||
:lead-statuses="leadStatuses"
|
||||
@apply-status="applyBulkStatus"
|
||||
@clear-selected="selected = []"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Vitest пройти GREEN**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/DealsView.spec.ts --reporter=default`
|
||||
Expected: все passes, в т.ч. 2 новых.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/DealsView.vue app/tests/Frontend/DealsView.spec.ts
|
||||
git commit -m "feat(deals): drawer виден при selected≤1, bulk-полоса только при ≥2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: API: project source fields в drawer сделки (пп. 4+6+7)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/DealController.php` (eager-load + payload)
|
||||
- Modify: `app/resources/js/api/deals.ts` (ApiDeal +4 поля)
|
||||
- Modify: `app/resources/js/composables/mockDeals.ts` (MockDeal +4 поля)
|
||||
- Modify: `app/resources/js/composables/dealsApiMapper.ts` (маппер +4 поля)
|
||||
- Modify: `app/resources/js/components/deals/DealDetailBody.vue` (UI: убрать Менеджер, +Тип, +Источник)
|
||||
- Test: `app/tests/Feature/Deals/DealShowEndpointTest.php` или подобный для controller; `app/tests/Frontend/DealDetailBody.spec.ts` если есть, иначе расширить DealDetailDrawer.spec.ts
|
||||
|
||||
- [ ] **Step 1: Pest failing для API payload**
|
||||
|
||||
В существующем тесте `app/tests/Feature/Deals/*.php` для GET /api/deals/{id} добавить assertion:
|
||||
|
||||
```php
|
||||
it('returns project signal_identifier/sms_keyword/sms_senders in deal payload', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'sms',
|
||||
'signal_identifier' => 'MTS',
|
||||
'sms_senders' => ['MTS', 'BEELINE'],
|
||||
'sms_keyword' => 'КРЕДИТ',
|
||||
]);
|
||||
$deal = Deal::factory()->create(['tenant_id' => $tenant->id, 'project_id' => $project->id]);
|
||||
|
||||
actingAsTenant($tenant);
|
||||
$resp = $this->getJson("/api/deals/{$deal->id}?tenant_id={$tenant->id}");
|
||||
|
||||
$resp->assertOk()->assertJsonPath('deal.project_signal_identifier', 'MTS');
|
||||
$resp->assertJsonPath('deal.project_sms_keyword', 'КРЕДИТ');
|
||||
$resp->assertJsonPath('deal.project_sms_senders.0', 'MTS');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить — FAIL**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest --filter "returns project signal_identifier"
|
||||
```
|
||||
|
||||
Expected: FAIL (поля отсутствуют в payload).
|
||||
|
||||
- [ ] **Step 3: Расширить DealController eager-load + transformer**
|
||||
|
||||
В `app/app/Http/Controllers/Api/DealController.php`:
|
||||
|
||||
- Строка 112 (`->with(['project:id,name,signal_type', ...])`) → расширить:
|
||||
`->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']);`
|
||||
- Строка 215 (`'project_signal_type' => …`) → добавить ниже:
|
||||
|
||||
```php
|
||||
'project_signal_identifier' => $d->project?->signal_identifier,
|
||||
'project_sms_keyword' => $d->project?->sms_keyword,
|
||||
'project_sms_senders' => $d->project?->sms_senders,
|
||||
```
|
||||
|
||||
Найти аналогичные места в `show()` методе (если есть) и добавить там же.
|
||||
|
||||
- [ ] **Step 4: Pest passes**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest --filter "returns project signal_identifier" -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Расширить TypeScript интерфейсы**
|
||||
|
||||
В `app/resources/js/api/deals.ts` интерфейс `ApiDeal` (строка 153) — добавить:
|
||||
|
||||
```ts
|
||||
project_signal_identifier: string | null;
|
||||
project_sms_keyword: string | null;
|
||||
project_sms_senders: string[] | null;
|
||||
```
|
||||
|
||||
В `app/resources/js/composables/mockDeals.ts` интерфейс `MockDeal` (строка 10) — добавить:
|
||||
|
||||
```ts
|
||||
projectSignalType?: 'site' | 'call' | 'sms' | null;
|
||||
projectSignalIdentifier?: string | null;
|
||||
projectSmsKeyword?: string | null;
|
||||
projectSmsSenders?: string[] | null;
|
||||
```
|
||||
|
||||
В `app/resources/js/composables/dealsApiMapper.ts` функция `mapApiDeal` — добавить маппинг новых полей.
|
||||
**ПРИМЕЧАНИЕ:** прочитать актуальный файл перед правкой, см. поля `signalType: d.project_signal_type as MockDeal['signalType']` — добавить аналогично:
|
||||
|
||||
```ts
|
||||
projectSignalType: d.project_signal_type as MockDeal['projectSignalType'],
|
||||
projectSignalIdentifier: d.project_signal_identifier,
|
||||
projectSmsKeyword: d.project_sms_keyword,
|
||||
projectSmsSenders: d.project_sms_senders,
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Failing Vitest test для UI Drawer**
|
||||
|
||||
Создать `app/tests/Frontend/DealDetailBody.spec.ts` (или расширить, если есть):
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import DealDetailBody from '../../resources/js/components/deals/DealDetailBody.vue';
|
||||
import type { MockDeal } from '../../resources/js/composables/mockDeals';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
setActivePinia(createPinia());
|
||||
|
||||
function makeDeal(overrides: Partial<MockDeal> = {}): MockDeal {
|
||||
return {
|
||||
id: 1, name: 'A', phone: '+79991234567', statusSlug: 'new',
|
||||
project: 'p', manager: { initials: 'AD', name: 'A' }, cost: 0,
|
||||
receivedMinutesAgo: 1,
|
||||
projectSignalType: 'site', projectSignalIdentifier: 'krk-finance.ru',
|
||||
projectSmsKeyword: null, projectSmsSenders: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DealDetailBody — Тип и Источник (18.05.2026)', () => {
|
||||
it('показывает Тип «Сайт» и Источник = signal_identifier для site', () => {
|
||||
const w = mount(DealDetailBody, {
|
||||
props: { deal: makeDeal() },
|
||||
global: { plugins: [vuetify, createPinia()] },
|
||||
});
|
||||
expect(w.text()).toContain('Сайт');
|
||||
expect(w.text()).toContain('krk-finance.ru');
|
||||
});
|
||||
|
||||
it('для sms показывает sender + (keyword)', () => {
|
||||
const w = mount(DealDetailBody, {
|
||||
props: { deal: makeDeal({
|
||||
projectSignalType: 'sms',
|
||||
projectSignalIdentifier: null,
|
||||
projectSmsSenders: ['MTS', 'BEELINE'],
|
||||
projectSmsKeyword: 'КРЕДИТ',
|
||||
}) },
|
||||
global: { plugins: [vuetify, createPinia()] },
|
||||
});
|
||||
expect(w.text()).toContain('СМС');
|
||||
expect(w.text()).toContain('MTS (КРЕДИТ)');
|
||||
});
|
||||
|
||||
it('для sms без keyword показывает только sender', () => {
|
||||
const w = mount(DealDetailBody, {
|
||||
props: { deal: makeDeal({
|
||||
projectSignalType: 'sms',
|
||||
projectSignalIdentifier: null,
|
||||
projectSmsSenders: ['MTS'],
|
||||
projectSmsKeyword: null,
|
||||
}) },
|
||||
global: { plugins: [vuetify, createPinia()] },
|
||||
});
|
||||
expect(w.text()).toContain('СМС');
|
||||
expect(w.text()).toContain('MTS');
|
||||
expect(w.text()).not.toMatch(/\([^)]*\)/);
|
||||
});
|
||||
|
||||
it('не отображает «Менеджер» секцию', () => {
|
||||
const w = mount(DealDetailBody, {
|
||||
props: { deal: makeDeal() },
|
||||
global: { plugins: [vuetify, createPinia()] },
|
||||
});
|
||||
expect(w.text()).not.toContain('Менеджер');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run — FAIL**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/DealDetailBody.spec.ts --reporter=verbose`
|
||||
|
||||
Expected: 4 fails.
|
||||
|
||||
- [ ] **Step 8: Реализация DealDetailBody.vue**
|
||||
|
||||
В `app/resources/js/components/deals/DealDetailBody.vue`:
|
||||
|
||||
1. Добавить helpers в `<script setup>`:
|
||||
|
||||
```ts
|
||||
const TYPE_LABELS: Record<string, string> = { site: 'Сайт', call: 'Звонок', sms: 'СМС' };
|
||||
const projectTypeLabel = computed((): string =>
|
||||
props.deal?.projectSignalType ? (TYPE_LABELS[props.deal.projectSignalType] ?? '—') : '—',
|
||||
);
|
||||
const projectSourceLabel = computed((): string => {
|
||||
if (!props.deal) return '—';
|
||||
const t = props.deal.projectSignalType;
|
||||
if (t === 'site' || t === 'call') return props.deal.projectSignalIdentifier ?? '—';
|
||||
if (t === 'sms') {
|
||||
const sender = props.deal.projectSmsSenders?.[0] ?? '';
|
||||
const kw = props.deal.projectSmsKeyword;
|
||||
if (sender && kw) return `${sender} (${kw})`;
|
||||
return sender || '—';
|
||||
}
|
||||
return '—';
|
||||
});
|
||||
```
|
||||
|
||||
1. В `<template>` найти блок `<div class="param">` для «Менеджер» (около строки 171-180) — **удалить целиком**.
|
||||
2. Между «Стоимость лида» и (где был Менеджер) добавить:
|
||||
|
||||
```vue
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Тип</dt>
|
||||
<dd class="text-body-2">{{ projectTypeLabel }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Источник</dt>
|
||||
<dd class="text-body-2">{{ projectSourceLabel }}</dd>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Vitest GREEN**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/DealDetailBody.spec.ts --reporter=default`
|
||||
Expected: 4 passes.
|
||||
|
||||
- [ ] **Step 10: Full Vitest перед коммитом**
|
||||
|
||||
`cd app && npx vitest run --reporter=default --maxWorkers=2`
|
||||
|
||||
Expected: 0 failed.
|
||||
|
||||
- [ ] **Step 11: Pest full перед коммитом**
|
||||
|
||||
`cd app && ./vendor/bin/pest --parallel 2>&1 | tail -10`
|
||||
|
||||
Expected: 0 failed (новый Deal-show-payload тест passes).
|
||||
|
||||
- [ ] **Step 12: Build**
|
||||
|
||||
`cd app && npm run build 2>&1 | tail -5`
|
||||
|
||||
Expected: `built in …s`.
|
||||
|
||||
- [ ] **Step 13: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Controllers/Api/DealController.php \
|
||||
app/resources/js/api/deals.ts \
|
||||
app/resources/js/composables/mockDeals.ts \
|
||||
app/resources/js/composables/dealsApiMapper.ts \
|
||||
app/resources/js/components/deals/DealDetailBody.vue \
|
||||
app/tests/Frontend/DealDetailBody.spec.ts \
|
||||
app/tests/Feature/Deals/*.php
|
||||
git commit -m "feat(deals/drawer): убрать «Менеджер», добавить «Тип» + «Источник» read-only"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Inline status picker в drawer (п. 3)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/components/deals/DealDetailHero.vue`
|
||||
- Test: `app/tests/Frontend/DealDetailHero.spec.ts` (создать или расширить)
|
||||
|
||||
**Логика:** клик по статус-чипу → `v-menu` с `v-list` всех статусов → выбор отправляет `PATCH /api/deals/{id} { status: <slug> }`, optimistic UI.
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import DealDetailHero from '../../resources/js/components/deals/DealDetailHero.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
const statuses = [
|
||||
{ slug: 'new', nameRu: 'Новая', colorHex: '#0F6E56' },
|
||||
{ slug: 'in_progress', nameRu: 'В работе', colorHex: '#0066CC' },
|
||||
{ slug: 'won', nameRu: 'Куплено', colorHex: '#00A36C' },
|
||||
];
|
||||
|
||||
describe('DealDetailHero — inline status picker', () => {
|
||||
it('клик по статус-чипу открывает меню статусов', async () => {
|
||||
const w = mount(DealDetailHero, {
|
||||
props: {
|
||||
deal: { id: 1, name: 'A', phone: '+1', statusSlug: 'new', project: 'p',
|
||||
manager: { initials: 'A', name: 'A' }, cost: 0, receivedMinutesAgo: 1 },
|
||||
status: statuses[0],
|
||||
allStatuses: statuses,
|
||||
},
|
||||
global: { plugins: [vuetify], stubs: { teleport: true } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await w.find('[data-testid="status-chip-trigger"]').trigger('click');
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(document.body.textContent).toContain('В работе');
|
||||
expect(document.body.textContent).toContain('Куплено');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('выбор статуса эмитит change-status с новым slug', async () => {
|
||||
const w = mount(DealDetailHero, {
|
||||
props: {
|
||||
deal: { id: 1, name: 'A', phone: '+1', statusSlug: 'new', project: 'p',
|
||||
manager: { initials: 'A', name: 'A' }, cost: 0, receivedMinutesAgo: 1 },
|
||||
status: statuses[0],
|
||||
allStatuses: statuses,
|
||||
},
|
||||
global: { plugins: [vuetify], stubs: { teleport: true } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await w.find('[data-testid="status-chip-trigger"]').trigger('click');
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const items = [...document.body.querySelectorAll('[data-testid^="status-option-"]')];
|
||||
const won = items.find(el => el.textContent?.includes('Куплено')) as HTMLElement | undefined;
|
||||
won?.click();
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(w.emitted('change-status')?.[0]?.[0]).toBe('won');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/DealDetailHero.spec.ts -v`
|
||||
|
||||
- [ ] **Step 3: Расширить DealDetailHero.vue**
|
||||
|
||||
В `<script setup>` props:
|
||||
|
||||
```ts
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
defineProps<{
|
||||
deal: MockDeal;
|
||||
status: LeadStatus | null;
|
||||
allStatuses: LeadStatus[];
|
||||
}>();
|
||||
defineEmits<{
|
||||
close: [];
|
||||
'change-status': [slug: string];
|
||||
}>();
|
||||
```
|
||||
|
||||
В `<template>` блок `<div v-if="status" class="status-row mt-3">` (строки 43-48) — заменить на:
|
||||
|
||||
```vue
|
||||
<div v-if="status" class="status-row mt-3">
|
||||
<v-menu>
|
||||
<template #activator="{ props: a }">
|
||||
<v-chip v-bind="a" data-testid="status-chip-trigger" size="small" variant="tonal"
|
||||
:style="{ color: status.colorHex, borderColor: status.colorHex, cursor: 'pointer' }">
|
||||
<span class="status-dot" :style="{ background: status.colorHex }" />
|
||||
{{ status.nameRu }}
|
||||
<v-icon size="14" class="ml-1">mdi-menu-down</v-icon>
|
||||
</v-chip>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="s in allStatuses" :key="s.slug"
|
||||
:data-testid="`status-option-${s.slug}`"
|
||||
@click="$emit('change-status', s.slug)">
|
||||
<template #prepend>
|
||||
<span class="status-dot" :style="{ background: s.colorHex }" />
|
||||
</template>
|
||||
<v-list-item-title>{{ s.nameRu }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Расширить parent DealDetailBody.vue**
|
||||
|
||||
Передавать allStatuses и обрабатывать change-status. В `DealDetailBody.vue`:
|
||||
|
||||
```vue
|
||||
<DealDetailHero :deal="deal" :status="status" :all-statuses="leadStatusesStore.statuses"
|
||||
@close="emit('close')" @change-status="onStatusChange" />
|
||||
```
|
||||
|
||||
Добавить handler:
|
||||
|
||||
```ts
|
||||
async function onStatusChange(slug: string): Promise<void> {
|
||||
if (!props.deal || !props.tenantId) return;
|
||||
const prev = props.deal.statusSlug;
|
||||
props.deal.statusSlug = slug as MockDeal['statusSlug'];
|
||||
try {
|
||||
await dealsApi.updateDeal(props.deal.id, { tenant_id: props.tenantId, status: slug });
|
||||
} catch {
|
||||
props.deal.statusSlug = prev;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Vitest GREEN**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/DealDetailHero.spec.ts -v`
|
||||
|
||||
- [ ] **Step 6: Full Vitest**
|
||||
|
||||
`cd app && npx vitest run --reporter=default --maxWorkers=2 2>&1 | tail -10`
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/deals/DealDetailHero.vue \
|
||||
app/resources/js/components/deals/DealDetailBody.vue \
|
||||
app/tests/Frontend/DealDetailHero.spec.ts
|
||||
git commit -m "feat(deals/drawer): inline status picker в карточке сделки"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Подпись «Источник» в NewProjectDialog (п. 8)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/projects/NewProjectDialog.vue`
|
||||
- Test: `app/tests/Frontend/NewProjectDialog.spec.ts` (создать или расширить если есть)
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import NewProjectDialog from '../../resources/js/views/projects/NewProjectDialog.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
describe('NewProjectDialog — подпись «Источник» (18.05.2026)', () => {
|
||||
it('на табе Сайт перед полем «Домен» есть подпись «Источник»', async () => {
|
||||
const w = mount(NewProjectDialog, {
|
||||
props: { modelValue: true, mode: 'create' },
|
||||
global: { plugins: [vuetify], stubs: { teleport: true } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(document.body.textContent).toContain('Источник');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/NewProjectDialog.spec.ts -v`
|
||||
|
||||
- [ ] **Step 3: Реализация**
|
||||
|
||||
В `app/resources/js/views/projects/NewProjectDialog.vue` — внутри `<v-tabs-window v-model="form.signal_type" class="mt-4">` (строка 19) — перед каждым `<v-tabs-window-item>` контентом добавить заголовок секции:
|
||||
|
||||
Для **site** (после `<v-tabs-window-item value="site">`):
|
||||
|
||||
```vue
|
||||
<div class="text-caption text-medium-emphasis mb-1">Источник — домен сайта-«донора», с которого приходят лиды</div>
|
||||
```
|
||||
|
||||
Для **call**:
|
||||
|
||||
```vue
|
||||
<div class="text-caption text-medium-emphasis mb-1">Источник — телефонный номер «донора», на который звонят клиенты</div>
|
||||
```
|
||||
|
||||
Для **sms**:
|
||||
|
||||
```vue
|
||||
<div class="text-caption text-medium-emphasis mb-1">Источник — отправитель SMS и (опционально) ключевое слово в тексте</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Vitest GREEN**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/NewProjectDialog.spec.ts -v`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/projects/NewProjectDialog.vue \
|
||||
app/tests/Frontend/NewProjectDialog.spec.ts
|
||||
git commit -m "feat(projects/new-dialog): подпись «Источник» над полями на 3 табах"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Редактирование источника в ProjectDetailsDrawer (п. 9)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Requests/UpdateProjectRequest.php` (+signal_identifier rules)
|
||||
- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (update — пропускать signal_identifier)
|
||||
- Modify: `app/resources/js/components/projects/ProjectDetailsDrawer.vue` (UI поля)
|
||||
- Test: `app/tests/Feature/Projects/UpdateProjectTest.php` (signal_identifier обновляется)
|
||||
|
||||
**ВАЖНО (риски):**
|
||||
|
||||
- signal_type **не редактируется** — менять signal_type у активного проекта = смена природы; не предусмотрено.
|
||||
- При смене signal_identifier у существующего проекта **прошлые сделки** уже привязаны к проекту, source-label в их карточке изменится автоматически (это поведение и хотел заказчик: «изменится у всех сделок этого проекта»).
|
||||
- Соблюсти validation: site = regex домена, call = regex 7\d{10}, sms = signal_identifier = sms_senders[0] (или просто пропускать через sms_senders/sms_keyword, как сейчас в UpdateProjectRequest).
|
||||
|
||||
- [ ] **Step 1: Pest failing**
|
||||
|
||||
Создать или расширить `app/tests/Feature/Projects/UpdateProjectTest.php`:
|
||||
|
||||
```php
|
||||
it('updates signal_identifier for site project', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'old.ru',
|
||||
]);
|
||||
actingAsTenant($tenant);
|
||||
|
||||
$resp = $this->patchJson("/api/projects/{$project->id}", [
|
||||
'signal_identifier' => 'new-source.ru',
|
||||
]);
|
||||
|
||||
$resp->assertOk();
|
||||
expect($project->fresh()->signal_identifier)->toBe('new-source.ru');
|
||||
});
|
||||
|
||||
it('updates signal_identifier for call project (phone regex)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79991111111',
|
||||
]);
|
||||
actingAsTenant($tenant);
|
||||
|
||||
$resp = $this->patchJson("/api/projects/{$project->id}", [
|
||||
'signal_identifier' => '79992222222',
|
||||
]);
|
||||
$resp->assertOk();
|
||||
expect($project->fresh()->signal_identifier)->toBe('79992222222');
|
||||
});
|
||||
|
||||
it('rejects invalid signal_identifier (site regex)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'ok.ru',
|
||||
]);
|
||||
actingAsTenant($tenant);
|
||||
|
||||
$this->patchJson("/api/projects/{$project->id}", ['signal_identifier' => 'not-a-domain'])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['signal_identifier']);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest --filter "updates signal_identifier\|rejects invalid"
|
||||
```
|
||||
|
||||
Expected: все FAIL (правило не разрешает signal_identifier).
|
||||
|
||||
- [ ] **Step 3: Расширить UpdateProjectRequest**
|
||||
|
||||
В `app/app/Http/Requests/UpdateProjectRequest.php` в массиве правил (около строки 27) — добавить condition-based валидацию:
|
||||
|
||||
```php
|
||||
$rules = [
|
||||
// ... existing rules name/daily_limit/regions/delivery_days_mask/sms_*
|
||||
];
|
||||
|
||||
// Условно на тип проекта — signal_identifier валидируется по signal_type
|
||||
$project = $this->route('id') ? \App\Models\Project::find($this->route('id')) : null;
|
||||
if ($project) {
|
||||
if ($project->signal_type === 'site') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,}$/i'];
|
||||
} elseif ($project->signal_type === 'call') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^7\d{10}$/'];
|
||||
}
|
||||
// sms: signal_identifier обновляется автоматически из sms_senders[0] в ProjectController (или через UpdateProjectAction)
|
||||
}
|
||||
|
||||
return $rules;
|
||||
```
|
||||
|
||||
(Адаптировать под существующую структуру файла — прочитать его сначала.)
|
||||
|
||||
- [ ] **Step 4: Расширить ProjectController::update**
|
||||
|
||||
В `app/app/Http/Controllers/Api/ProjectController.php` метод `update()` (около строки 96) — пропустить signal_identifier через assign:
|
||||
|
||||
```php
|
||||
$validated = $request->validated();
|
||||
$project = Project::where('tenant_id', $tenantId)->findOrFail($id);
|
||||
|
||||
// signal_type не меняем — explicitly NOT в $fillable update
|
||||
$updates = collect($validated)->only([
|
||||
'name', 'daily_limit_target', 'regions', 'delivery_days_mask',
|
||||
'sms_senders', 'sms_keyword', 'signal_identifier',
|
||||
])->toArray();
|
||||
|
||||
$project->update($updates);
|
||||
return response()->json(['project' => $project->fresh()]);
|
||||
```
|
||||
|
||||
(Адаптировать под существующую логику.)
|
||||
|
||||
- [ ] **Step 5: Pest GREEN**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest --filter "updates signal_identifier\|rejects invalid"
|
||||
```
|
||||
|
||||
Expected: 3 pass.
|
||||
|
||||
- [ ] **Step 6: Расширить ProjectDetailsDrawer.vue**
|
||||
|
||||
В `app/resources/js/components/projects/ProjectDetailsDrawer.vue` интерфейс `FormState` (около строки 12) — добавить:
|
||||
|
||||
```ts
|
||||
signal_identifier: string;
|
||||
```
|
||||
|
||||
И в `reseedFromProject` (около строки 32) — добавить:
|
||||
|
||||
```ts
|
||||
form.signal_identifier = p.signal_identifier ?? '';
|
||||
```
|
||||
|
||||
В `<template>` после `<label class="pdd-field">` блока «Название» (строки 124-128) — добавить блок «Источник», условно по signal_type:
|
||||
|
||||
```vue
|
||||
<label v-if="project?.signal_type === 'site'" class="pdd-field">
|
||||
<span class="pdd-label">Источник (домен сайта)</span>
|
||||
<input v-model="form.signal_identifier" data-testid="pdd-signal-identifier" class="pdd-input"
|
||||
placeholder="okna-konkurent.ru" />
|
||||
<div v-if="errors.signal_identifier" class="pdd-error">{{ errors.signal_identifier[0] }}</div>
|
||||
</label>
|
||||
<label v-else-if="project?.signal_type === 'call'" class="pdd-field">
|
||||
<span class="pdd-label">Источник (телефонный номер)</span>
|
||||
<input v-model="form.signal_identifier" data-testid="pdd-signal-identifier" class="pdd-input"
|
||||
placeholder="79161234567" />
|
||||
<div v-if="errors.signal_identifier" class="pdd-error">{{ errors.signal_identifier[0] }}</div>
|
||||
</label>
|
||||
<!-- sms: signal_identifier подтягивается из sms_senders, отдельное поле не нужно -->
|
||||
<label v-else-if="project?.signal_type === 'sms'" class="pdd-field">
|
||||
<span class="pdd-label">Отправители SMS (до 11 символов каждый)</span>
|
||||
<v-combobox v-model="form.sms_senders" multiple chips clearable
|
||||
data-testid="pdd-sms-senders" hide-details />
|
||||
</label>
|
||||
<label v-if="project?.signal_type === 'sms'" class="pdd-field">
|
||||
<span class="pdd-label">Ключевое слово (опционально)</span>
|
||||
<input v-model="form.sms_keyword" data-testid="pdd-sms-keyword" class="pdd-input" />
|
||||
</label>
|
||||
```
|
||||
|
||||
В `onSave()` — добавить signal_identifier в payload:
|
||||
|
||||
```ts
|
||||
if (props.project.signal_type === 'site' || props.project.signal_type === 'call') {
|
||||
payload.signal_identifier = form.signal_identifier;
|
||||
}
|
||||
if (props.project.signal_type === 'sms') {
|
||||
payload.sms_senders = form.sms_senders;
|
||||
payload.sms_keyword = form.sms_keyword;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Vitest для ProjectDetailsDrawer**
|
||||
|
||||
Создать `app/tests/Frontend/ProjectDetailsDrawer.spec.ts` (если нет) с тестом:
|
||||
|
||||
```ts
|
||||
it('показывает поле редактирования signal_identifier для site-проекта', () => {
|
||||
const w = mount(ProjectDetailsDrawer, {
|
||||
props: { project: { id: 1, tenant_id: 1, name: 'P', signal_type: 'site',
|
||||
signal_identifier: 'old.ru', daily_limit_target: 50,
|
||||
regions: [], delivery_days_mask: 127, is_active: true } as never },
|
||||
global: { plugins: [vuetify, createPinia()] },
|
||||
});
|
||||
const input = w.find('[data-testid="pdd-signal-identifier"]');
|
||||
expect(input.exists()).toBe(true);
|
||||
expect((input.element as HTMLInputElement).value).toBe('old.ru');
|
||||
});
|
||||
```
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/ProjectDetailsDrawer.spec.ts -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Full Pest + Vitest + Build**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest --parallel 2>&1 | tail -10
|
||||
cd app && npx vitest run --reporter=default --maxWorkers=2 2>&1 | tail -10
|
||||
cd app && npm run build 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: всё GREEN.
|
||||
|
||||
- [ ] **Step 9: Manual smoke на /projects**
|
||||
|
||||
Открыть `http://127.0.0.1:8000/projects` → клик на любой site-проект → drawer справа → поле «Источник» видно с текущим доменом → меняем → «Сохранить» → перезагрузка карточки → новое значение применилось → перейти на `/deals` → drawer сделки этого проекта → «Источник» = новое значение.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Requests/UpdateProjectRequest.php \
|
||||
app/app/Http/Controllers/Api/ProjectController.php \
|
||||
app/resources/js/components/projects/ProjectDetailsDrawer.vue \
|
||||
app/tests/Frontend/ProjectDetailsDrawer.spec.ts \
|
||||
app/tests/Feature/Projects/UpdateProjectTest.php
|
||||
git commit -m "feat(projects/drawer): редактирование источника (site/call/sms) в карточке проекта"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Финал: атомарный push
|
||||
|
||||
После Task 5 — push всех 5 коммитов одним:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git log HEAD..origin/main --oneline # должно быть пусто
|
||||
git push origin feat/parallel-sessions-coordination:main
|
||||
```
|
||||
|
||||
Эталон обновить: §1 HEAD, §6 «Текущие нити» добавить запись «18.05 — деали-drawer + edit-источник проекта».
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
- п.1+2 ✅ Task 1
|
||||
- п.3 ✅ Task 3
|
||||
- п.4 ✅ Task 2 (Step 8 удаляет блок «Менеджер»)
|
||||
- п.5 ✅ уже сделано в `36ea9cd`
|
||||
- п.6 ✅ Task 2 (computed projectSourceLabel)
|
||||
- п.7 ✅ Task 2 (computed projectTypeLabel)
|
||||
- п.8 ✅ Task 4
|
||||
- п.9 ✅ Task 5
|
||||
|
||||
**Placeholder scan:** в плане НЕТ «TBD»/«TODO», все code-блоки полные.
|
||||
|
||||
**Type consistency:** `projectSignalType`/`projectSignalIdentifier`/`projectSmsKeyword`/`projectSmsSenders` consistent через Task 2 (api/mockDeals/mapper/UI). `allStatuses` consistent в Task 3 (Hero ← Body).
|
||||
|
||||
**Риски:**
|
||||
|
||||
- Task 5 затрагивает критическое поле `signal_identifier`; нет ограничения «нельзя менять у проекта с уже накопленными сделками». При желании заказчика добавить — отдельная задача (warn-dialog типа Task 5.5). Не делаем в MVP — заказчик прямо сказал «изменится у всех сделок этого проекта» (он понимает scope).
|
||||
- Pest тесты предполагают наличие `actingAsTenant()` helper'а в `app/tests/Pest.php`. Если такого нет — использовать существующий паттерн авторизации в тестах.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,465 +0,0 @@
|
||||
# Переделка миграции проектов — План 4: админка (тумблер + очистка) + ЛК
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Доделать UI эпика: глобальный тумблер режима экспорта (online/batch) в админке; ручной экран «Проекты у поставщика» (кто заказывал · дата последней поставки · bulk-удаление); в ЛК — обязательный выбор региона + явная опция «Вся РФ» с предупреждением и подтверждением.
|
||||
|
||||
**Architecture:** Бэкенд — `AdminSupplierIntegrationController` +4 метода (getExportMode/setExportMode/projectsIndex/projectsDestroy), удаление на портале через `SupplierPortalClient::deleteProject` (pivot CASCADE снимает локальные связи). Фронт — тумблер в `AdminSupplierIntegrationView.vue` + новый `AdminSupplierProjectsView.vue` (таблица + bulk-delete) + роут/nav. ЛК — `NewProjectDialog.vue`: вернуть sentinel «Вся РФ» (code 0), взаимоисключение с субъектами, предупреждение-диалог + блок submit без выбора. «Require region» — UI-гейт (на бэке `regions=[]` = «Вся РФ» неотличим от «забыл»; backend-валидация `present|array` без изменений).
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13 / Vue 3 + Vuetify 3 / Pest 4 / Vitest.
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md](../specs/2026-05-20-project-migration-redesign-design.md) §4.1 (тумблер), §4.7 (очистка), §4.8 (ЛК).
|
||||
|
||||
**Prereq:** Планы 1+2+3 исполнены (pivot, subject_code, supplier_export_mode seed, SupplierExportMode резолвер, per-субъект supplier_projects).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: тумблер режима экспорта (бэкенд + UI)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php` (+`getExportMode`, +`setExportMode`)
|
||||
- Modify: `app/routes/*` (роуты в admin-группе рядом с supplier-integration; найти по `AdminSupplierIntegrationController`)
|
||||
- Modify: `app/resources/js/views/admin/AdminSupplierIntegrationView.vue` (+тумблер)
|
||||
- Test: `app/tests/Feature/Admin/SupplierExportModeEndpointTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест (бэкенд)**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SaasAdminUser;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('GET export-mode returns current; POST switches it', function (): void {
|
||||
$admin = SaasAdminUser::factory()->create(); // адаптировать под фактическую admin-аутентификацию проекта
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'batch']);
|
||||
|
||||
$this->actingAs($admin, 'saas_admin')
|
||||
->getJson('/api/admin/supplier-integration/export-mode')
|
||||
->assertOk()->assertJson(['mode' => 'batch']);
|
||||
|
||||
$this->actingAs($admin, 'saas_admin')
|
||||
->postJson('/api/admin/supplier-integration/export-mode', ['mode' => 'online'])
|
||||
->assertOk()->assertJson(['mode' => 'online']);
|
||||
|
||||
expect(DB::table('system_settings')->where('key', 'supplier_export_mode')->value('value'))->toBe('online');
|
||||
});
|
||||
|
||||
it('POST export-mode rejects invalid value', function (): void {
|
||||
$admin = SaasAdminUser::factory()->create();
|
||||
$this->actingAs($admin, 'saas_admin')
|
||||
->postJson('/api/admin/supplier-integration/export-mode', ['mode' => 'turbo'])
|
||||
->assertStatus(422);
|
||||
});
|
||||
```
|
||||
|
||||
(Admin-guard/factory — привести к фактическому паттерну из существующих `app/tests/Feature/Admin/*` тестов.)
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SupplierExportModeEndpointTest`
|
||||
Expected: FAIL — методов/роутов нет.
|
||||
|
||||
- [ ] **Step 3: Добавить методы в контроллер**
|
||||
|
||||
В `AdminSupplierIntegrationController`:
|
||||
|
||||
```php
|
||||
public function getExportMode(): JsonResponse
|
||||
{
|
||||
return response()->json(['mode' => \App\Services\Supplier\SupplierExportMode::current()]);
|
||||
}
|
||||
|
||||
public function setExportMode(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'mode' => ['required', 'in:online,batch'],
|
||||
]);
|
||||
|
||||
DB::table('system_settings')->updateOrInsert(
|
||||
['key' => 'supplier_export_mode'],
|
||||
['value' => $data['mode'], 'type' => 'string', 'updated_at' => now()],
|
||||
);
|
||||
|
||||
return response()->json(['mode' => $data['mode']]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Зарегистрировать роуты**
|
||||
|
||||
Рядом с существующими роутами `AdminSupplierIntegrationController` (найти `supplier-integration` в `app/routes/`), в той же группе (auth:saas_admin / admin middleware):
|
||||
|
||||
```php
|
||||
Route::get('admin/supplier-integration/export-mode', [AdminSupplierIntegrationController::class, 'getExportMode']);
|
||||
Route::post('admin/supplier-integration/export-mode', [AdminSupplierIntegrationController::class, 'setExportMode']);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=SupplierExportModeEndpointTest`
|
||||
Expected: PASS (2). Полный вывод.
|
||||
|
||||
- [ ] **Step 6: UI-тумблер в `AdminSupplierIntegrationView.vue`**
|
||||
|
||||
Добавить секцию «Режим экспорта проектов» с `<v-switch>` / `<v-btn-toggle>` (online|batch): на mount GET `/api/admin/supplier-integration/export-mode`, при смене POST. Подпись: «Онлайн — перенос сразу при правке; Пакетный — ночной 18:00». Vitest-стаб API.
|
||||
|
||||
- [ ] **Step 7: Vitest для тумблера**
|
||||
|
||||
`app/resources/js/views/admin/__tests__/AdminSupplierIntegrationView.export-mode.spec.ts` — mount, mock GET возвращает batch → переключение шлёт POST online. Run: `npm run test:vue -- AdminSupplierIntegrationView.export-mode`. Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: pint + larastan + Коммит**
|
||||
|
||||
```bash
|
||||
composer pint; composer stan
|
||||
git add -- app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php app/routes app/resources/js/views/admin/AdminSupplierIntegrationView.vue app/resources/js/views/admin/__tests__/AdminSupplierIntegrationView.export-mode.spec.ts app/tests/Feature/Admin/SupplierExportModeEndpointTest.php
|
||||
git commit -m "feat(admin): supplier export-mode toggle (online|batch) endpoint + UI" -- app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php app/routes app/resources/js/views/admin/AdminSupplierIntegrationView.vue app/resources/js/views/admin/__tests__/AdminSupplierIntegrationView.export-mode.spec.ts app/tests/Feature/Admin/SupplierExportModeEndpointTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: экран «Проекты у поставщика» — бэкенд (список + bulk-delete)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php` (+`projectsIndex`, +`projectsDestroy`)
|
||||
- Modify: `app/routes/*` (2 роута)
|
||||
- Test: `app/tests/Feature/Admin/SupplierProjectsAdminTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SaasAdminUser;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
it('lists supplier projects with orderers and last delivery date', function (): void {
|
||||
$admin = SaasAdminUser::factory()->create();
|
||||
$tenant = Tenant::factory()->create(['name' => 'ООО Ромашка']);
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'okna.ru',
|
||||
'subject_code' => 82, 'current_limit' => 5, 'sync_status' => 'ok', 'supplier_external_id' => '777',
|
||||
]);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id, 'supplier_project_id' => $sp->id, 'platform' => 'B1', 'subject_code' => 82,
|
||||
]);
|
||||
|
||||
$resp = $this->actingAs($admin, 'saas_admin')
|
||||
->getJson('/api/admin/supplier-integration/projects')->assertOk()->json();
|
||||
|
||||
$row = collect($resp['projects'])->firstWhere('id', $sp->id);
|
||||
expect($row['unique_key'])->toBe('okna.ru')
|
||||
->and($row['subject_code'])->toBe(82)
|
||||
->and($row['orderers'])->toContain('ООО Ромашка');
|
||||
});
|
||||
|
||||
it('bulk-deletes selected supplier projects on portal + locally (pivot cascades)', function (): void {
|
||||
Http::fake(['*/admin/visit/rt-project-delete' => Http::response(['status' => 'OK'], 200)]);
|
||||
$admin = SaasAdminUser::factory()->create();
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'del.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok', 'supplier_external_id' => '888',
|
||||
]);
|
||||
|
||||
$this->actingAs($admin, 'saas_admin')
|
||||
->postJson('/api/admin/supplier-integration/projects/delete', ['ids' => [$sp->id]])
|
||||
->assertOk()->assertJson(['deleted' => 1]);
|
||||
|
||||
expect(SupplierProject::find($sp->id))->toBeNull();
|
||||
Http::assertSent(fn ($r) => str_contains($r->url(), 'rt-project-delete'));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SupplierProjectsAdminTest`
|
||||
Expected: FAIL — методов/роутов нет.
|
||||
|
||||
- [ ] **Step 3: Добавить методы**
|
||||
|
||||
В `AdminSupplierIntegrationController` (+ импорт `use App\Services\Supplier\SupplierPortalClient;`):
|
||||
|
||||
```php
|
||||
public function projectsIndex(): JsonResponse
|
||||
{
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
|
||||
$projects = $conn->table('supplier_projects as sp')
|
||||
->select('sp.id', 'sp.platform', 'sp.signal_type', 'sp.unique_key', 'sp.subject_code', 'sp.supplier_external_id', 'sp.current_limit', 'sp.inactive_since')
|
||||
->orderBy('sp.unique_key')->orderBy('sp.subject_code')->orderBy('sp.platform')
|
||||
->get()
|
||||
->map(function ($sp) use ($conn): array {
|
||||
$orderers = $conn->table('project_supplier_links as psl')
|
||||
->join('projects as p', 'p.id', '=', 'psl.project_id')
|
||||
->join('tenants as t', 't.id', '=', 'p.tenant_id')
|
||||
->where('psl.supplier_project_id', $sp->id)
|
||||
->where('p.is_active', true)
|
||||
->distinct()->pluck('t.name')->all();
|
||||
|
||||
$lastDelivery = $conn->table('supplier_leads')
|
||||
->where('supplier_project_id', $sp->id)
|
||||
->max('created_at');
|
||||
|
||||
return [
|
||||
'id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
'signal_type' => $sp->signal_type,
|
||||
'unique_key' => $sp->unique_key,
|
||||
'subject_code' => $sp->subject_code !== null ? (int) $sp->subject_code : null,
|
||||
'subject_name' => $sp->subject_code !== null
|
||||
? (\App\Support\RussianRegions::CODE_TO_NAME[(int) $sp->subject_code] ?? null)
|
||||
: 'РФ',
|
||||
'current_limit' => (int) $sp->current_limit,
|
||||
'orderers' => $orderers,
|
||||
'last_delivery_at' => $lastDelivery,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['projects' => $projects->all()]);
|
||||
}
|
||||
|
||||
public function projectsDestroy(Request $request, SupplierPortalClient $client): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'ids' => ['required', 'array', 'min:1'],
|
||||
'ids.*' => ['integer'],
|
||||
]);
|
||||
|
||||
$deleted = 0;
|
||||
$failures = [];
|
||||
|
||||
foreach (SupplierProject::whereIn('id', $data['ids'])->get() as $sp) {
|
||||
try {
|
||||
if ($sp->supplier_external_id !== null) {
|
||||
$client->deleteProject((int) $sp->supplier_external_id);
|
||||
}
|
||||
$sp->delete(); // project_supplier_links снимутся CASCADE
|
||||
$deleted++;
|
||||
} catch (\Throwable $e) {
|
||||
$failures[] = ['id' => $sp->id, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['deleted' => $deleted, 'failures' => $failures]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Роуты**
|
||||
|
||||
```php
|
||||
Route::get('admin/supplier-integration/projects', [AdminSupplierIntegrationController::class, 'projectsIndex']);
|
||||
Route::post('admin/supplier-integration/projects/delete', [AdminSupplierIntegrationController::class, 'projectsDestroy']);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=SupplierProjectsAdminTest`
|
||||
Expected: PASS (2). Полный вывод; failed — file:line.
|
||||
|
||||
- [ ] **Step 6: pint + larastan + Коммит**
|
||||
|
||||
```bash
|
||||
composer pint; composer stan
|
||||
git add -- app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php app/routes app/tests/Feature/Admin/SupplierProjectsAdminTest.php
|
||||
git commit -m "feat(admin): supplier projects list (orderers, last delivery) + bulk delete" -- app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php app/routes app/tests/Feature/Admin/SupplierProjectsAdminTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: экран «Проекты у поставщика» — фронтенд
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/resources/js/views/admin/AdminSupplierProjectsView.vue`
|
||||
- Modify: `app/resources/js/router/index.ts` (роут `/admin/supplier-projects`)
|
||||
- Modify: `app/resources/js/layouts/AdminLayout.vue` (nav-пункт)
|
||||
- Test: `app/resources/js/views/admin/__tests__/AdminSupplierProjectsView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающий Vitest**
|
||||
|
||||
```ts
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import AdminSupplierProjectsView from '../AdminSupplierProjectsView.vue';
|
||||
import { apiClient } from '../../../api/client';
|
||||
|
||||
vi.mock('../../../api/client', () => ({ apiClient: { get: vi.fn(), post: vi.fn() }, ensureCsrfCookie: vi.fn() }));
|
||||
|
||||
describe('AdminSupplierProjectsView', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders rows with orderers + last delivery and bulk-deletes selected', async () => {
|
||||
(apiClient.get as any).mockResolvedValue({ data: { projects: [
|
||||
{ id: 1, platform: 'B1', unique_key: 'okna.ru', subject_name: 'Москва', current_limit: 5, orderers: ['ООО Ромашка'], last_delivery_at: '2026-05-19T10:00:00Z' },
|
||||
] } });
|
||||
(apiClient.post as any).mockResolvedValue({ data: { deleted: 1, failures: [] } });
|
||||
|
||||
const wrapper = mount(AdminSupplierProjectsView, { global: { plugins: [createVuetify()] } });
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('okna.ru');
|
||||
expect(wrapper.text()).toContain('ООО Ромашка');
|
||||
|
||||
// выбрать строку + удалить
|
||||
await wrapper.find('[data-testid="row-checkbox-1"]').setValue(true);
|
||||
await wrapper.find('[data-testid="bulk-delete-btn"]').trigger('click');
|
||||
// подтверждение → confirm
|
||||
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/admin/supplier-integration/projects/delete', { ids: [1] });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
(`flushPromises` — импортировать из `@vue/test-utils`; адаптировать селекторы под фактическую разметку компонента.)
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `npm run test:vue -- AdminSupplierProjectsView`
|
||||
Expected: FAIL — компонента нет.
|
||||
|
||||
- [ ] **Step 3: Создать `AdminSupplierProjectsView.vue`**
|
||||
|
||||
Таблица `<v-data-table>` колонки: чекбокс · Источник (`unique_key`) · Платформа · Регион (`subject_name`) · Лимит · Кто заказывал (`orderers.join(', ')`) · Последняя поставка (`last_delivery_at` форматом даты, «—» если null). Над таблицей — `<v-btn data-testid="bulk-delete-btn" :disabled="selected.length===0">Удалить выбранные</v-btn>`. По клику — `<v-dialog>` подтверждения с `data-testid="confirm-delete-btn"` → POST `/api/admin/supplier-integration/projects/delete` `{ids: selected}` → refresh + snackbar (deleted/failures). Иконки — Lucide через IconSet (не mdi). Загрузка списка на mount GET `/api/admin/supplier-integration/projects`.
|
||||
|
||||
- [ ] **Step 4: Роут + nav**
|
||||
|
||||
- `router/index.ts`: `{ path: '/admin/supplier-projects', component: () => import('../views/admin/AdminSupplierProjectsView.vue'), meta: { layout: 'app', requiresAdmin: true } }` (адаптировать под фактический meta-паттерн admin-роутов).
|
||||
- `AdminLayout.vue`: nav-пункт «Проекты у поставщика» (Lucide-иконка).
|
||||
|
||||
- [ ] **Step 5: Прогнать Vitest — зелёный**
|
||||
|
||||
Run: `npm run test:vue -- AdminSupplierProjectsView`
|
||||
Expected: PASS. Полный вывод.
|
||||
|
||||
- [ ] **Step 6: type-check + lint + Коммит**
|
||||
|
||||
```bash
|
||||
npm run type-check; npm run lint:vue
|
||||
git add -- app/resources/js/views/admin/AdminSupplierProjectsView.vue app/resources/js/router/index.ts app/resources/js/layouts/AdminLayout.vue app/resources/js/views/admin/__tests__/AdminSupplierProjectsView.spec.ts
|
||||
git commit -m "feat(admin): supplier projects cleanup screen (list + bulk delete)" -- app/resources/js/views/admin/AdminSupplierProjectsView.vue app/resources/js/router/index.ts app/resources/js/layouts/AdminLayout.vue app/resources/js/views/admin/__tests__/AdminSupplierProjectsView.spec.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: ЛК — обязательный регион + «Вся РФ» с предупреждением
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/projects/NewProjectDialog.vue`
|
||||
- Test: `app/resources/js/views/projects/__tests__/NewProjectDialog.regions.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающий Vitest**
|
||||
|
||||
```ts
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import NewProjectDialog from '../NewProjectDialog.vue';
|
||||
|
||||
vi.mock('../../../api/client', () => ({ apiClient: { post: vi.fn().mockResolvedValue({}) }, ensureCsrfCookie: vi.fn(), extractErrorMessage: () => '' }));
|
||||
|
||||
const mountDialog = () => mount(NewProjectDialog, {
|
||||
props: { modelValue: true, mode: 'create' },
|
||||
global: { plugins: [createVuetify()], stubs: { DevIndexBadge: true } },
|
||||
});
|
||||
|
||||
describe('NewProjectDialog regions gate', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('blocks submit when no region chosen', async () => {
|
||||
const w = mountDialog();
|
||||
// заполнить остальные обязательные поля минимально...
|
||||
await w.find('[data-testid="submit-btn"]').trigger('click');
|
||||
const { apiClient } = await import('../../../api/client');
|
||||
expect(apiClient.post).not.toHaveBeenCalled();
|
||||
expect(w.text()).toContain('Выберите регион'); // ошибка-валидации
|
||||
});
|
||||
|
||||
it('selecting «Вся РФ» shows warning and requires confirm before submit; sends regions=[]', async () => {
|
||||
const w = mountDialog();
|
||||
// выбрать «Вся РФ» (sentinel)
|
||||
await w.vm.$nextTick();
|
||||
(w.vm as any).chooseVsyaRf?.(); // или установить form.regions=[0] через UI
|
||||
await w.vm.$nextTick();
|
||||
expect(w.text()).toContain('всю Россию'); // предупреждение
|
||||
await w.find('[data-testid="confirm-vsya-rf"]').trigger('click');
|
||||
// заполнить прочие поля + submit
|
||||
await w.find('[data-testid="submit-btn"]').trigger('click');
|
||||
const { apiClient } = await import('../../../api/client');
|
||||
const payload = (apiClient.post as any).mock.calls[0][1];
|
||||
expect(payload.regions).toEqual([]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
(Селекторы/хелперы — адаптировать под фактическую разметку; ключевые проверки: (1) пустой выбор → submit заблокирован + ошибка; (2) «Вся РФ» → предупреждение + подтверждение → `regions:[]`.)
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `npm run test:vue -- NewProjectDialog.regions`
|
||||
Expected: FAIL — гейта/опции «Вся РФ»/предупреждения нет.
|
||||
|
||||
- [ ] **Step 3: Доработать `NewProjectDialog.vue`**
|
||||
|
||||
- Вернуть «Вся РФ» в опции: `selectableRegions` ([NewProjectDialog.vue:154](../../../app/resources/js/views/projects/NewProjectDialog.vue#L154)) — включить sentinel `code:0` (или отдельный чекбокс «Вся РФ»).
|
||||
- **Взаимоисключение:** выбор «Вся РФ» очищает конкретные субъекты и наоборот.
|
||||
- **Предупреждение:** при выборе «Вся РФ» — `<v-dialog>`/`<v-alert>` «Вы выбрали всю Россию — проект будет получать лиды по всем регионам» + кнопка `data-testid="confirm-vsya-rf"`; без подтверждения «Вся РФ» не считается выбранной.
|
||||
- **Гейт submit:** `submit()` блокируется (ошибка `errors.regions = ['Выберите регион']`), если не выбрано ни субъектов, ни подтверждённой «Вся РФ». Submit-кнопка `:disabled` либо ранний `return` в `submit()`.
|
||||
- **Маппинг на API:** «Вся РФ» → `form.regions = []` (sentinel 0 не отправлять); конкретные → коды субъектов как сейчас.
|
||||
- Подпись поля: «Источник» / регион — оставить «Регионы» (но убрать «(пусто = вся РФ)», т.к. пусто теперь = не выбрано / ошибка).
|
||||
|
||||
- [ ] **Step 4: Прогнать Vitest — зелёный**
|
||||
|
||||
Run: `npm run test:vue -- NewProjectDialog.regions`
|
||||
Expected: PASS. Полный вывод. Прогнать также существующие `NewProjectDialog` specs — не сломаны (если дефолт изменился — поправить старые ожидания «regions=[]»).
|
||||
|
||||
- [ ] **Step 5: type-check + lint + Коммит**
|
||||
|
||||
```bash
|
||||
npm run type-check; npm run lint:vue
|
||||
git add -- app/resources/js/views/projects/NewProjectDialog.vue app/resources/js/views/projects/__tests__/NewProjectDialog.regions.spec.ts
|
||||
git commit -m "feat(projects): require region + explicit «Вся РФ» with warning gate" -- app/resources/js/views/projects/NewProjectDialog.vue app/resources/js/views/projects/__tests__/NewProjectDialog.regions.spec.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Регрессия эпика (полная)
|
||||
|
||||
**Files:** нет (проверка).
|
||||
|
||||
- [ ] **Step 1: Backend full Supplier/Admin/Jobs**
|
||||
|
||||
Run: `php artisan test app/tests/Feature/Supplier app/tests/Feature/Admin app/tests/Feature/Jobs app/tests/Feature/Schedule app/tests/Unit/Services`
|
||||
Expected: GREEN. passed/failed; failed — file:line.
|
||||
|
||||
- [ ] **Step 2: Frontend Vitest**
|
||||
|
||||
Run: `npm run test:vue`
|
||||
Expected: GREEN (или `--maxWorkers=2` при OOM — квирк 98).
|
||||
|
||||
- [ ] **Step 3: Полный regression sweep**
|
||||
|
||||
Run: `/regression full` (Pest --parallel + Larastan + Vitest + Vite build + lychee + gitleaks). Привести каноническую статус-строку + вердикт.
|
||||
|
||||
- [ ] **Step 4 (verification-before-completion):** перед «План 4 / эпик готов» — invoke `superpowers:verification-before-completion`; фактический вывод, не «должно проходить».
|
||||
|
||||
---
|
||||
|
||||
## Self-review (выполнен при написании плана)
|
||||
|
||||
- **Покрытие spec §4.1/§4.7/§4.8:** тумблер режима endpoint+UI (T1); экран очистки — список (кто заказывал через pivot→projects→tenants, дата последней поставки = max supplier_leads.created_at) + bulk-delete через `deleteProject` + CASCADE pivot (T2 бэкенд, T3 фронт); ЛК require-region UI-гейт + «Вся РФ» предупреждение/подтверждение/взаимоисключение, regions=[] на API (T4).
|
||||
- **Без плейсхолдеров:** бэкенд-код полный (контроллер, тесты). Фронт — структура компонента + ключевая логика + Vitest с `data-testid`; точные селекторы помечены «адаптировать под разметку» (компонент создаётся в этом же таске — не внешняя неизвестность).
|
||||
- **Согласованность типов:** `mode` ∈ {online,batch} единообразно (резолвер План 3 + endpoint); `projectsIndex` отдаёт `subject_code:int|null` + `subject_name` (RussianRegions из Плана 2); `projectsDestroy` `{ids:int[]}` → `{deleted,failures}`; ЛК → `regions:int[]` (пусто=Вся РФ) совпадает с StoreProjectRequest `present|array`.
|
||||
- **Граница require-region:** на бэке `regions=[]` (Вся РФ) и «забыл» неотличимы → гейт намеренно UI-only (T4); backend-валидация `present|array` не меняется (P1: Вся РФ — валидный пустой массив).
|
||||
- **Риск:** admin-аутентификация в тестах (`saas_admin` guard / factory) — привести к фактическому паттерну существующих `app/tests/Feature/Admin/*`; роуты — в ту же группу, что текущие `AdminSupplierIntegrationController` методы.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,912 +0,0 @@
|
||||
# Admin Tenant Balance Edit Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Дать SaaS-админу установить точный рублёвый баланс тенанта из админки (карточка тенанта + инлайн в таблице списка), с записью в ledger + audit-log.
|
||||
|
||||
**Architecture:** Новый эндпоинт `PATCH /api/admin/tenants/{id}/balance` (метод `AdminTenantsController::updateBalance`) под `saas-admin` middleware. Семантика «установить точную сумму»: сервер считает знаковую дельту `target − current`, применяет под `lockForUpdate` через bcmath, пишет `balance_transactions(type='manual_adjustment')` + `saas_admin_audit_log`. Frontend — общий `TenantBalanceDialog.vue`, открывается из `TenantDetailHeader` (карточка «Баланс») и из строки `TenantsTable`.
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / Vue 3.5 / Vuetify 3.12 / Vitest 4 / bcmath / PostgreSQL 16.
|
||||
|
||||
**Spec:** [../specs/2026-05-23-admin-tenant-balance-edit-design.md](../specs/2026-05-23-admin-tenant-balance-edit-design.md)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `app/app/Http/Controllers/Api/AdminTenantsController.php` — добавить `use` трейта/моделей + метод `updateBalance`.
|
||||
- **Modify** `app/routes/web.php` — 1 строка маршрута в группе `saas-admin` (рядом с tenants lookup).
|
||||
- **Create** `app/tests/Feature/Admin/AdminTenantBalanceUpdateTest.php` — Pest feature-тест.
|
||||
- **Modify** `app/resources/js/api/admin.ts` — функция `updateTenantBalance`.
|
||||
- **Create** `app/resources/js/components/admin/TenantBalanceDialog.vue` — общий диалог.
|
||||
- **Create** `app/tests/Frontend/TenantBalanceDialog.spec.ts` — Vitest.
|
||||
- **Modify** `app/resources/js/components/admin/tenant-detail/TenantDetailHeader.vue` — кнопка в карточке «Баланс» + emit.
|
||||
- **Modify** `app/resources/js/views/admin/AdminTenantDetailView.vue` — монтаж диалога + reload по `saved`.
|
||||
- **Modify** `app/resources/js/components/admin/tenants/TenantsTable.vue` — действие в строке + emit.
|
||||
- **Modify** `app/resources/js/views/admin/AdminTenantsView.vue` — монтаж диалога + обновление строки по `saved`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Backend `updateBalance` endpoint
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/AdminTenantsController.php`
|
||||
- Modify: `app/routes/web.php` (~line 100, после tenants `show`)
|
||||
- Test: `app/tests/Feature/Admin/AdminTenantBalanceUpdateTest.php`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `app/tests/Feature/Admin/AdminTenantBalanceUpdateTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
function makeBalanceTenant(string $balanceRub): Tenant
|
||||
{
|
||||
return Tenant::factory()->create(['balance_rub' => $balanceRub]);
|
||||
}
|
||||
|
||||
it('sets exact balance and records signed manual_adjustment delta', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$resp = $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '2500.00',
|
||||
'reason' => 'Коррекция тестового баланса',
|
||||
]);
|
||||
|
||||
$resp->assertOk()
|
||||
->assertJsonPath('balance_rub', '2500.00')
|
||||
->assertJsonPath('delta', '1500.00');
|
||||
|
||||
$tenant->refresh();
|
||||
expect((string) $tenant->balance_rub)->toBe('2500.00');
|
||||
|
||||
$tx = BalanceTransaction::where('tenant_id', $tenant->id)
|
||||
->where('type', BalanceTransaction::TYPE_MANUAL_ADJUSTMENT)
|
||||
->latest('id')->first();
|
||||
expect($tx)->not->toBeNull();
|
||||
expect((string) $tx->amount_rub)->toBe('1500.00');
|
||||
expect((string) $tx->balance_rub_after)->toBe('2500.00');
|
||||
expect($tx->amount_leads)->toBeNull();
|
||||
expect($tx->description)->toBe('Коррекция тестового баланса');
|
||||
});
|
||||
|
||||
it('records negative delta when lowering balance', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$resp = $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '300.00',
|
||||
]);
|
||||
|
||||
$resp->assertOk()->assertJsonPath('delta', '-700.00');
|
||||
|
||||
$tx = BalanceTransaction::where('tenant_id', $tenant->id)
|
||||
->where('type', BalanceTransaction::TYPE_MANUAL_ADJUSTMENT)
|
||||
->latest('id')->first();
|
||||
expect((string) $tx->amount_rub)->toBe('-700.00');
|
||||
// Default description когда reason не передан.
|
||||
expect($tx->description)->toBe('Ручная корректировка баланса (админ)');
|
||||
});
|
||||
|
||||
it('accepts negative target balance (debt correction)', function () {
|
||||
$tenant = makeBalanceTenant('0.00');
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '-500.00',
|
||||
])->assertOk()->assertJsonPath('balance_rub', '-500.00');
|
||||
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('-500.00');
|
||||
});
|
||||
|
||||
it('rejects no-op (target equals current) with 422', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '1000.00',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('rejects malformed balance_rub with 422', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '10.123',
|
||||
])->assertStatus(422);
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => 'abc',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('returns 404 for missing or soft-deleted tenant', function () {
|
||||
$this->patchJson('/api/admin/tenants/99999999/balance', [
|
||||
'balance_rub' => '100.00',
|
||||
])->assertStatus(404);
|
||||
|
||||
$tenant = makeBalanceTenant('100.00');
|
||||
$tenant->delete();
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '200.00',
|
||||
])->assertStatus(404);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run from `app/`: `./vendor/bin/pest tests/Feature/Admin/AdminTenantBalanceUpdateTest.php`
|
||||
Expected: FAIL — route `PATCH /api/admin/tenants/{id}/balance` does not exist (404 on all, or method-not-allowed).
|
||||
|
||||
If the testing DB lacks the `manual_adjustment` value in `balance_transactions_type_check` or the May-2026 partition — note from prior Billing v2 work: run `php artisan migrate --env=testing` and `php artisan partitions:create-months` if a CHECK/partition error appears. `manual_adjustment` is an existing CHECK value (predates this work), so it should already be valid.
|
||||
|
||||
- [ ] **Step 3: Add imports + trait to the controller**
|
||||
|
||||
In `app/app/Http/Controllers/Api/AdminTenantsController.php`, the current `use` block is:
|
||||
|
||||
```php
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
```
|
||||
|
||||
Add these imports (alphabetical placement):
|
||||
|
||||
```php
|
||||
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\SaasAdminAuditLog;
|
||||
```
|
||||
|
||||
Add the trait inside the class (right after the class opening brace):
|
||||
|
||||
```php
|
||||
class AdminTenantsController extends Controller
|
||||
{
|
||||
use ResolvesAdminUserId;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the `updateBalance` method**
|
||||
|
||||
Append this method to `AdminTenantsController` (after `show()`, before the private helpers):
|
||||
|
||||
```php
|
||||
/**
|
||||
* PATCH /api/admin/tenants/{id}/balance — установить точный ₽-баланс тенанта.
|
||||
*
|
||||
* Семантика «set absolute»: админ передаёт целевой balance_rub, сервер
|
||||
* считает знаковую дельту (target − current) и пишет её append-only строкой
|
||||
* balance_transactions(type='manual_adjustment') + saas_admin_audit_log.
|
||||
*
|
||||
* SaaS-уровневый: НЕ tenant-aware. Money — bcmath, lockForUpdate (конвенция
|
||||
* LedgerService / AdminBillingController::refund). balance_leads не трогаем
|
||||
* (Billing v2 Spec A — лиды vestigial, удаляются в Phase B).
|
||||
*/
|
||||
public function updateBalance(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'balance_rub' => ['required', 'string', 'regex:/^-?\d+(\.\d{1,2})?$/'],
|
||||
'reason' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$target = bcadd((string) $validated['balance_rub'], '0', 2); // нормализуем scale 2
|
||||
$reason = isset($validated['reason']) && trim((string) $validated['reason']) !== ''
|
||||
? trim((string) $validated['reason'])
|
||||
: 'Ручная корректировка баланса (админ)';
|
||||
$adminUserId = $this->resolveAdminUserId($request, 'system-balance@liderra.local', 'System Balance Bot');
|
||||
|
||||
/** @var array{balance_rub:string, delta:string, transaction_id:int} $result */
|
||||
$result = DB::transaction(function () use ($id, $target, $reason, $adminUserId, $request): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$id);
|
||||
|
||||
$tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')
|
||||
->lockForUpdate()->first();
|
||||
if ($tenant === null) {
|
||||
abort(404, 'tenant not found');
|
||||
}
|
||||
|
||||
$current = (string) $tenant->balance_rub;
|
||||
$delta = bcsub($target, $current, 2);
|
||||
if (bccomp($delta, '0', 2) === 0) {
|
||||
abort(422, 'balance unchanged');
|
||||
}
|
||||
|
||||
DB::table('tenants')->where('id', $id)->update([
|
||||
'balance_rub' => $target,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$tx = BalanceTransaction::create([
|
||||
'tenant_id' => $id,
|
||||
'type' => BalanceTransaction::TYPE_MANUAL_ADJUSTMENT,
|
||||
'amount_rub' => $delta,
|
||||
'amount_leads' => null,
|
||||
'balance_rub_after' => $target,
|
||||
'balance_leads_after' => null,
|
||||
'description' => $reason,
|
||||
'admin_user_id' => $adminUserId,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminUserId,
|
||||
'action' => 'tenant.balance_adjusted',
|
||||
'target_type' => 'tenant',
|
||||
'target_id' => $id,
|
||||
'target_tenant_id' => $id,
|
||||
'payload_before' => ['balance_rub' => $current],
|
||||
'payload_after' => ['balance_rub' => $target, 'delta' => $delta, 'transaction_id' => $tx->id],
|
||||
'reason' => $reason,
|
||||
'ip_address' => $request->ip() ?? '127.0.0.1',
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
|
||||
return ['balance_rub' => $target, 'delta' => $delta, 'transaction_id' => (int) $tx->id];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'id' => $id,
|
||||
'balance_rub' => $result['balance_rub'],
|
||||
'delta' => $result['delta'],
|
||||
'transaction_id' => $result['transaction_id'],
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
Verify `BalanceTransaction::TYPE_MANUAL_ADJUSTMENT` constant exists (it does — `= 'manual_adjustment'`, seen in the model). If the constant name differs, grep `app/app/Models/BalanceTransaction.php` for `MANUAL_ADJUSTMENT` and use the actual name.
|
||||
|
||||
- [ ] **Step 5: Add the route**
|
||||
|
||||
In `app/routes/web.php`, inside the `Route::middleware('saas-admin')->group(...)` block, right after the tenants `show` route (line ~100):
|
||||
|
||||
```php
|
||||
Route::patch('/api/admin/tenants/{id}/balance', 'App\Http\Controllers\Api\AdminTenantsController@updateBalance')
|
||||
->where('id', '[0-9]+');
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run test to verify it passes**
|
||||
|
||||
Run: `./vendor/bin/pest tests/Feature/Admin/AdminTenantBalanceUpdateTest.php`
|
||||
Expected: PASS (6 tests).
|
||||
|
||||
If `saas_admin_audit_log` insert fails on a missing partition for the current month — create it (`php artisan partitions:create-months`) and re-run; this is the known testing-DB partition quirk, not a code bug.
|
||||
|
||||
- [ ] **Step 7: Run adjacent admin suite for regressions**
|
||||
|
||||
Run: `./vendor/bin/pest tests/Feature/Admin`
|
||||
Expected: no NEW failures (pre-existing partition-gap failures, if any, are unrelated).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация/.claude/worktrees/admin-tenant-balance-edit"
|
||||
git add app/app/Http/Controllers/Api/AdminTenantsController.php \
|
||||
app/routes/web.php \
|
||||
app/tests/Feature/Admin/AdminTenantBalanceUpdateTest.php
|
||||
git commit -m "feat(admin): PATCH tenants/{id}/balance — set exact rub balance + ledger + audit"
|
||||
```
|
||||
|
||||
Use `LEFTHOOK=0 git commit ...` if pre-commit fails on missing worktree binaries (gitleaks.exe/squawk.exe) — known worktree quirk.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Frontend API client `updateTenantBalance`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/api/admin.ts`
|
||||
|
||||
- [ ] **Step 1: Add the function**
|
||||
|
||||
In `app/resources/js/api/admin.ts`, after the `refundTenant` function (~line 372), add:
|
||||
|
||||
```typescript
|
||||
export async function updateTenantBalance(
|
||||
id: number,
|
||||
payload: { balance_rub: string; reason?: string },
|
||||
): Promise<{ id: number; balance_rub: string; delta: string; transaction_id: number }> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.patch<{
|
||||
id: number;
|
||||
balance_rub: string;
|
||||
delta: string;
|
||||
transaction_id: number;
|
||||
}>(`/api/admin/tenants/${id}/balance`, payload);
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Type-check**
|
||||
|
||||
Run from `app/`: `npm run type-check 2>&1 | grep -E "admin.ts" | head`
|
||||
Expected: no errors on `admin.ts`.
|
||||
|
||||
- [ ] **Step 3: No commit yet** — rides with Task 3 (its first consumer, the dialog).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `TenantBalanceDialog.vue` + Vitest
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/resources/js/components/admin/TenantBalanceDialog.vue`
|
||||
- Create: `app/tests/Frontend/TenantBalanceDialog.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Write the Vitest spec (TDD)**
|
||||
|
||||
Create `app/tests/Frontend/TenantBalanceDialog.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
import TenantBalanceDialog from '../../resources/js/components/admin/TenantBalanceDialog.vue';
|
||||
import * as adminApi from '../../resources/js/api/admin';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
function mountDialog(props: Record<string, unknown> = {}) {
|
||||
return mount(TenantBalanceDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
tenantId: 42,
|
||||
tenantName: 'Окна Москва ООО',
|
||||
currentBalanceRub: 1000,
|
||||
...props,
|
||||
},
|
||||
global: { plugins: [vuetify] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
}
|
||||
|
||||
describe('TenantBalanceDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('previews signed delta when new balance entered', async () => {
|
||||
const w = mountDialog();
|
||||
const vm = w.vm as unknown as { newBalance: string; delta: string };
|
||||
vm.newBalance = '2500';
|
||||
await w.vm.$nextTick();
|
||||
// delta = 2500 − 1000 = +1500
|
||||
expect((w.vm as unknown as { delta: string }).delta).toBe('1500.00');
|
||||
});
|
||||
|
||||
it('disables save when balance empty or unchanged', async () => {
|
||||
const w = mountDialog();
|
||||
const vm = w.vm as unknown as { newBalance: string; canSave: boolean };
|
||||
vm.newBalance = '';
|
||||
await w.vm.$nextTick();
|
||||
expect((w.vm as unknown as { canSave: boolean }).canSave).toBe(false);
|
||||
vm.newBalance = '1000'; // равно текущему
|
||||
await w.vm.$nextTick();
|
||||
expect((w.vm as unknown as { canSave: boolean }).canSave).toBe(false);
|
||||
vm.newBalance = '1500';
|
||||
await w.vm.$nextTick();
|
||||
expect((w.vm as unknown as { canSave: boolean }).canSave).toBe(true);
|
||||
});
|
||||
|
||||
it('calls updateTenantBalance with normalized payload and emits saved', async () => {
|
||||
const spy = vi.spyOn(adminApi, 'updateTenantBalance').mockResolvedValue({
|
||||
id: 42,
|
||||
balance_rub: '2500.00',
|
||||
delta: '1500.00',
|
||||
transaction_id: 7,
|
||||
});
|
||||
const w = mountDialog();
|
||||
const vm = w.vm as unknown as { newBalance: string; reason: string; submit: () => Promise<void> };
|
||||
vm.newBalance = '2500';
|
||||
vm.reason = 'тест';
|
||||
await vm.submit();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(42, { balance_rub: '2500.00', reason: 'тест' });
|
||||
expect(w.emitted('saved')).toBeTruthy();
|
||||
expect(w.emitted('saved')![0][0]).toMatchObject({ balance_rub: '2500.00' });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run from `app/`: `npm run test:vue -- TenantBalanceDialog.spec.ts 2>&1 | tail -20`
|
||||
Expected: FAIL — component does not exist.
|
||||
|
||||
- [ ] **Step 3: Create the component**
|
||||
|
||||
Create `app/resources/js/components/admin/TenantBalanceDialog.vue`:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Диалог установки точного ₽-баланса тенанта (SaaS-admin).
|
||||
* Используется из карточки тенанта (TenantDetailHeader) и из строки таблицы
|
||||
* списка (TenantsTable). Семантика «установить точную сумму» — сервер сам
|
||||
* считает знаковую дельту и пишет manual_adjustment + audit.
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { updateTenantBalance } from '../../api/admin';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
tenantId: number;
|
||||
tenantName: string;
|
||||
currentBalanceRub: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
saved: [payload: { balance_rub: string; delta: string; transaction_id: number }];
|
||||
}>();
|
||||
|
||||
const newBalance = ref('');
|
||||
const reason = ref('');
|
||||
const submitting = ref(false);
|
||||
const errorMsg = ref<string | null>(null);
|
||||
|
||||
// Нормализованная целевая сумма (scale 2) — '' если ввод невалиден.
|
||||
const targetNormalized = computed(() => {
|
||||
const raw = newBalance.value.trim().replace(',', '.');
|
||||
if (!/^-?\d+(\.\d{1,2})?$/.test(raw)) return '';
|
||||
return Number(raw).toFixed(2);
|
||||
});
|
||||
|
||||
const delta = computed(() => {
|
||||
if (targetNormalized.value === '') return '';
|
||||
return (Number(targetNormalized.value) - props.currentBalanceRub).toFixed(2);
|
||||
});
|
||||
|
||||
const canSave = computed(
|
||||
() => !submitting.value && targetNormalized.value !== '' && delta.value !== '' && Number(delta.value) !== 0,
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
newBalance.value = '';
|
||||
reason.value = '';
|
||||
errorMsg.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (!canSave.value) return;
|
||||
submitting.value = true;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
const payload: { balance_rub: string; reason?: string } = { balance_rub: targetNormalized.value };
|
||||
if (reason.value.trim() !== '') payload.reason = reason.value.trim();
|
||||
const result = await updateTenantBalance(props.tenantId, payload);
|
||||
emit('saved', { balance_rub: result.balance_rub, delta: result.delta, transaction_id: result.transaction_id });
|
||||
emit('update:modelValue', false);
|
||||
} catch (e) {
|
||||
errorMsg.value = extractErrorMessage(e, 'Не удалось изменить баланс.');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="460"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">Изменить баланс</v-card-title>
|
||||
<v-card-subtitle>{{ tenantName }}</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<div class="text-body-2 text-medium-emphasis mb-3">
|
||||
Текущий баланс: <strong class="num">{{ currentBalanceRub.toFixed(2) }} ₽</strong>
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
v-model="newBalance"
|
||||
label="Новый баланс, ₽"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
density="comfortable"
|
||||
data-testid="balance-input"
|
||||
:hint="targetNormalized === '' && newBalance !== '' ? 'Формат: 1234.56' : ''"
|
||||
persistent-hint
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="reason"
|
||||
label="Причина (необязательно)"
|
||||
type="text"
|
||||
density="comfortable"
|
||||
maxlength="500"
|
||||
class="mt-2"
|
||||
data-testid="reason-input"
|
||||
/>
|
||||
|
||||
<div v-if="delta !== ''" class="preview mt-3 text-body-2">
|
||||
было <span class="num">{{ currentBalanceRub.toFixed(2) }} ₽</span>
|
||||
→ станет <span class="num">{{ targetNormalized }} ₽</span>
|
||||
(<span class="num" :class="Number(delta) < 0 ? 'text-error' : 'text-success'">
|
||||
{{ Number(delta) > 0 ? '+' : '' }}{{ delta }} ₽
|
||||
</span>)
|
||||
</div>
|
||||
|
||||
<v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-3">
|
||||
{{ errorMsg }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" :disabled="submitting" @click="close">Отмена</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="submitting"
|
||||
:disabled="!canSave"
|
||||
data-testid="balance-save"
|
||||
@click="submit"
|
||||
>
|
||||
Сохранить
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run Vitest**
|
||||
|
||||
Run from `app/`: `npm run test:vue -- TenantBalanceDialog.spec.ts 2>&1 | tail -20`
|
||||
Expected: PASS (3 tests). If `extractErrorMessage` signature differs (e.g. single-arg), check `app/resources/js/api/client.ts` and adapt the call.
|
||||
|
||||
- [ ] **Step 5: vue-tsc**
|
||||
|
||||
Run: `npm run type-check 2>&1 | grep -E "TenantBalanceDialog|admin.ts" | head`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 6: Commit (Task 2 api + Task 3 dialog together)**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/api/admin.ts \
|
||||
app/resources/js/components/admin/TenantBalanceDialog.vue \
|
||||
app/tests/Frontend/TenantBalanceDialog.spec.ts
|
||||
git commit -m "feat(admin): TenantBalanceDialog + updateTenantBalance api client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire dialog into tenant detail card
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/components/admin/tenant-detail/TenantDetailHeader.vue`
|
||||
- Modify: `app/resources/js/views/admin/AdminTenantDetailView.vue`
|
||||
|
||||
- [ ] **Step 1: Add «Изменить баланс» button to the balance KPI card**
|
||||
|
||||
In `TenantDetailHeader.vue`, the `defineEmits` is currently:
|
||||
|
||||
```typescript
|
||||
const emit = defineEmits<{
|
||||
back: [];
|
||||
impersonate: [];
|
||||
}>();
|
||||
```
|
||||
|
||||
Change to add `editBalance`:
|
||||
|
||||
```typescript
|
||||
const emit = defineEmits<{
|
||||
back: [];
|
||||
impersonate: [];
|
||||
editBalance: [];
|
||||
}>();
|
||||
```
|
||||
|
||||
In the template, the balance KPI card is:
|
||||
|
||||
```vue
|
||||
<v-card variant="outlined" class="kpi-card pa-4" data-testid="kpi-balance">
|
||||
<div class="kpi-label text-caption text-medium-emphasis">Баланс</div>
|
||||
<div class="kpi-value num" :class="{ 'text-error': tenant.balanceRub < 0 }">
|
||||
{{ formatRub(tenant.balanceRub) }}
|
||||
</div>
|
||||
<div class="kpi-sub text-caption text-medium-emphasis">runway ~{{ tenant.runwayDays }} дн</div>
|
||||
</v-card>
|
||||
```
|
||||
|
||||
Add an «Изменить» button after the `kpi-sub` div, inside the card:
|
||||
|
||||
```vue
|
||||
<v-card variant="outlined" class="kpi-card pa-4" data-testid="kpi-balance">
|
||||
<div class="kpi-label text-caption text-medium-emphasis">Баланс</div>
|
||||
<div class="kpi-value num" :class="{ 'text-error': tenant.balanceRub < 0 }">
|
||||
{{ formatRub(tenant.balanceRub) }}
|
||||
</div>
|
||||
<div class="kpi-sub text-caption text-medium-emphasis">runway ~{{ tenant.runwayDays }} дн</div>
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
density="comfortable"
|
||||
prepend-icon="mdi-pencil"
|
||||
class="mt-1 px-0"
|
||||
data-testid="edit-balance-btn"
|
||||
@click="emit('editBalance')"
|
||||
>
|
||||
Изменить
|
||||
</v-btn>
|
||||
</v-card>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Mount the dialog in the detail view**
|
||||
|
||||
In `AdminTenantDetailView.vue`:
|
||||
|
||||
Add import after the existing component imports (~line 24):
|
||||
|
||||
```typescript
|
||||
import TenantBalanceDialog from '../../components/admin/TenantBalanceDialog.vue';
|
||||
```
|
||||
|
||||
Add state near `impersonationOpen` (~line 66):
|
||||
|
||||
```typescript
|
||||
const balanceDialogOpen = ref(false);
|
||||
```
|
||||
|
||||
In the template, inside the `v-container v-if="tenant"` block, after `<ImpersonationDialog ... />`, add:
|
||||
|
||||
```vue
|
||||
<TenantBalanceDialog
|
||||
v-model="balanceDialogOpen"
|
||||
:tenant-id="tenant.id"
|
||||
:tenant-name="tenant.name"
|
||||
:current-balance-rub="tenant.balanceRub"
|
||||
@saved="onBalanceSaved"
|
||||
/>
|
||||
```
|
||||
|
||||
Wire the header emit on the `<TenantDetailHeader>` element:
|
||||
|
||||
```vue
|
||||
<TenantDetailHeader
|
||||
:tenant="tenant"
|
||||
@back="goBack"
|
||||
@impersonate="impersonationOpen = true"
|
||||
@edit-balance="balanceDialogOpen = true"
|
||||
/>
|
||||
```
|
||||
|
||||
Add the `onBalanceSaved` handler (after `goBack`):
|
||||
|
||||
```typescript
|
||||
async function onBalanceSaved(): Promise<void> {
|
||||
await loadTenant();
|
||||
}
|
||||
```
|
||||
|
||||
Add `balanceDialogOpen` to `defineExpose` so Vitest can drive it:
|
||||
|
||||
```typescript
|
||||
defineExpose({ tenant, activeTab, impersonationOpen, balanceDialogOpen, loadTenant });
|
||||
```
|
||||
|
||||
Confirm `AdminTenantDetail` mock type has a numeric `id` field (it does — `mockTenantDetail.ts:11 id: number`) and `balanceRub: number` (used by header). If `tenant.id` is absent on the mapped type, check `adminTenantDetailMapper.ts` maps `tenant.id` from the API response and add it.
|
||||
|
||||
- [ ] **Step 3: Run frontend checks**
|
||||
|
||||
Run from `app/`:
|
||||
|
||||
```bash
|
||||
npm run test:vue -- AdminTenantDetailView 2>&1 | tail -20
|
||||
npm run type-check 2>&1 | grep -E "AdminTenantDetailView|TenantDetailHeader" | head
|
||||
```
|
||||
|
||||
Expected: existing detail-view tests still pass; vue-tsc clean. If an existing test mounts `TenantDetailHeader` and asserts emitted events, it remains valid (we only added an emit).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/admin/tenant-detail/TenantDetailHeader.vue \
|
||||
app/resources/js/views/admin/AdminTenantDetailView.vue
|
||||
git commit -m "feat(admin): wire balance dialog into tenant detail card"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire dialog into tenant list table
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/components/admin/tenants/TenantsTable.vue`
|
||||
- Modify: `app/resources/js/views/admin/AdminTenantsView.vue`
|
||||
|
||||
- [ ] **Step 1: Add row action + emit in TenantsTable**
|
||||
|
||||
In `TenantsTable.vue`, `defineEmits` is:
|
||||
|
||||
```typescript
|
||||
const emit = defineEmits<{
|
||||
rowClick: [tenant: AdminTenant];
|
||||
impersonate: [tenant: AdminTenant];
|
||||
}>();
|
||||
```
|
||||
|
||||
Add `editBalance`:
|
||||
|
||||
```typescript
|
||||
const emit = defineEmits<{
|
||||
rowClick: [tenant: AdminTenant];
|
||||
impersonate: [tenant: AdminTenant];
|
||||
editBalance: [tenant: AdminTenant];
|
||||
}>();
|
||||
```
|
||||
|
||||
In the `#[`item.actions`]` slot, add a balance-edit icon button before the impersonate tooltip (inside the same slot):
|
||||
|
||||
```vue
|
||||
<template #[`item.actions`]="{ item }: { item: AdminTenant }">
|
||||
<v-tooltip text="Изменить баланс" location="top" aria-label="Изменить баланс">
|
||||
<template #activator="{ props: tipProps }">
|
||||
<v-btn
|
||||
v-bind="tipProps"
|
||||
icon="mdi-cash-edit"
|
||||
variant="text"
|
||||
size="small"
|
||||
density="comfortable"
|
||||
:aria-label="`Изменить баланс для ${item.name}`"
|
||||
:data-testid="`edit-balance-btn-${item.id}`"
|
||||
@click.stop="emit('editBalance', item)"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
text="Войти как клиент (impersonation)"
|
||||
location="top"
|
||||
aria-label="Войти как клиент (impersonation)"
|
||||
>
|
||||
<template #activator="{ props: tipProps }">
|
||||
<v-btn
|
||||
v-bind="tipProps"
|
||||
icon="mdi-account-switch"
|
||||
variant="text"
|
||||
size="small"
|
||||
density="comfortable"
|
||||
:aria-label="`Войти как клиент (impersonation) для ${item.name}`"
|
||||
:disabled="item.status === 'suspended'"
|
||||
:data-testid="`impersonate-btn-${item.id}`"
|
||||
@click.stop="emit('impersonate', item)"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
```
|
||||
|
||||
Widen the actions column so two icons fit — change the `actions` header `width: 56` to `width: 96` in the `:headers` array.
|
||||
|
||||
- [ ] **Step 2: Mount the dialog in the list view**
|
||||
|
||||
Read `app/resources/js/views/admin/AdminTenantsView.vue` first to see how it consumes `TenantsTable` and where it keeps state / how it reloads the list (look for the `listAdminTenants` call and the mapped tenants ref).
|
||||
|
||||
Then:
|
||||
|
||||
- Import `TenantBalanceDialog` and (if not already) ensure tenants list is in a reactive ref with a reload function.
|
||||
- Add state: `const balanceDialogOpen = ref(false);` and `const balanceTarget = ref<AdminTenant | null>(null);`.
|
||||
- Wire `<TenantsTable ... @edit-balance="openBalanceDialog" />`.
|
||||
- Add handler:
|
||||
|
||||
```typescript
|
||||
function openBalanceDialog(t: AdminTenant): void {
|
||||
balanceTarget.value = t;
|
||||
balanceDialogOpen.value = true;
|
||||
}
|
||||
async function onBalanceSaved(): Promise<void> {
|
||||
// перезагрузить список, чтобы строка показала новый баланс
|
||||
await loadTenants(); // имя реальной функции загрузки — взять из файла
|
||||
}
|
||||
```
|
||||
|
||||
(Use the actual list-loader function name found in the file.)
|
||||
- Mount the dialog (guarded by `balanceTarget`):
|
||||
|
||||
```vue
|
||||
<TenantBalanceDialog
|
||||
v-if="balanceTarget"
|
||||
v-model="balanceDialogOpen"
|
||||
:tenant-id="balanceTarget.id"
|
||||
:tenant-name="balanceTarget.name"
|
||||
:current-balance-rub="balanceTarget.balanceRub"
|
||||
@saved="onBalanceSaved"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run frontend checks**
|
||||
|
||||
Run from `app/`:
|
||||
|
||||
```bash
|
||||
npm run test:vue -- AdminTenantsView 2>&1 | tail -20
|
||||
npm run type-check 2>&1 | grep -E "AdminTenantsView|TenantsTable" | head
|
||||
```
|
||||
|
||||
Expected: existing list-view tests pass; vue-tsc clean.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/admin/tenants/TenantsTable.vue \
|
||||
app/resources/js/views/admin/AdminTenantsView.vue
|
||||
git commit -m "feat(admin): wire balance dialog into tenant list table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Full frontend + backend regression
|
||||
|
||||
**Files:** none directly; fix wherever it breaks.
|
||||
|
||||
- [ ] **Step 1: Run targeted suites**
|
||||
|
||||
```bash
|
||||
cd app
|
||||
./vendor/bin/pest tests/Feature/Admin/AdminTenantBalanceUpdateTest.php
|
||||
npm run test:vue -- TenantBalanceDialog 2>&1 | tail -10
|
||||
npm run type-check 2>&1 | tail -20
|
||||
npm run lint:vue 2>&1 | tail -20
|
||||
npm run build 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: all green (pre-existing unrelated failures excluded).
|
||||
|
||||
- [ ] **Step 2: Fix any breaks, commit incrementally**
|
||||
|
||||
Each fix = own commit: `fix(admin): <what>`.
|
||||
|
||||
---
|
||||
|
||||
## Spec Coverage Check (self-review)
|
||||
|
||||
| Spec requirement | Task | Status |
|
||||
|---|---|---|
|
||||
| Set-absolute semantics, server computes delta | Task 1 | ✓ |
|
||||
| `manual_adjustment` ledger row, signed amount | Task 1 | ✓ |
|
||||
| `saas_admin_audit_log` `tenant.balance_adjusted` | Task 1 | ✓ |
|
||||
| bcmath + lockForUpdate, SaaS connection / SET LOCAL | Task 1 | ✓ |
|
||||
| Validation: decimal regex, negative allowed, reason optional, no-op 422, 404 | Task 1 | ✓ |
|
||||
| Route under `saas-admin`, id-constrained | Task 1 | ✓ |
|
||||
| API client `updateTenantBalance` | Task 2 | ✓ |
|
||||
| Shared `TenantBalanceDialog` with live delta preview | Task 3 | ✓ |
|
||||
| Edit from tenant detail card | Task 4 | ✓ |
|
||||
| Edit inline from list table | Task 5 | ✓ |
|
||||
| balance_leads NOT edited | Task 1 (amount_leads null, no leads field) | ✓ |
|
||||
| Tests: Pest + Vitest | Tasks 1, 3 | ✓ |
|
||||
|
||||
---
|
||||
|
||||
## Plan complete
|
||||
|
||||
**Deployment after merge:** копир-паттерном на боевой `liderra.ru` (3 PHP-файла + frontend `public/build`); DDL не требуется. После выкатки заказчик выставляет реальные балансы тестовым тенантам через UI.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,807 +0,0 @@
|
||||
# Биллинг v2 Спек B — политика дублей: план реализации
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Убрать наш телефонный антифрод-фильтр дублей (доверяем дедупу поставщика), но гарантировать на уровне БД, что одна поставка одному клиенту тарифицируется ровно один раз; лимит шеринга — 3 разных клиента.
|
||||
|
||||
**Architecture:** Удаляем `DuplicateDetector` из обоих job-путей. В шеринг-пути (`RouteSupplierLeadJob`) раздача переводится с лимита-по-проектам на лимит-по-клиентам (один проект на клиента — DISTINCT ON по `tenant_id`, выбор проекта с макс. остатком дневного лимита; cap=3 клиента). Новая таблица-замок `supplier_lead_deliveries` (PK `supplier_lead_id`+`tenant_id`) + `insertOrIgnore` внутри транзакции создания сделки гарантирует «одна поставка → один оплаченный лид на клиента» даже при гонках/перезапусках/CSV-восстановлении.
|
||||
|
||||
**Tech Stack:** Laravel 13, PostgreSQL 16 (партиционированная `deals`, RLS по `app.current_tenant_id`, 5 ролей), Pest 4 (`--parallel`), bcmath/`LedgerService`. Worktree `.claude/worktrees/billing-v2-spec-b/`, ветка `feat/billing-v2-spec-b` (база origin/main `ff2ee59e`, Спек A уже влит).
|
||||
|
||||
**Спека:** `docs/superpowers/specs/2026-05-23-billing-v2-spec-b-duplicates-design.md`
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Важный контекст базы (прочитать до старта)
|
||||
|
||||
1. **Спек A влит в origin/main.** `App\Services\Billing\LedgerService::chargeForDelivery` — always-rub: списывает `balance_rub` (bcmath), пишет `LeadCharge(charge_source='rub')` + `BalanceTransaction` + `supplier_lead_costs`; `balance_leads` НЕ трогает. Возвращает `ChargeResult`.
|
||||
2. **Тест-долг Спека A.** Часть существующих тестов (`app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` ассертит `balance_leads → 99`; `RouteSupplierLeadJobBillingTest.php` имеет кейс `charge_source='prepaid'`) противоречит always-rub `LedgerService` и, вероятно, **уже красная на этой базе**. Это НЕ наша регрессия. Task 1 устанавливает фактический baseline. Новые тесты Спека B заякорены на **model-agnostic** ассерты (число `Deal` / `LeadCharge` на клиента + строки таблицы-замка) и сетап через хелпер `prepareSharingFlow` с достаточным `balance_rub`, чтобы не зависеть от prepaid/rub.
|
||||
3. **Два job-пути.** `ProcessWebhookJob` (прямой вебхук, `WebhookReceiveController`) — идемпотентность по `vid` через `webhook_dedup_keys (tenant_id, source_crm_id)`; замок там НЕ нужен. `RouteSupplierLeadJob` (шеринг, `SupplierWebhookController` + `CsvReconcileJob`) — замок нужен здесь.
|
||||
4. **Гранты — blanket.** `db/02_grants.sql` выдаёт `GRANT ... ON ALL TABLES` + `ALTER DEFAULT PRIVILEGES`. Новая tenant-таблица грантов отдельно не требует. На dev — `postgres` superuser.
|
||||
5. **`duplicate_detected` в origin/main отсутствует** (ни в `db/schema.sql`, ни во фронте, ни в backend) — чистить нечего, только verify-grep. Колонка `deals.duplicate_of_id` (schema.sql:1626) + индекс (schema.sql:1688) — есть.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Действие | Ответственность |
|
||||
|---|---|---|
|
||||
| `db/migrations/2026_05_23_200_supplier_lead_deliveries.sql` | Create | DDL таблицы-замка (RLS + PK + FK) |
|
||||
| `app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php` | Create | парная Laravel-миграция (idempotency guard) |
|
||||
| `db/migrations/2026_05_23_201_drop_deals_duplicate_of_id_index.sql` | Create | DROP лишнего индекса |
|
||||
| `app/database/migrations/2026_05_23_201000_drop_deals_duplicate_of_id_index.php` | Create | парная Laravel-миграция |
|
||||
| `db/schema.sql` | Modify | +CREATE TABLE supplier_lead_deliveries; −CREATE INDEX deals(duplicate_of_id); header v8.32→v8.33 |
|
||||
| `db/CHANGELOG_schema.md` | Modify | +запись v8.33 |
|
||||
| `app/app/Models/SupplierLeadDelivery.php` | Create | Eloquent-модель замка |
|
||||
| `app/app/Services/DuplicateDetector.php` | Delete | сервис телефонного фильтра |
|
||||
| `app/app/Jobs/ProcessWebhookJob.php` | Modify | убрать findMaster + markAsDuplicate, всегда charge |
|
||||
| `app/app/Jobs/RouteSupplierLeadJob.php` | Modify | убрать DuplicateDetector из сигнатур; +замок insertOrIgnore; раздача по клиентам |
|
||||
| `app/app/Services/LeadRouter.php` | Modify | DISTINCT ON (tenant_id) — один проект на клиента (макс. остаток лимита) |
|
||||
| `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` | Create | тесты замка + раздачи по клиентам |
|
||||
| `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` | Modify | убрать DuplicateDetector из `runRouteJob`; удалить/переписать дубль-тесты |
|
||||
| `app/tests/Feature/ProcessWebhookJobTest.php` | Modify | убрать дубль-тесты; +тест «два vid, один телефон → оба charge» |
|
||||
| прочие тесты с `DuplicateDetector`/`runRouteJob` | Modify | привести сигнатуры к 6-арговому handle() |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Baseline — зафиксировать фактическое состояние
|
||||
|
||||
**Files:** нет правок (только прогон).
|
||||
|
||||
- [ ] **Step 1: Подготовить тестовую БД worktree**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd .claude/worktrees/billing-v2-spec-b/app
|
||||
php artisan migrate:fresh --env=testing
|
||||
php artisan partitions:create-months --env=testing
|
||||
```
|
||||
|
||||
Expected: миграции проходят; партиции `deals_*`, `balance_transactions_*`, `supplier_lead_costs_*` за текущий/смежные месяцы созданы. (Квирк Спека A: при нехватке партиций тесты падают с partition-ошибкой — пересоздать.)
|
||||
|
||||
- [ ] **Step 2: Прогнать затронутые сюиты, записать baseline**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php tests/Feature/ProcessWebhookJobTest.php tests/Feature/Integration/SupplierLeadFlowTest.php tests/Feature/Supplier/AutoPauseFlowTest.php tests/Feature/Supplier/CsvReconcileJobTest.php tests/Feature/Pd/DealCreatePdLogTest.php
|
||||
```
|
||||
|
||||
Expected: записать в заметку, какие тесты GREEN, какие RED. Ожидаемо красные (тест-долг Спека A, НЕ наша задача): `RouteSupplierLeadJobTest` (balance_leads ассерты), prepaid-кейс в `RouteSupplierLeadJobBillingTest`. Всё остальное должно быть GREEN.
|
||||
|
||||
- [ ] **Step 3: Подтвердить модель списания**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -n "charge_source\|balance_rub\|balance_leads" app/Services/Billing/LedgerService.php
|
||||
```
|
||||
|
||||
Expected: `charge_source` = `'rub'` хардкод, списывается `balance_rub`. Зафиксировать: новые тесты используют `balance_rub` и `LeadCharge::count()`.
|
||||
|
||||
- [ ] **Step 4: Коммит заметки baseline (опционально)**
|
||||
|
||||
Если ведёте журнал — зафиксируйте baseline-вывод в описании задачи. Кода-коммита нет.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Таблица-замок `supplier_lead_deliveries`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `db/migrations/2026_05_23_200_supplier_lead_deliveries.sql`
|
||||
- Create: `app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php`
|
||||
- Modify: `db/schema.sql` (вставить CREATE TABLE; header v8.32→v8.33)
|
||||
- Modify: `db/CHANGELOG_schema.md`
|
||||
- Create: `app/app/Models/SupplierLeadDelivery.php`
|
||||
- Test: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` (schema-часть)
|
||||
|
||||
- [ ] **Step 1: Написать падающий schema-тест**
|
||||
|
||||
Создать `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('supplier_lead_deliveries table exists with PK (supplier_lead_id, tenant_id) and RLS', function (): void {
|
||||
$cols = collect(DB::select(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_lead_deliveries'"
|
||||
))->pluck('column_name')->all();
|
||||
|
||||
expect($cols)->toContain('supplier_lead_id')
|
||||
->toContain('tenant_id')
|
||||
->toContain('deal_id')
|
||||
->toContain('created_at');
|
||||
|
||||
$pk = collect(DB::select(
|
||||
"SELECT a.attname FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = 'supplier_lead_deliveries'::regclass AND i.indisprimary"
|
||||
))->pluck('attname')->sort()->values()->all();
|
||||
expect($pk)->toBe(['supplier_lead_id', 'tenant_id']);
|
||||
|
||||
$rls = DB::selectOne(
|
||||
"SELECT relrowsecurity FROM pg_class WHERE relname = 'supplier_lead_deliveries'"
|
||||
);
|
||||
expect($rls->relrowsecurity)->toBeTrue();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить — убедиться, что падает**
|
||||
|
||||
Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`
|
||||
Expected: FAIL (таблицы нет).
|
||||
|
||||
- [ ] **Step 3: Написать DDL-файл миграции**
|
||||
|
||||
Создать `db/migrations/2026_05_23_200_supplier_lead_deliveries.sql`:
|
||||
|
||||
```sql
|
||||
-- =============================================================================
|
||||
-- supplier_lead_deliveries — замок «одна поставка одному клиенту = один раз»
|
||||
-- (Billing v2 Spec B). Ключ по поставке (supplier_lead_id), НЕ по телефону —
|
||||
-- разные поставки с одним телефоном остаются отдельными платными лидами.
|
||||
-- Защищает шеринг-путь (RouteSupplierLeadJob) от наших собственных дублей
|
||||
-- при гонках / перезапусках задачи / CSV-восстановлении.
|
||||
-- =============================================================================
|
||||
CREATE TABLE supplier_lead_deliveries (
|
||||
supplier_lead_id BIGINT NOT NULL REFERENCES supplier_leads(id) ON DELETE CASCADE,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
deal_id BIGINT, -- созданная сделка; без FK (deals партиционирована)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (supplier_lead_id, tenant_id)
|
||||
);
|
||||
|
||||
ALTER TABLE supplier_lead_deliveries ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON supplier_lead_deliveries
|
||||
USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Написать парную Laravel-миграцию**
|
||||
|
||||
Создать `app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Idempotency: если schema.sql уже загружен (migrate:fresh), таблица есть — пропускаем.
|
||||
if (DB::selectOne('SELECT 1 AS ok FROM pg_class WHERE relname = ?', ['supplier_lead_deliveries']) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sql = file_get_contents(base_path('../db/migrations/2026_05_23_200_supplier_lead_deliveries.sql'));
|
||||
if ($sql === false) {
|
||||
throw new RuntimeException('Migration SQL file not found.');
|
||||
}
|
||||
DB::unprepared($sql);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::unprepared('DROP TABLE IF EXISTS supplier_lead_deliveries CASCADE;');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Вставить CREATE TABLE в `db/schema.sql`**
|
||||
|
||||
Вставить блок из Step 3 (без комментария-шапки повторно — достаточно одного) в `db/schema.sql` сразу ПОСЛЕ блока `CREATE TABLE webhook_dedup_keys (...)` с его индексами/RLS (найти `grep -n "CREATE TABLE webhook_dedup_keys" db/schema.sql`). Обновить header-строку версии:
|
||||
|
||||
```
|
||||
-- Версия: v8.33 (23.05.2026 — Billing v2 Spec B: +supplier_lead_deliveries замок поставка↔клиент; −индекс deals(duplicate_of_id))
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Запись в `db/CHANGELOG_schema.md`**
|
||||
|
||||
Добавить сверху списка изменений:
|
||||
|
||||
```markdown
|
||||
## v8.33 (2026-05-23) — Billing v2 Spec B: политика дублей
|
||||
|
||||
- **+таблица `supplier_lead_deliveries`** (PK `supplier_lead_id`+`tenant_id`, FK на `supplier_leads` ON DELETE CASCADE, `deal_id` без FK, RLS `tenant_isolation`). Замок «одна поставка одному клиенту = один оплаченный лид» для шеринг-пути.
|
||||
- **−индекс `deals (duplicate_of_id) WHERE duplicate_of_id IS NOT NULL`** — концепция телефонного дедупа удалена (DuplicateDetector); колонка `deals.duplicate_of_id` оставлена спящей.
|
||||
- Метрики: +1 таблица, −1 индекс. (Сверять с header schema.sql.)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Создать Eloquent-модель**
|
||||
|
||||
Создать `app/app/Models/SupplierLeadDelivery.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Замок «поставка ↔ клиент» (Billing v2 Spec B). Композитный PK без автоинкремента.
|
||||
* Пишется в шеринг-пути (RouteSupplierLeadJob) через insertOrIgnore под RLS-контекстом.
|
||||
*/
|
||||
class SupplierLeadDelivery extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $primaryKey = null;
|
||||
|
||||
protected $fillable = ['supplier_lead_id', 'tenant_id', 'deal_id', 'created_at'];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Пересоздать тестовую БД и прогнать schema-тест**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
php artisan migrate:fresh --env=testing && php artisan partitions:create-months --env=testing
|
||||
php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Коммит**
|
||||
|
||||
```bash
|
||||
git add db/migrations/2026_05_23_200_supplier_lead_deliveries.sql \
|
||||
app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php \
|
||||
db/schema.sql db/CHANGELOG_schema.md \
|
||||
app/app/Models/SupplierLeadDelivery.php \
|
||||
app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php
|
||||
git commit -m "feat(billing-v2): supplier_lead_deliveries lock table (Spec B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Раздача по клиентам (LeadRouter — один проект на клиента)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/LeadRouter.php`
|
||||
- Test: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` (добавить кейс)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест «один клиент, 2 проекта → 1 сделка»**
|
||||
|
||||
Добавить в `SupplierLeadDeliveryGuardTest.php` (хелперы `prepareSharingFlow` / `linkProjectToSupplier` — из `tests/Pest.php`; сверить сигнатуру по `RouteSupplierLeadJobBillingTest.php`):
|
||||
|
||||
```php
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\LeadCharge;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
|
||||
it('one delivery to a tenant with 2 eligible projects → exactly 1 deal + 1 charge', function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twoproj.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100000.00']);
|
||||
|
||||
// Два подходящих проекта одного клиента, разный остаток лимита.
|
||||
$pLow = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 9, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
$pHigh = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
linkProjectToSupplier($pLow, $sp);
|
||||
linkProjectToSupplier($pHigh, $sp);
|
||||
|
||||
$vid = 600001;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => ['vid' => $vid, 'project' => 'B1_twoproj.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
||||
]);
|
||||
|
||||
runRouteJob($lead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
|
||||
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
// Выбран проект с наибольшим остатком лимита.
|
||||
expect($pHigh->fresh()->delivered_today)->toBe(1);
|
||||
expect($pLow->fresh()->delivered_today)->toBe(9);
|
||||
});
|
||||
```
|
||||
|
||||
NB: `runRouteJob` уже определён в `RouteSupplierLeadJobTest.php`, но это другой файл. Определить локальный хелпер в этом файле (после Task 4 он будет 6-арговым — см. ниже), либо вызвать job напрямую. Чтобы не зависеть от Task 4, в этом тесте вызвать job через `app()`-резолв 6 аргументов ПОСЛЕ Task 4. Поэтому: написать тело теста, но запускать его в Step 3 уже после правки LeadRouter, а полную зелёность по job — в Task 6.
|
||||
|
||||
- [ ] **Step 2: Переписать `LeadRouter::matchEligibleProjects` на DISTINCT ON (tenant_id)**
|
||||
|
||||
Заменить тело `matchEligibleProjects` в `app/app/Services/LeadRouter.php` — добавить `DISTINCT ON (projects.tenant_id)` с выбором проекта максимального остатка лимита:
|
||||
|
||||
```php
|
||||
/** @var Collection<int, Project> $candidates */
|
||||
$candidates = Project::on('pgsql_supplier')
|
||||
->select('projects.*')
|
||||
->selectRaw('DISTINCT ON (projects.tenant_id) projects.id AS __distinct_marker')
|
||||
->whereExists(function ($q) use ($supplierProject): void {
|
||||
$q->selectRaw('1')
|
||||
->from('project_supplier_links')
|
||||
->whereColumn('project_supplier_links.project_id', 'projects.id')
|
||||
->where('project_supplier_links.supplier_project_id', $supplierProject->id);
|
||||
})
|
||||
->where('is_active', true)
|
||||
->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit])
|
||||
->whereRaw('delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)')
|
||||
->whereExists(function ($q): void {
|
||||
$q->selectRaw('1')
|
||||
->from('tenants')
|
||||
->whereColumn('tenants.id', 'projects.tenant_id')
|
||||
->where(function ($qq): void {
|
||||
$qq->where('tenants.balance_leads', '>', 0)
|
||||
->orWhere('tenants.balance_rub', '>', 0);
|
||||
});
|
||||
})
|
||||
->orderBy('projects.tenant_id')
|
||||
->orderByRaw('COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today DESC')
|
||||
->orderBy('projects.created_at')
|
||||
->orderBy('projects.id')
|
||||
->get();
|
||||
|
||||
return $candidates->values();
|
||||
```
|
||||
|
||||
NB: смешение `DISTINCT ON` + Eloquent `select('projects.*')` хрупко. **Предпочтительный вариант** — сырой select без маркера:
|
||||
|
||||
```php
|
||||
$candidates = Project::on('pgsql_supplier')
|
||||
->fromRaw('projects')
|
||||
->whereExists(/* project_supplier_links ... */)
|
||||
->where('is_active', true)
|
||||
->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit])
|
||||
->whereRaw('delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)')
|
||||
->whereExists(/* tenants balance ... */)
|
||||
->orderByRaw('projects.tenant_id, COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today DESC, projects.created_at, projects.id')
|
||||
->selectRaw('DISTINCT ON (projects.tenant_id) projects.*')
|
||||
->get();
|
||||
```
|
||||
|
||||
Реализатор выбирает рабочий из двух (проверить SQL прогоном). Семантика обязательна: **ровно один Project на tenant_id, с максимальным остатком `COALESCE(effective_daily_limit_today, daily_limit_target) - delivered_today`; тай-брейк `created_at, id`**.
|
||||
|
||||
- [ ] **Step 3: Прогон существующих router-зависимых тестов**
|
||||
|
||||
Run: `php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php --filter="caps deal creation at 3"`
|
||||
Expected: тест cap=3 (5 клиентов по 1 проекту) остаётся GREEN (DISTINCT ON не меняет результат при одном проекте на клиента). Если упал из-за DuplicateDetector-аргумента — это чинится в Task 4; здесь убедиться, что SQL DISTINCT ON валиден (нет SQL-ошибки).
|
||||
|
||||
- [ ] **Step 4: Коммит**
|
||||
|
||||
```bash
|
||||
git add app/app/Services/LeadRouter.php app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php
|
||||
git commit -m "feat(billing-v2): LeadRouter — one project per tenant (max remaining limit)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Удалить `DuplicateDetector` из `RouteSupplierLeadJob`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php`
|
||||
- Modify: `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` (сигнатура `runRouteJob`, удалить дубль-тесты)
|
||||
|
||||
- [ ] **Step 1: Убрать DuplicateDetector из `handle()` и `createDealCopyForProject()`**
|
||||
|
||||
В `app/app/Jobs/RouteSupplierLeadJob.php`:
|
||||
|
||||
- Удалить `use App\Services\DuplicateDetector;`.
|
||||
- Из сигнатуры `handle(...)` убрать параметр `DuplicateDetector $duplicateDetector,`.
|
||||
- Из вызова `$this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode)` убрать `$duplicateDetector`.
|
||||
- Из сигнатуры `createDealCopyForProject(...)` убрать параметр `DuplicateDetector $duplicateDetector,`.
|
||||
- Удалить блок поиска master + ветку дубля (строки ~274–306: `$master = $duplicateDetector->findMaster(...)` ... `if ($master !== null && $master->id !== $deal->id) { ... return false; }`). Сделка всегда идёт на `chargeForDelivery`.
|
||||
- Обновить doc-комментарии (убрать упоминания DuplicateDetector/Биз-19/duplicate_of_id).
|
||||
|
||||
- [ ] **Step 2: Обновить тест-хелпер и удалить дубль-тесты**
|
||||
|
||||
В `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php`:
|
||||
|
||||
- Убрать `use App\Services\DuplicateDetector;`.
|
||||
- В `runRouteJob()` и в инлайн-вызове теста «caps deal creation at 3» убрать аргумент `app(DuplicateDetector::class),` (handle() теперь 6-арговый).
|
||||
- Удалить тест `it('marks duplicate via DuplicateDetector — no charge ...')` (строки ~158–204) — концепция удалена.
|
||||
- Переписать тест `it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean')` → новое имя/поведение: pre-existing deal с тем же телефоном (другой `vid`) НЕ подавляет списание; ожидать `deals_created_count = 3`, все три баланса/счётчики списаны. (См. также Task 7 — там добавляются model-agnostic тесты; здесь достаточно убрать `duplicate_of_id`-ассерты и привести ожидание к «3 charged».)
|
||||
|
||||
- [ ] **Step 3: Прогон**
|
||||
|
||||
Run: `php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php`
|
||||
Expected: тесты, не завязанные на `balance_leads`-долг, GREEN; компиляция (6-арговый handle) проходит. Красные строго из-за `balance_leads`-ассертов (тест-долг Спека A) — допустимо; если задача включает их починку, мигрировать на `balance_rub` (см. Task 7 Step 4).
|
||||
|
||||
- [ ] **Step 4: Коммит**
|
||||
|
||||
```bash
|
||||
git add app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
|
||||
git commit -m "refactor(billing-v2): drop DuplicateDetector from RouteSupplierLeadJob (Spec B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Удалить `DuplicateDetector` из `ProcessWebhookJob` + сам сервис
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Jobs/ProcessWebhookJob.php`
|
||||
- Delete: `app/app/Services/DuplicateDetector.php`
|
||||
- Modify: `app/tests/Feature/ProcessWebhookJobTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест «два vid, один телефон → оба charge»**
|
||||
|
||||
В `app/tests/Feature/ProcessWebhookJobTest.php` добавить (сверить сетап с существующими тестами файла — tenant с балансом, dispatch `ProcessWebhookJob`):
|
||||
|
||||
```php
|
||||
it('charges both leads with same phone but different vid (no phone dedup)', function (): void {
|
||||
// Сетап tenant + project как в соседних тестах файла.
|
||||
// Прогнать ProcessWebhookJob дважды: тот же phone, разные vid.
|
||||
// Ожидать: 2 Deal, баланс списан дважды, ни у одной нет duplicate_of_id.
|
||||
// (точный сетап — по образцу существующих тестов ProcessWebhookJobTest)
|
||||
})->todo();
|
||||
```
|
||||
|
||||
Затем заменить `->todo()` на полноценный тест по образцу существующего «новая сделка списывает баланс» из этого же файла (взять его сетап tenant/project/payload, продублировать вызов с двумя разными `vid`, одинаковым `phone`; ассертить 2 сделки + двойное списание).
|
||||
|
||||
- [ ] **Step 2: Запустить — убедиться, что падает (или показывает старое поведение)**
|
||||
|
||||
Run: `php artisan test tests/Feature/ProcessWebhookJobTest.php --filter="same phone but different vid"`
|
||||
Expected: при наличии DuplicateDetector второй лид помечается дублем (FAIL: ожидаем 2 charge, получаем 1).
|
||||
|
||||
- [ ] **Step 3: Убрать DuplicateDetector из `ProcessWebhookJob`**
|
||||
|
||||
В `app/app/Jobs/ProcessWebhookJob.php`:
|
||||
|
||||
- Удалить `use App\Services\DuplicateDetector;`.
|
||||
- Удалить `$duplicateDetector = app(DuplicateDetector::class);` и его передачу в `DB::transaction`.
|
||||
- Удалить блок поиска master + ветку (строки ~119–133: `$master = $duplicateDetector->findMaster(...)` ... `if ($master !== null && ...) { $this->markAsDuplicate(...); return; }`). После проверки `wasRecentlyCreated` сразу `$this->chargeNewLead(...)`.
|
||||
- Удалить приватный метод `markAsDuplicate(...)` (строки ~144–165).
|
||||
- Обновить doc-комментарии (убрать абзац про Биз-19/DuplicateDetector).
|
||||
|
||||
- [ ] **Step 4: Удалить сервис и дубль-тесты**
|
||||
|
||||
```bash
|
||||
rm app/app/Services/DuplicateDetector.php
|
||||
```
|
||||
|
||||
В `app/tests/Feature/ProcessWebhookJobTest.php` удалить тесты телефонного дедупа (master в 24ч → дубль / master старше 24ч / ActivityLog duplicate_of). Оставить/адаптировать только релевантные (vid-идемпотентность, zero-balance).
|
||||
|
||||
- [ ] **Step 5: Прогон**
|
||||
|
||||
Run: `php artisan test tests/Feature/ProcessWebhookJobTest.php`
|
||||
Expected: GREEN (включая новый тест из Step 1).
|
||||
|
||||
- [ ] **Step 6: Verify — нет висячих ссылок на DuplicateDetector**
|
||||
|
||||
Run: `grep -rn "DuplicateDetector\|findMaster\|markAsDuplicate" app/`
|
||||
Expected: 0 совпадений.
|
||||
|
||||
- [ ] **Step 7: Коммит**
|
||||
|
||||
```bash
|
||||
git add app/app/Jobs/ProcessWebhookJob.php app/tests/Feature/ProcessWebhookJobTest.php
|
||||
git rm app/app/Services/DuplicateDetector.php
|
||||
git commit -m "refactor(billing-v2): remove DuplicateDetector + phone dedup from ProcessWebhookJob (Spec B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Замок в `RouteSupplierLeadJob::createDealCopyForProject`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php`
|
||||
- Test: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест замка (повторная выдача той же поставки клиенту)**
|
||||
|
||||
Добавить в `SupplierLeadDeliveryGuardTest.php` (определить локальный 6-арговый `runRouteJob`-хелпер в этом файле, без `DuplicateDetector`):
|
||||
|
||||
```php
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
function runRouteJobB(int $id): void
|
||||
{
|
||||
(new RouteSupplierLeadJob($id))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
it('lock: re-running same delivery to same tenant does not double-charge', function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'lock.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100000.00']);
|
||||
$p = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
linkProjectToSupplier($p, $sp);
|
||||
|
||||
$vid = 610001;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => ['vid' => $vid, 'project' => 'B1_lock.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
||||
]);
|
||||
|
||||
runRouteJobB($lead->id);
|
||||
|
||||
// Сбросить processed_at, чтобы пройти мимо idempotency-guard и проверить ИМЕННО замок БД.
|
||||
$lead->update(['processed_at' => null]);
|
||||
runRouteJobB($lead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
|
||||
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
expect(DB::table('supplier_lead_deliveries')
|
||||
->where('supplier_lead_id', $lead->id)->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить — убедиться, что падает**
|
||||
|
||||
Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php --filter="re-running same delivery"`
|
||||
Expected: FAIL (без замка второй прогон создаёт вторую сделку + второй charge).
|
||||
|
||||
- [ ] **Step 3: Вставить замок в `createDealCopyForProject`**
|
||||
|
||||
В `app/app/Jobs/RouteSupplierLeadJob.php`, внутри `DB::transaction` в `createDealCopyForProject`, ПОСЛЕ `SET LOCAL app.current_tenant_id`, lock'а tenant и recheck'а лимита проекта, но ДО `Deal::create`:
|
||||
|
||||
```php
|
||||
// Spec B: замок «одна поставка одному клиенту = один раз».
|
||||
// insertOrIgnore вернёт 0, если строка (supplier_lead_id, tenant_id) уже есть —
|
||||
// эта поставка уже выдавалась этому клиенту (гонка / перезапуск / CSV). Без charge.
|
||||
$locked = DB::table('supplier_lead_deliveries')->insertOrIgnore([
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
if ($locked === 0) {
|
||||
Log::info('supplier_lead.delivery_already_locked', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
После `Deal::create([...])` добавить проставление `deal_id` в замок:
|
||||
|
||||
```php
|
||||
DB::table('supplier_lead_deliveries')
|
||||
->where('supplier_lead_id', $lead->id)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->update(['deal_id' => $deal->id]);
|
||||
```
|
||||
|
||||
NB: `insertOrIgnore` под RLS-политикой `tenant_isolation` — `app.current_tenant_id` уже выставлен в этой транзакции, WITH CHECK (= USING) пройдёт.
|
||||
|
||||
- [ ] **Step 4: Прогон**
|
||||
|
||||
Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`
|
||||
Expected: PASS (все кейсы файла, включая Task 3 «2 проекта → 1 сделка»).
|
||||
|
||||
- [ ] **Step 5: Коммит**
|
||||
|
||||
```bash
|
||||
git add app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php
|
||||
git commit -m "feat(billing-v2): per-(delivery,tenant) lock guard in RouteSupplierLeadJob (Spec B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Тесты политики дублей (model-agnostic) + reconcile прочих сюит
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`
|
||||
- Modify: затронутые тесты с `DuplicateDetector`/`runRouteJob` / `balance_leads`-долгом
|
||||
|
||||
- [ ] **Step 1: Тест «два разных vid, один телефон, один клиент → оба charge»**
|
||||
|
||||
Добавить в `SupplierLeadDeliveryGuardTest.php`:
|
||||
|
||||
```php
|
||||
it('same phone, two different deliveries to one tenant → both charged', function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twohit.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100000.00']);
|
||||
$p = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
linkProjectToSupplier($p, $sp);
|
||||
|
||||
foreach ([700001, 700002] as $vid) {
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => ['vid' => $vid, 'project' => 'B1_twohit.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
||||
]);
|
||||
runRouteJobB($lead->id);
|
||||
}
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
expect(Deal::query()->where('tenant_id', $tenant->id)->whereIn('source_crm_id', [700001, 700002])->count())->toBe(2);
|
||||
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(2);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Тест «5 клиентов под источник → ровно 3 списания у 3 клиентов»**
|
||||
|
||||
Добавить (сидируемый distributor для детерминизма, как в существующем cap-тесте):
|
||||
|
||||
```php
|
||||
use Random\Engine\Mt19937;
|
||||
use Random\Randomizer;
|
||||
|
||||
it('cap = 3 distinct tenants: 5 eligible tenants → exactly 3 charged', function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
app()->bind(LeadDistributor::class, fn () => new LeadDistributor(new Randomizer(new Mt19937(7))));
|
||||
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap3.ru',
|
||||
]);
|
||||
foreach (range(1, 5) as $i) {
|
||||
$t = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100000.00']);
|
||||
$p = Project::factory()->create([
|
||||
'tenant_id' => $t->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
linkProjectToSupplier($p, $sp);
|
||||
}
|
||||
|
||||
$vid = 710001;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => ['vid' => $vid, 'project' => 'B1_cap3.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
||||
]);
|
||||
|
||||
runRouteJobB($lead->id);
|
||||
|
||||
$lead->refresh();
|
||||
expect($lead->deals_created_count)->toBe(3);
|
||||
expect(LeadCharge::query()->where('tier_no', '>=', 0)->count())->toBe(3);
|
||||
// 3 разных клиента в замке.
|
||||
expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->count())->toBe(3);
|
||||
expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->distinct()->count('tenant_id'))->toBe(3);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Прогон файла**
|
||||
|
||||
Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`
|
||||
Expected: PASS все кейсы.
|
||||
|
||||
- [ ] **Step 4: Reconcile прочих сюит, ломающихся сигнатурой/моделью**
|
||||
|
||||
Найти все вызовы 7-арговой `handle()` и ссылки на DuplicateDetector:
|
||||
|
||||
```bash
|
||||
grep -rln "DuplicateDetector\|app(DuplicateDetector" app/tests
|
||||
```
|
||||
|
||||
В каждом файле (`RouteSupplierLeadJobBillingTest.php`, `Integration/SupplierLeadFlowTest.php`, `AutoPauseFlowTest.php`, `Pd/DealCreatePdLogTest.php`, и т.п.):
|
||||
|
||||
- убрать `app(DuplicateDetector::class),` из вызовов `handle()` (→ 6 аргументов);
|
||||
- убрать `use App\Services\DuplicateDetector;`;
|
||||
- удалить/переписать кейсы, проверявшие телефонный дедуп.
|
||||
Если эти тесты используют `balance_leads`-ассерты, несовместимые с always-rub (тест-долг Спека A) и попадают в зону правки — мигрировать на `balance_rub`/`LeadCharge` по образцу `RouteSupplierLeadJobBillingTest` rub-кейса. Тесты, которые мы не трогаем и которые были красны до Task 1, оставить как есть (вне scope Спека B; зафиксировать в отчёте).
|
||||
|
||||
- [ ] **Step 5: Прогон затронутых сюит**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php tests/Feature/Integration/SupplierLeadFlowTest.php tests/Feature/Supplier/AutoPauseFlowTest.php tests/Feature/Pd/DealCreatePdLogTest.php tests/Feature/Supplier/CsvReconcileJobTest.php
|
||||
```
|
||||
|
||||
Expected: GREEN (кроме явно задокументированного pre-existing `balance_leads`-долга, если решено его не трогать).
|
||||
|
||||
- [ ] **Step 6: Коммит**
|
||||
|
||||
```bash
|
||||
git add app/tests
|
||||
git commit -m "test(billing-v2): dup-policy tests (no phone dedup, per-client cap, lock) + signature reconcile"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Финальная регрессия + чистка
|
||||
|
||||
**Files:** нет новых правок (verify).
|
||||
|
||||
- [ ] **Step 1: Verify — нет `duplicate_detected` / `duplicate_of_id`-записи**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -rn "duplicate_detected" app/ db/ # ожидать 0
|
||||
grep -rn "duplicate_of_id" app/app # ожидать 0 (колонка спящая, код не пишет)
|
||||
```
|
||||
|
||||
Expected: 0 совпадений в коде (комментарии/CHANGELOG допустимы).
|
||||
|
||||
- [ ] **Step 2: DROP лишнего индекса (миграция + schema уже правлены в Task 2 Step 5)**
|
||||
|
||||
Создать `db/migrations/2026_05_23_201_drop_deals_duplicate_of_id_index.sql`:
|
||||
|
||||
```sql
|
||||
-- Индекс по deals(duplicate_of_id) больше не нужен — телефонный дедуп удалён (Spec B).
|
||||
DROP INDEX IF EXISTS deals_duplicate_of_id_idx;
|
||||
```
|
||||
|
||||
NB: имя индекса автоген — уточнить: `grep -n "duplicate_of_id" db/schema.sql` + на dev `\di deals*` / `SELECT indexname FROM pg_indexes WHERE tablename='deals' AND indexdef ILIKE '%duplicate_of_id%'`. Подставить фактическое имя.
|
||||
Создать парную `app/database/migrations/2026_05_23_201000_drop_deals_duplicate_of_id_index.php` (паттерн как Task 2 Step 4, idempotent через `DROP INDEX IF EXISTS`; `up()` грузит .sql, `down()` — пусто или воссоздаёт индекс). Убедиться, что `CREATE INDEX ... deals (duplicate_of_id)` уже убран из `db/schema.sql` (Task 2 Step 5).
|
||||
|
||||
- [ ] **Step 3: Линт/статика**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
composer pint
|
||||
composer stan
|
||||
```
|
||||
|
||||
Expected: Pint clean; Larastan 0 новых ошибок (для baseline в worktree скопировать `_ide_helper*.php` из основного чекаута — квирк A1-tooling).
|
||||
|
||||
- [ ] **Step 4: Полная backend-регрессия**
|
||||
|
||||
Run: `php artisan test --parallel`
|
||||
Expected: GREEN; кроме явно задокументированного pre-existing `balance_leads`-тест-долга Спека A, если он не входил в scope правок. Зафиксировать итог в отчёте.
|
||||
|
||||
- [ ] **Step 5: Финальный коммит миграции индекса**
|
||||
|
||||
```bash
|
||||
git add db/migrations/2026_05_23_201_drop_deals_duplicate_of_id_index.sql \
|
||||
app/database/migrations/2026_05_23_201000_drop_deals_duplicate_of_id_index.php
|
||||
git commit -m "chore(billing-v2): drop unused deals(duplicate_of_id) index (Spec B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (выполнено автором плана)
|
||||
|
||||
- **Покрытие спека:** §3.1 убрать фильтр → Tasks 4,5; §3.2 раздача по клиентам → Task 3; §3.3 замок БД → Tasks 2,6; §3.4 чистка следов → Tasks 2 (индекс), 8 (verify; `duplicate_detected` отсутствует в base — подтверждено); §3.5 не трогаем (vid-идемпотентность/CSV-дедуп) → не затрагиваются; §4 крайние случаи → тесты Tasks 6,7; §5 тесты → Tasks 5,6,7; §6 выкатка одна-фазная + CHANGELOG → Task 2.
|
||||
- **Плейсхолдеры:** код приведён для всех правок; имя индекса в Task 8 — единственное «уточнить прогоном» (автоген PG-имя, нельзя знать без БД — дана точная команда выяснения).
|
||||
- **Согласованность типов:** `runRouteJobB` (6 арг, без DuplicateDetector) — единый хелпер новых тестов; `insertOrIgnore` возвращает int (кол-во вставленных); `LedgerService::chargeForDelivery` сигнатура неизменна; таблица `supplier_lead_deliveries` колонки совпадают между DDL, моделью и тестами.
|
||||
- **Scope:** один связный план; pre-existing `balance_leads`-тест-долг Спека A явно вынесен как «вне scope, по решению — мигрировать только затронутое».
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,355 +0,0 @@
|
||||
# Phase 1: Always JSON 422 for webhook validation errors
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Webhook `/api/webhook/supplier/*` ВСЕГДА возвращает JSON 422 на ValidationException, никогда не редиректит на `/`. Закрывает ~76 потерянных лидов сутки в логах nginx.
|
||||
|
||||
**Architecture:** Один `withExceptions()` render-callback в `bootstrap/app.php`: для запросов матчащих `api/webhook/supplier/*` отдаём `response()->json(['message','errors'], 422)`. Для остальных — `return null` (дефолт). Существующие тесты остаются valid, добавляется один новый тест с `Accept: text/html` (имитация реального поставщика).
|
||||
|
||||
**Tech Stack:** Laravel 13 / Pest 4 / PHP 8.3
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md` §3 Phase 1
|
||||
**Ветка:** `feat/supplier-webhook-fixes` (создана)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Создать:**
|
||||
- `app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php` — единственный новый тест, фиксирующий формат ответа для не-JSON Accept
|
||||
|
||||
**Изменить:**
|
||||
- `app/bootstrap/app.php` — добавить `$exceptions->render(...)` для ValidationException
|
||||
|
||||
**Не трогать:**
|
||||
- `SupplierWebhookController.php` — логика валидации не меняется
|
||||
- Существующие `SupplierWebhookTest.php` — все `postJson()` тесты продолжают работать
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Failing test — webhook returns 422 JSON for non-JSON-Accept clients
|
||||
|
||||
**Files:**
|
||||
- Create: `app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_webhook_secret')
|
||||
->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_ip_allowlist')
|
||||
->update(['value' => '[]']);
|
||||
});
|
||||
|
||||
it('returns 422 JSON when supplier posts invalid payload WITHOUT Accept: application/json header', function () {
|
||||
// Воспроизводит реальное поведение crm.bp-gr.ru: POST без Accept-JSON.
|
||||
// До фикса (302→422) Laravel редиректил на / с Set-Cookie, поставщик
|
||||
// терял тело запроса. После фикса всегда JSON.
|
||||
$response = $this->call(
|
||||
'POST',
|
||||
'/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa',
|
||||
[], // params
|
||||
[], // cookies
|
||||
[], // files
|
||||
['HTTP_CONTENT_TYPE' => 'application/x-www-form-urlencoded'], // server: НЕТ Accept JSON
|
||||
http_build_query([
|
||||
'vid' => 1,
|
||||
'project' => 'invalid_no_b_prefix',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
])
|
||||
);
|
||||
|
||||
$response->assertStatus(422);
|
||||
expect($response->headers->get('Content-Type'))->toContain('application/json');
|
||||
$response->assertJsonStructure(['message', 'errors' => ['project']]);
|
||||
});
|
||||
|
||||
it('still works correctly for postJson clients (regression)', function () {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 1,
|
||||
'project' => 'invalid_no_b_prefix',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)->assertJsonValidationErrors('project');
|
||||
});
|
||||
|
||||
it('non-webhook routes still use default render (no JSON forced)', function () {
|
||||
// Регрессионный тест: дефолтный render остальных routes не сломан
|
||||
// (например /login — должен возвращать redirect, а не JSON).
|
||||
$response = $this->call(
|
||||
'POST',
|
||||
'/login',
|
||||
['email' => 'bad', 'password' => ''],
|
||||
[], [], [],
|
||||
);
|
||||
// Любой не-200 кроме 422-JSON допустим — главное чтобы наш fix не перехватил
|
||||
expect($response->headers->get('Content-Type'))->not->toContain('application/json');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
|
||||
```
|
||||
|
||||
Expected: тест #1 (non-JSON Accept) FAIL с status=302 (или Content-Type=text/html), потому что ValidationException рендерится через redirect.
|
||||
|
||||
- [ ] **Step 3: Commit failing test**
|
||||
|
||||
```bash
|
||||
git add app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
|
||||
git commit -m "test(supplier-webhook): assert JSON 422 for non-JSON Accept clients (failing)
|
||||
|
||||
Reproduces 302-redirect bug observed on prod 2026-05-25 — when supplier
|
||||
crm.bp-gr.ru POSTs without Accept: application/json, Laravel renders
|
||||
ValidationException as redirect to /, losing body. Test calls webhook
|
||||
without Accept header and asserts JSON 422 response. Will fail until
|
||||
bootstrap/app.php has render(ValidationException) for api/webhook/supplier/*."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Implement bootstrap render — force JSON 422 for webhook routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/bootstrap/app.php` (lines 35-48 — withExceptions block)
|
||||
|
||||
- [ ] **Step 1: Add ValidationException render in bootstrap/app.php**
|
||||
|
||||
В `withExceptions` callback (после существующего `QueryException` render) добавить новый render для `ValidationException`:
|
||||
|
||||
```php
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->render(function (QueryException $e, Request $request) {
|
||||
// ... existing code, не менять ...
|
||||
});
|
||||
|
||||
// Supplier webhook always returns JSON, even when client omits Accept header.
|
||||
// Without this render, Laravel's default ValidationException handler returns
|
||||
// 302 redirect to /, which strips POST body — losing supplier leads.
|
||||
// Confirmed 2026-05-25: 76 of 234 webhook hits today got 302 instead of 422.
|
||||
$exceptions->render(function (\Illuminate\Validation\ValidationException $e, Request $request) {
|
||||
if ($request->is('api/webhook/supplier/*')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $e->errors(),
|
||||
], 422);
|
||||
}
|
||||
return null; // default render for other routes
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
NB: `use Illuminate\Validation\ValidationException;` — не нужен, используем FQN inline чтобы не трогать existing imports section.
|
||||
|
||||
- [ ] **Step 2: Run new test to verify it passes**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
|
||||
```
|
||||
|
||||
Expected: все 3 теста PASS.
|
||||
|
||||
- [ ] **Step 3: Run full webhook test suite (regression)**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
|
||||
```
|
||||
|
||||
Expected: все тесты (≥14 в обоих файлах) PASS. Особенно проверить что `'rejects invalid project format (no B[123]_ prefix) with 422'` (line 95 в SupplierWebhookTest.php) продолжает PASS — он использует `postJson()`, поэтому новый render для него не сработает (default handler уже даёт 422 для JSON Accept), но мы не должны его сломать.
|
||||
|
||||
- [ ] **Step 4: Commit implementation**
|
||||
|
||||
```bash
|
||||
git add app/bootstrap/app.php
|
||||
git commit -m "fix(supplier-webhook): always return JSON 422 on ValidationException
|
||||
|
||||
Adds withExceptions render callback for ValidationException that forces
|
||||
JSON 422 response when request matches api/webhook/supplier/* — regardless
|
||||
of Accept header. Default Laravel behavior is 302 redirect for non-JSON
|
||||
clients, which strips POST body.
|
||||
|
||||
Observed on prod 2026-05-25: 76 of 234 supplier webhook hits got 302 (Location: /),
|
||||
mostly for non-B-prefix projects (client.carmoney.ru, cabinet.caranga.ru,
|
||||
cashmotor.ru). Supplier doesn't follow 302 redirects on POST, so the
|
||||
lead body is lost. This fix ensures supplier always sees a meaningful
|
||||
422 with errors[] instead of a redirect.
|
||||
|
||||
Other routes unaffected (render returns null for non-webhook URLs)."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Reproduce on staging-clone or local — manual smoke
|
||||
|
||||
**Files:**
|
||||
- Test: manual curl (no file)
|
||||
|
||||
- [ ] **Step 1: Run dev server locally (if available) or skip to Task 4**
|
||||
|
||||
Если на машине поднят `php artisan serve --port=8000`:
|
||||
```bash
|
||||
cd app && php artisan serve --port=8000 &
|
||||
sleep 2
|
||||
```
|
||||
|
||||
- [ ] **Step 2: POST without Accept header — assert 422 JSON**
|
||||
|
||||
```bash
|
||||
curl -sk -X POST \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d 'vid=1&project=invalid_no_b_prefix&phone=79991234567&time='$(date +%s) \
|
||||
http://localhost:8000/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa \
|
||||
-w "\nSTATUS: %{http_code}\nCT: %{content_type}\n"
|
||||
```
|
||||
|
||||
Expected: `STATUS: 422`, `CT: application/json`, тело содержит `"errors":{"project":...}`.
|
||||
|
||||
- [ ] **Step 3: POST with Accept: application/json — same result (regression)**
|
||||
|
||||
```bash
|
||||
curl -sk -X POST \
|
||||
-H "Accept: application/json" -H "Content-Type: application/json" \
|
||||
-d '{"vid":1,"project":"invalid_no_b_prefix","phone":"79991234567","time":'$(date +%s)'}' \
|
||||
http://localhost:8000/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa \
|
||||
-w "\nSTATUS: %{http_code}\n"
|
||||
```
|
||||
|
||||
Expected: `STATUS: 422`, JSON body.
|
||||
|
||||
- [ ] **Step 4: Stop server (если запускал)**
|
||||
|
||||
```bash
|
||||
pkill -f 'artisan serve' || true
|
||||
```
|
||||
|
||||
Если dev-сервер не поднимается на этой машине — пропустить Task 3, прод-smoke в Task 5 покроет.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Regression — quick mode
|
||||
|
||||
**Files:**
|
||||
- None
|
||||
|
||||
- [ ] **Step 1: Run /regression quick**
|
||||
|
||||
```
|
||||
/regression quick
|
||||
```
|
||||
|
||||
Expected: GREEN — lint, format, type-check ОК. Если pre-commit hook падает (memory `feedback_environment.md` #111 — gitleaks висит на heavy diff), использовать `LEFTHOOK=0` при коммите.
|
||||
|
||||
- [ ] **Step 2: If quick GREEN, proceed to /regression full**
|
||||
|
||||
```
|
||||
/regression full
|
||||
```
|
||||
|
||||
Expected: Pest 742+ pass / 0 fail, Vitest 736+ pass, Vite build OK, lychee 0 broken, gitleaks 0. Допустимы pre-existing skipped.
|
||||
|
||||
Если найдены регрессии — НЕ переходить к деплою. Зафиксировать в отдельном fixup-commit либо вернуться к Task 2.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Deploy to liderra.ru (prod)
|
||||
|
||||
**Files:**
|
||||
- None — деплой через ssh + redeploy.sh
|
||||
|
||||
- [ ] **Step 1: Pre-deploy validation via prod-deploy-validator agent**
|
||||
|
||||
Через Task tool:
|
||||
```
|
||||
subagent_type: prod-deploy-validator
|
||||
prompt: проверь готовность боевого liderra.ru к выкату ветки feat/supplier-webhook-fixes на коммит после Phase 1 (bootstrap/app.php изменён). Что меняется: webhook /api/webhook/supplier/* теперь всегда отвечает JSON 422 на validation errors. Миграций БД нет. Очередь queue:restart нужен? проверь 8 pre-flight.
|
||||
```
|
||||
|
||||
Expected: вердикт GO. Если NO-GO — устранить причину (квирки 104-108) и повторить.
|
||||
|
||||
- [ ] **Step 2: Merge feature branch fixup to main**
|
||||
|
||||
После одобрения Phase 1 changes:
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация"
|
||||
git checkout main
|
||||
git merge --ff-only feat/supplier-webhook-fixes
|
||||
git push origin main
|
||||
```
|
||||
|
||||
NB: ОДНОВРЕМЕННО другие phases ещё не закоммичены, поэтому FF-merge содержит только Phase 1.
|
||||
|
||||
- [ ] **Step 3: Run redeploy.sh on prod**
|
||||
|
||||
```bash
|
||||
ssh liderra "cd /var/www/liderra/app && sudo -u www-data ./redeploy.sh 2>&1 | tail -50"
|
||||
```
|
||||
|
||||
Expected: успешный pull + composer install + `optimize:clear` + `optimize` + queue:restart. Errors → revert (git revert + redeploy).
|
||||
|
||||
- [ ] **Step 4: Prod smoke — webhook returns 422 not 302**
|
||||
|
||||
```bash
|
||||
ssh liderra 'curl -sk -X POST \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "vid=1&project=invalid&phone=79991234567&time="$(date +%s) \
|
||||
https://liderra.ru/api/webhook/supplier/8c1c07ddb0768763661b357198e0625832f74ad0915d91b1 \
|
||||
-w "\nSTATUS: %{http_code}\nCT: %{content_type}\n"'
|
||||
```
|
||||
|
||||
Expected: `STATUS: 422`, `CT: application/json`. **Если 302 — деплой не применился, откатывать.**
|
||||
|
||||
- [ ] **Step 5: Wait 30 min, check nginx access.log**
|
||||
|
||||
```bash
|
||||
ssh liderra "sudo grep '/api/webhook/supplier' /var/log/nginx/access.log | tail -50 | awk '{print \$9}' | sort | uniq -c"
|
||||
```
|
||||
|
||||
Expected: только 202, 422, 429, 404. **0 × 302, 0 × 301** для запросов на webhook URL.
|
||||
|
||||
- [ ] **Step 6: Update ПИЛОТ.md + memory**
|
||||
|
||||
Через прямой Edit, отметка «Phase 1 deployed 25.05.2026 HH:MM МСК, webhook always JSON». Memory update — `project_billing_v2.md` или новый `project_supplier_webhook_fixes.md`.
|
||||
|
||||
```bash
|
||||
# Update ПИЛОТ.md as needed manually
|
||||
git add ПИЛОТ.md
|
||||
git commit -m "docs(пилот): Phase 1 supplier webhook JSON-422 deployed"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Done criteria для Phase 1
|
||||
|
||||
- [ ] Все тесты в `SupplierWebhookTest.php` + `SupplierWebhookValidationFormatTest.php` PASS
|
||||
- [ ] /regression full GREEN
|
||||
- [ ] Прод-smoke: curl без Accept → 422 JSON
|
||||
- [ ] За 30 мин после деплоя в nginx access.log — 0 × 302 на webhook URL
|
||||
- [ ] Phase 2 plan starts only after Phase 1 deployed AND observed clean for ≥30 min
|
||||
|
||||
---
|
||||
|
||||
## Откат (если что-то пошло не так)
|
||||
|
||||
```bash
|
||||
ssh liderra "cd /var/www/liderra/app && git revert --no-edit HEAD && sudo -u www-data ./redeploy.sh 2>&1 | tail -20"
|
||||
```
|
||||
|
||||
Изменение касается только обработки исключений — откат без миграций, мгновенный.
|
||||
@@ -1,475 +0,0 @@
|
||||
# Phase 2: Idempotent dedup webhook ↔ CSV-recovered
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Webhook, поступивший после CSV-recovered deal по `(tenant_id, phone, project_id)` в окне 24h, **обновляет** существующий deal (`source_crm_id`, `received_at`), не создаёт второй. Без двойного списания биллингом. Закрывает 37 дублей сутки.
|
||||
|
||||
**Architecture:** В `RouteSupplierLeadJob::createDealCopyForProject` под уже существующей `DB::transaction + lockForUpdate(Tenant)+lockForUpdate(Project)` добавляется проверка «есть ли csv-recovered deal по `(tenant_id, phone, project_id, received_at ≥ now()-24h, source_crm_id IS NULL)`». Если есть — `UPDATE existing.source_crm_id = lead.vid` + `INSERT supplier_lead_deliveries` (привязка webhook к existing deal), **БЕЗ** `chargeForDelivery`. Возврат специального статуса `MERGED` (не считается в `$createdCount`, не failure).
|
||||
|
||||
**Tech Stack:** Laravel 13 / Pest 4 / PHP 8.3 / PostgreSQL 16 / bcmath / RLS
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md` §3 Phase 2
|
||||
**Предусловие:** Phase 1 deployed и наблюдаем clean ≥30 мин.
|
||||
**Ветка:** `feat/supplier-webhook-fixes` (продолжение)
|
||||
|
||||
---
|
||||
|
||||
## Открытый вопрос (OQ-1 из спеки) — резолвится в Task 1
|
||||
|
||||
`LedgerService::chargeForDelivery` (app/app/Services/Billing/LedgerService.php:47-117) — **НЕ идемпотентен**: каждый вызов делает INSERT LeadCharge, BalanceTransaction, supplier_lead_costs + decrement balance_rub. Поэтому критично НЕ вызывать его второй раз для merged deal.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Создать:**
|
||||
- `app/tests/Feature/Supplier/CsvWebhookRaceTest.php` — TDD-тесты для merge сценария
|
||||
|
||||
**Изменить:**
|
||||
- `app/app/Jobs/RouteSupplierLeadJob.php` — добавить блок поиска csv-recovered deal в `createDealCopyForProject`
|
||||
|
||||
**Не трогать:**
|
||||
- `LedgerService.php` — не меняем, идемпотентность достигается через ранний return ДО его вызова
|
||||
- `supplier_lead_deliveries` schema — не меняем (текущая `(supplier_lead_id, tenant_id)` UNIQUE остаётся; добавляем дополнительный row для merge case)
|
||||
- `CsvReconcileJob.php` — не меняем (он создаёт SupplierLead с vid=NULL, как и было)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Verify LedgerService is NOT idempotent (read-only confirmation)
|
||||
|
||||
**Files:**
|
||||
- Read: `app/app/Services/Billing/LedgerService.php`
|
||||
|
||||
- [ ] **Step 1: Confirm there is NO check for existing lead_charges with same deal_id**
|
||||
|
||||
Открыть [app/app/Services/Billing/LedgerService.php:47-117](../../../app/app/Services/Billing/LedgerService.php#L47-L117). Подтвердить:
|
||||
- Нет `LeadCharge::where('deal_id', $deal->id)->exists()` guard.
|
||||
- Нет SELECT перед INSERT.
|
||||
- Метод просто делает INSERT, increment, INSERT, INSERT.
|
||||
|
||||
Если идемпотентность ЕСТЬ — пересмотреть план Phase 2 (может быть проще, без MERGED статуса). Если НЕТ (ожидаемо) — продолжаем по плану.
|
||||
|
||||
- [ ] **Step 2: Document in commit message**
|
||||
|
||||
Зафиксировать наблюдение в первом коммите Task 2. Никакой правки в LedgerService не делаем — guard добавляется в caller (RouteSupplierLeadJob).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Failing test — webhook after CSV-recovered merges, doesn't duplicate or double-charge
|
||||
|
||||
**Files:**
|
||||
- Create: `app/tests/Feature/Supplier/CsvWebhookRaceTest.php`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\LeadCharge;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* Phase 2 — webhook ↔ CSV-recovered idempotency.
|
||||
*
|
||||
* Сценарий (наблюдался на prod 2026-05-25):
|
||||
* 1. Поставщик шлёт webhook → 302 (теряется тело) — Phase 1 уже починила.
|
||||
* 2. CsvReconcileJob через 30 мин видит лид в CSV, не находит supplier_lead
|
||||
* по (phone, project) → создаёт recovered SupplierLead (vid=NULL,
|
||||
* source='csv_recovery') → RouteSupplierLeadJob → Deal с source_crm_id=NULL.
|
||||
* 3. Поставщик ретраит webhook (ещё 15 мин) → новый SupplierLead с vid=<int>
|
||||
* → RouteSupplierLeadJob → создаёт второй Deal с тем же phone+project
|
||||
* → биллинг списывает второй раз.
|
||||
*
|
||||
* Phase 2 fix: шаг 3 находит существующий CSV-recovered deal, обновляет
|
||||
* source_crm_id, привязывает webhook supplier_lead к существующему deal через
|
||||
* supplier_lead_deliveries, НЕ создаёт второй Deal, НЕ списывает повторно.
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '1000.00',
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
$this->project = Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'krk-finance.ru',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 100,
|
||||
'delivered_today' => 0,
|
||||
]);
|
||||
// ... настроить supplier_projects + project_supplier_links для платформы B1
|
||||
// identifier krk-finance.ru — детали зависят от фабрик
|
||||
});
|
||||
|
||||
it('webhook after CSV-recovered merges into existing deal (no duplicate, no double-charge)', function () {
|
||||
// Step 1: simulate CSV-recovered SupplierLead (vid=null)
|
||||
$csvLead = SupplierLead::create([
|
||||
'platform' => 'B1',
|
||||
'phone' => '79991234567',
|
||||
'vid' => null,
|
||||
'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => time()],
|
||||
'received_at' => now()->subHour(),
|
||||
'recovered_from_csv_at' => now()->subHour(),
|
||||
'source' => 'csv_recovery',
|
||||
]);
|
||||
(new RouteSupplierLeadJob($csvLead->id))->handle(
|
||||
app(\App\Services\LeadRouter::class),
|
||||
app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
|
||||
app(\App\Services\NotificationService::class),
|
||||
app(\App\Services\Billing\LedgerService::class),
|
||||
app(\App\Services\LeadDistributor::class),
|
||||
app(\App\Services\RegionTagResolver::class),
|
||||
);
|
||||
|
||||
$csvDeal = Deal::where('phone', '79991234567')->first();
|
||||
expect($csvDeal)->not->toBeNull();
|
||||
expect($csvDeal->source_crm_id)->toBeNull();
|
||||
$chargesAfterCsv = LeadCharge::where('deal_id', $csvDeal->id)->count();
|
||||
expect($chargesAfterCsv)->toBe(1); // одна charge от CSV-recovered
|
||||
|
||||
$balanceAfterCsv = (string) $this->tenant->fresh()->balance_rub;
|
||||
|
||||
// Step 2: simulate webhook arriving 15 min later with real vid
|
||||
$webhookLead = SupplierLead::create([
|
||||
'platform' => 'B1',
|
||||
'phone' => '79991234567',
|
||||
'vid' => 1672819986,
|
||||
'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => time()],
|
||||
'received_at' => now()->subMinutes(15),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
(new RouteSupplierLeadJob($webhookLead->id))->handle(
|
||||
app(\App\Services\LeadRouter::class),
|
||||
app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
|
||||
app(\App\Services\NotificationService::class),
|
||||
app(\App\Services\Billing\LedgerService::class),
|
||||
app(\App\Services\LeadDistributor::class),
|
||||
app(\App\Services\RegionTagResolver::class),
|
||||
);
|
||||
|
||||
// Assertion 1: still ONE deal, but source_crm_id теперь заполнен
|
||||
$deals = Deal::where('phone', '79991234567')->get();
|
||||
expect($deals)->toHaveCount(1);
|
||||
expect($deals->first()->source_crm_id)->toBe(1672819986);
|
||||
|
||||
// Assertion 2: НЕТ второго LeadCharge (idempotency биллинга)
|
||||
$chargesAfterWebhook = LeadCharge::where('deal_id', $csvDeal->id)->count();
|
||||
expect($chargesAfterWebhook)->toBe(1); // всё ещё ОДИН charge
|
||||
|
||||
// Assertion 3: balance НЕ списан второй раз
|
||||
$balanceAfterWebhook = (string) $this->tenant->fresh()->balance_rub;
|
||||
expect($balanceAfterWebhook)->toBe($balanceAfterCsv);
|
||||
|
||||
// Assertion 4: supplier_lead_deliveries содержит ОБА supplier_lead_id,
|
||||
// привязанные к ОДНОМУ deal.id
|
||||
$deliveries = DB::table('supplier_lead_deliveries')
|
||||
->where('deal_id', $csvDeal->id)
|
||||
->get();
|
||||
expect($deliveries)->toHaveCount(2);
|
||||
expect($deliveries->pluck('supplier_lead_id')->all())
|
||||
->toContain($csvLead->id, $webhookLead->id);
|
||||
});
|
||||
|
||||
it('two webhooks with DIFFERENT vids both create deals (Spec B — за повторы поставщика берём)', function () {
|
||||
// Регрессионный тест: если поставщик намеренно шлёт два webhook'а с РАЗНЫМИ
|
||||
// vid'ами на тот же phone+project — это два разных лида, оба должны быть
|
||||
// приняты. Спек B Phase 1 (commit ccfecd5e) специально снял DD для этого
|
||||
// кейса. Наш Phase 2 fix НЕ должен этому препятствовать.
|
||||
$lead1 = SupplierLead::create([
|
||||
'platform' => 'B1', 'phone' => '79991234567', 'vid' => 100,
|
||||
'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => time()],
|
||||
'received_at' => now()->subHour(), 'source' => 'webhook',
|
||||
]);
|
||||
(new RouteSupplierLeadJob($lead1->id))->handle(/* ... */);
|
||||
|
||||
$lead2 = SupplierLead::create([
|
||||
'platform' => 'B1', 'phone' => '79991234567', 'vid' => 200,
|
||||
'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => time()],
|
||||
'received_at' => now()->subMinutes(30), 'source' => 'webhook',
|
||||
]);
|
||||
(new RouteSupplierLeadJob($lead2->id))->handle(/* ... */);
|
||||
|
||||
// Assertion: ОБА webhook'а имеют source_crm_id (не NULL), поэтому merge
|
||||
// не происходит — это два разных лида у поставщика, два разных deal.
|
||||
$deals = Deal::where('phone', '79991234567')->get();
|
||||
expect($deals)->toHaveCount(2);
|
||||
expect($deals->pluck('source_crm_id')->all())->toContain(100, 200);
|
||||
expect(LeadCharge::whereIn('deal_id', $deals->pluck('id'))->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('csv-recovered deal older than 24h is NOT merged with new webhook', function () {
|
||||
// Окно merge — 24h. Если CSV-recovered deal старше — не считается duplicate.
|
||||
$csvLead = SupplierLead::create([
|
||||
'platform' => 'B1', 'phone' => '79991234567', 'vid' => null,
|
||||
'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => now()->subDays(2)->getTimestamp()],
|
||||
'received_at' => now()->subDays(2),
|
||||
'recovered_from_csv_at' => now()->subDays(2),
|
||||
'source' => 'csv_recovery',
|
||||
]);
|
||||
(new RouteSupplierLeadJob($csvLead->id))->handle(/* ... */);
|
||||
|
||||
$webhookLead = SupplierLead::create([
|
||||
'platform' => 'B1', 'phone' => '79991234567', 'vid' => 999,
|
||||
'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => time()],
|
||||
'received_at' => now(), 'source' => 'webhook',
|
||||
]);
|
||||
(new RouteSupplierLeadJob($webhookLead->id))->handle(/* ... */);
|
||||
|
||||
// Assertion: TWO deals (старый CSV-recovered + новый webhook), не merge
|
||||
$deals = Deal::where('phone', '79991234567')->get();
|
||||
expect($deals)->toHaveCount(2);
|
||||
});
|
||||
```
|
||||
|
||||
NB: код тестов написан как **набросок**. При имплементации:
|
||||
- Заменить `(new RouteSupplierLeadJob(...))->handle(/* ... */)` на правильную диспатч-схему (Bus::dispatchSync или вручную с DI). Посмотреть в [app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php](../../../app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php) для примера.
|
||||
- Настроить supplier_projects + project_supplier_links фабрики правильно. Посмотреть в существующих тестах.
|
||||
|
||||
- [ ] **Step 2: Run tests, expect FAIL**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/CsvWebhookRaceTest.php
|
||||
```
|
||||
|
||||
Expected: тест #1 FAIL (deals.count == 2 а не 1; charges.count == 2 а не 1). Это подтверждает баг.
|
||||
|
||||
- [ ] **Step 3: Commit failing tests**
|
||||
|
||||
```bash
|
||||
git add app/tests/Feature/Supplier/CsvWebhookRaceTest.php
|
||||
git commit -m "test(supplier): assert webhook-after-csv-recovered merges into existing deal (failing)
|
||||
|
||||
Reproduces 37 duplicate deals observed on prod 2026-05-25 for tenant client1.
|
||||
After Spec B Phase 1 (commit ccfecd5e) removed DuplicateDetector, the race
|
||||
between CsvReconcileJob (creates SupplierLead vid=null) and later webhook
|
||||
retry (vid=int) results in two separate Deals because supplier_lead_deliveries
|
||||
locks on supplier_lead_id (which differs between csv-recovery and webhook),
|
||||
not on (phone, project_id).
|
||||
|
||||
Failing now — implementation comes in next commit."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Implement merge logic in RouteSupplierLeadJob::createDealCopyForProject
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php:207-330`
|
||||
|
||||
- [ ] **Step 1: Add early merge check ДО supplier_lead_deliveries insertOrIgnore**
|
||||
|
||||
В `createDealCopyForProject`, **после** `$lockedProject = ... lockForUpdate(); ... if (delivered_today >= limit) return false;`, **до** `$locked = DB::table('supplier_lead_deliveries')->insertOrIgnore(...)`:
|
||||
|
||||
```php
|
||||
// Phase 2 fix: merge с CSV-recovered deal если webhook догоняет.
|
||||
// Идемпотентность race condition между CsvReconcileJob (vid=NULL, recovered
|
||||
// from CSV) и webhook (vid=int, реальный supplier-id). До этой проверки они
|
||||
// создавали 2 deal'a (DD снят Spec B Phase 1). Merge выполняется только если:
|
||||
// - webhook ЕСТЬ настоящий vid (lead.vid !== null) — без vid merge'ить нечего;
|
||||
// - csv-recovered deal существует за последние 24h, тот же phone+project+tenant;
|
||||
// - csv-recovered deal БЕЗ source_crm_id (т.е. он именно CSV-recovered, не другой webhook).
|
||||
// При merge: UPDATE existing.source_crm_id, INSERT supplier_lead_deliveries,
|
||||
// БЕЗ chargeForDelivery (LeadCharge уже есть с момента CSV recovery).
|
||||
$existingMergeable = null;
|
||||
if ($lead->vid !== null) {
|
||||
$existingMergeable = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('phone', (string) $lead->phone)
|
||||
->where('project_id', $project->id)
|
||||
->whereNull('source_crm_id')
|
||||
->where('received_at', '>=', now()->subDay())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
}
|
||||
if ($existingMergeable !== null) {
|
||||
// Заполняем supplier_lead.id у обоих SupplierLead → одному Deal
|
||||
DB::table('supplier_lead_deliveries')->insert([
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'deal_id' => $existingMergeable->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$existingMergeable->source_crm_id = $lead->vid;
|
||||
if ($lead->received_at !== null && $lead->received_at->gt($existingMergeable->received_at)) {
|
||||
$existingMergeable->received_at = $lead->received_at;
|
||||
}
|
||||
$existingMergeable->save();
|
||||
|
||||
Log::info('supplier_lead.merged_into_csv_recovered', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'merged_into_deal_id' => $existingMergeable->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
|
||||
return true; // считаем «доставленным», но без второго списания
|
||||
}
|
||||
|
||||
// Spec B: per-(supplier_lead, tenant) lock — existing code ниже без изменений
|
||||
$locked = DB::table('supplier_lead_deliveries')->insertOrIgnore([
|
||||
// ... existing ...
|
||||
]);
|
||||
```
|
||||
|
||||
NB:
|
||||
- `lockForUpdate()` на existingMergeable защищает от двойного merge при параллельных queue workers.
|
||||
- Условие `whereNull('source_crm_id')` — критично: оно отличает CSV-recovered (vid=NULL → source_crm_id=NULL) от настоящих webhook deals (source_crm_id=vid). Без этого условия мы бы мерджили на любой повтор поставщика, что **сломало бы Spec B**.
|
||||
- Insert в `supplier_lead_deliveries` — простой `->insert()`, не `->insertOrIgnore()`. Потому что `(supplier_lead_id, tenant_id)` уникален, и для webhook-after-csv это новая комбинация (другой supplier_lead_id чем у csv-recovered).
|
||||
|
||||
- [ ] **Step 2: Run tests, expect PASS**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/CsvWebhookRaceTest.php
|
||||
```
|
||||
|
||||
Expected: все 3 теста PASS.
|
||||
|
||||
- [ ] **Step 3: Run full supplier test suite (regression)**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/ tests/Feature/Jobs/RouteSupplierLeadJobTest.php
|
||||
```
|
||||
|
||||
Expected: все existing тесты PASS. Особенно:
|
||||
- `SupplierLeadDeliveryGuardTest` (текущий lock-механизм)
|
||||
- `RouteSupplierLeadJobBillingTest` (биллинг)
|
||||
- `RouteSupplierLeadJobTest`
|
||||
- `CsvReconcileJobTest`
|
||||
|
||||
Если что-то сломалось — это знак что existingMergeable условие слишком широкое. Сузить и повторить.
|
||||
|
||||
- [ ] **Step 4: Commit implementation**
|
||||
|
||||
```bash
|
||||
git add app/app/Jobs/RouteSupplierLeadJob.php
|
||||
git commit -m "fix(supplier): merge webhook into csv-recovered deal, no double-charge
|
||||
|
||||
Adds early merge check in RouteSupplierLeadJob::createDealCopyForProject:
|
||||
when lead.vid IS NOT NULL and an existing deal with NULL source_crm_id
|
||||
exists for (tenant, phone, project_id) within last 24h, UPDATE that
|
||||
deal's source_crm_id instead of creating a second Deal. INSERT into
|
||||
supplier_lead_deliveries links the new supplier_lead.id to the existing
|
||||
deal.id. LedgerService::chargeForDelivery is NOT called — the original
|
||||
charge happened when the csv-recovery created the deal.
|
||||
|
||||
Closes 37 duplicate deals observed on prod for tenant client1 25.05.2026.
|
||||
Spec B Phase 1 (commit ccfecd5e) removed DuplicateDetector — this fix
|
||||
restores idempotency for the specific webhook-after-csv-recovered case
|
||||
WITHOUT re-blocking intentional supplier repeats with different vids.
|
||||
|
||||
Guard: only merges where source_crm_id IS NULL (the CSV-recovered marker).
|
||||
Two webhooks with different vids on same phone+project still create two
|
||||
deals — by-design per Spec B."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Regression and prod data probe
|
||||
|
||||
**Files:**
|
||||
- None
|
||||
|
||||
- [ ] **Step 1: /regression full**
|
||||
|
||||
```
|
||||
/regression full
|
||||
```
|
||||
|
||||
Expected: GREEN. Особенно фокус на Pest --parallel (race conditions).
|
||||
|
||||
- [ ] **Step 2: Prod data probe — current state of duplicates**
|
||||
|
||||
ДО деплоя:
|
||||
```bash
|
||||
ssh liderra "sudo -u postgres psql -d liderra -P pager=off -c \"SELECT phone, project_id, COUNT(*) AS cnt FROM deals WHERE tenant_id=2 AND created_at::date = CURRENT_DATE GROUP BY phone, project_id HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10\""
|
||||
```
|
||||
|
||||
Зафиксировать список (это будут текущие 37 пар). После деплоя — повторить ту же команду через 2 часа: новые пары не должны появляться.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Deploy to liderra.ru
|
||||
|
||||
**Files:**
|
||||
- None
|
||||
|
||||
- [ ] **Step 1: prod-deploy-validator agent**
|
||||
|
||||
```
|
||||
subagent_type: prod-deploy-validator
|
||||
prompt: проверь готовность боевого liderra.ru к Phase 2 деплою. Меняется только RouteSupplierLeadJob.php (добавлен merge-check для CSV-recovered deals). Миграций БД нет. Очередь — queue:restart обязателен, потому что job изменился. Phase 1 уже на проде ≥30 мин.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Merge to main + push**
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --ff-only feat/supplier-webhook-fixes
|
||||
git push origin main
|
||||
```
|
||||
|
||||
- [ ] **Step 3: redeploy on prod**
|
||||
|
||||
```bash
|
||||
ssh liderra "cd /var/www/liderra/app && sudo -u www-data ./redeploy.sh 2>&1 | tail -50"
|
||||
```
|
||||
|
||||
Expected: успешно. Особенно проверить что `php artisan queue:restart` отработал (см. в выводе redeploy.sh).
|
||||
|
||||
- [ ] **Step 4: Prod smoke — нет новых дублей за 2 часа**
|
||||
|
||||
Подождать 2 часа, потом:
|
||||
```bash
|
||||
ssh liderra "sudo -u postgres psql -d liderra -P pager=off -c \"SELECT phone, project_id, COUNT(*) FROM deals WHERE tenant_id=2 AND created_at >= NOW() - interval '2 hours' GROUP BY phone, project_id HAVING COUNT(*) > 1\""
|
||||
```
|
||||
|
||||
Expected: **0 rows** (нет новых дублей за 2 часа после деплоя).
|
||||
|
||||
- [ ] **Step 5: Check merge logs**
|
||||
|
||||
```bash
|
||||
ssh liderra "sudo grep 'merged_into_csv_recovered' /var/www/liderra/app/storage/logs/laravel.log | tail -20"
|
||||
```
|
||||
|
||||
Expected: есть записи (показывает что merge сработал). Каждая запись — закрытый дубль.
|
||||
|
||||
- [ ] **Step 6: Update ПИЛОТ.md + memory**
|
||||
|
||||
```bash
|
||||
# Edit ПИЛОТ.md mentioning Phase 2 deployed + merge stats
|
||||
git add ПИЛОТ.md
|
||||
git commit -m "docs(пилот): Phase 2 supplier dedup deployed, $N merges in 2h window"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Done criteria для Phase 2
|
||||
|
||||
- [ ] Все тесты в `CsvWebhookRaceTest.php` PASS
|
||||
- [ ] Все существующие `tests/Feature/Supplier/` PASS (regression)
|
||||
- [ ] /regression full GREEN
|
||||
- [ ] За 2 часа после деплоя — 0 новых пар дубликатов на проде
|
||||
- [ ] Существуют `merged_into_csv_recovered` записи в логе (показывает что merge работает)
|
||||
- [ ] Phase 3 plan starts only after Phase 2 observed clean ≥2h
|
||||
|
||||
---
|
||||
|
||||
## Откат
|
||||
|
||||
```bash
|
||||
ssh liderra "cd /var/www/liderra/app && git revert --no-edit HEAD && sudo -u www-data ./redeploy.sh 2>&1 | tail -20"
|
||||
```
|
||||
|
||||
Миграций нет → откат мгновенный. Дубли начнут возникать снова, но эти 2-3 часа потерь покрываются CsvReconcileJob.
|
||||
@@ -1,899 +0,0 @@
|
||||
# Phase 3: DIRECT platform for non-B prefix projects
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Webhook на проекты без `B[123]_` префикса (`client.carmoney.ru`, `cashmotor.ru`, числовые) принимается, проходит routing, создаёт Deal под новой платформой `DIRECT`. Закрывает оставшиеся ~67 потерь сутки.
|
||||
|
||||
**Architecture:** Расширить `platform` enum в `supplier_projects` и `project_supplier_links` до `(B1, B2, B3, DIRECT)` через миграцию. Снять regex в webhook controller. `parsePlatform`/`parseProjectField`/`extractPlatform` возвращают `'DIRECT'` для не-B. `SupplierProjectResolver` принимает DIRECT. `LeadRouter` для DIRECT использует **прямой матч signal_identifier** (потому что DIRECT-supplier_projects ещё не привязаны к Лидерра-проектам через `project_supplier_links`). `LedgerService.resolveSupplierId` — fallback для DIRECT.
|
||||
|
||||
**Tech Stack:** Laravel 13 / PostgreSQL 16 / Pest 4 / PHP 8.3
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md` §3 Phase 3
|
||||
**Предусловие:** Phase 2 deployed и наблюдаем clean ≥2 часов.
|
||||
**Ветка:** `feat/supplier-webhook-fixes` (продолжение)
|
||||
**Риск:** ВЫСОКИЙ — миграция БД + 5 файлов кода + бизнес-семантика биллинга
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- **OQ-2.** `chk_supplier_projects_b1_not_for_sms` constraint — мешает ли DIRECT? **Ответ:** не мешает — это `CHECK (NOT (platform='B1' AND signal_type='sms'))`. DIRECT+SMS пропускается.
|
||||
- **OQ-3.** Биллинг для DIRECT-платформы — какой Supplier (`suppliers.code`) использовать? **Ответ:** добавим `supplier code='direct'` в seed; в [LedgerService.resolveSupplierId](../../../app/app/Services/Billing/LedgerService.php#L127) добавим case `if platform=='DIRECT' return Supplier::where('code', 'direct')`.
|
||||
- **OQ-4.** Как DIRECT-supplier_project привязывается к Лидерра-проекту, если `project_supplier_links` для DIRECT supplier_projects ещё нет? **Ответ:** добавляем fallback в `LeadRouter::matchEligibleProjects` для DIRECT supplier_projects — матчинг по `signal_type + signal_identifier` напрямую с `projects.signal_type + projects.signal_identifier`, без обязательного `project_supplier_links`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Создать:**
|
||||
- `database/migrations/2026_05_25_120000_add_direct_platform_to_supplier_projects.php` — расширение CHECK constraints
|
||||
- `database/migrations/2026_05_25_120100_seed_direct_supplier.php` — seed строки `suppliers.code='direct'` (cost_rub из существующего шаблона)
|
||||
- `app/tests/Feature/Supplier/DirectPlatformTest.php` — end-to-end тесты для DIRECT flow
|
||||
|
||||
**Изменить:**
|
||||
- `app/app/Http/Controllers/Api/SupplierWebhookController.php`:
|
||||
- line 86: снять `regex:/^B[123]_.+$/'`
|
||||
- lines 183-188: `parsePlatform` возвращает `'DIRECT'` для не-B
|
||||
- `app/app/Jobs/RouteSupplierLeadJob.php`:
|
||||
- lines 172-200: `parseProjectField` добавить DIRECT branch
|
||||
- `app/app/Jobs/Supplier/CsvReconcileJob.php`:
|
||||
- lines 237-244: `extractPlatform` возвращает 'DIRECT' (а не `null`) для парсящихся как domain/call/sms строк; `null` оставить только для реального мусора (numeric-only без структуры)
|
||||
- `app/app/Services/SupplierProjects/SupplierProjectResolver.php`:
|
||||
- line 24: `ALLOWED_PLATFORMS = ['B1','B2','B3','DIRECT']`
|
||||
- `app/app/Services/LeadRouter.php`:
|
||||
- lines 50-71: для DIRECT — расширить eligibility SQL с fallback на signal_type+identifier
|
||||
- `app/app/Services/Billing/LedgerService.php`:
|
||||
- lines 127-148: `resolveSupplierId` — добавить case `platform='DIRECT'`
|
||||
- `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php`:
|
||||
- line 95: переписать тест — теперь `invalid_no_b_prefix` → 202 (принимается, platform=DIRECT)
|
||||
- `db/schema.sql` — отразить новый constraint
|
||||
- `db/CHANGELOG_schema.md` — запись v8.X
|
||||
|
||||
**Не трогать:**
|
||||
- `LeadDistributor` — cap=3 работает на Collection, platform-agnostic
|
||||
- `supplier_lead_deliveries` — уже Phase 2 покрывает идемпотентность
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Read all touched files + verify b1-not-for-sms constraint
|
||||
|
||||
**Files:**
|
||||
- Read: `db/schema.sql` § supplier_projects + project_supplier_links
|
||||
- Read: `app/database/migrations/` для последней supplier_projects-related migration
|
||||
|
||||
- [ ] **Step 1: Find current CHECK constraints**
|
||||
|
||||
```bash
|
||||
grep -n 'chk_supplier_projects_platform\|chk_psl_platform\|chk_supplier_projects_b1' \
|
||||
"c:/моя/проекты/портал crm/Документация/db/schema.sql"
|
||||
```
|
||||
|
||||
Зафиксировать exact text constraints для миграции (DROP + ADD).
|
||||
|
||||
- [ ] **Step 2: Find last migration touching supplier_projects.platform**
|
||||
|
||||
```bash
|
||||
ls "c:/моя/проекты/портал crm/Документация/app/database/migrations/" | grep -i supplier_project
|
||||
```
|
||||
|
||||
Документировать в комментарии новой миграции.
|
||||
|
||||
- [ ] **Step 3: Verify b1-not-for-sms doesn't conflict with DIRECT**
|
||||
|
||||
`chk_supplier_projects_b1_not_for_sms` — это `CHECK (NOT (platform='B1' AND signal_type='sms'))`. DIRECT+SMS — не B1, так что пропускается. Не нужно трогать.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Migration — extend platform CHECK to include DIRECT
|
||||
|
||||
**Files:**
|
||||
- Create: `app/database/migrations/2026_05_25_120000_add_direct_platform_to_supplier_projects.php`
|
||||
|
||||
- [ ] **Step 1: Write migration**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 3 supplier webhook reliability — расширяет platform enum в
|
||||
* supplier_projects и project_supplier_links до (B1,B2,B3,DIRECT).
|
||||
*
|
||||
* DIRECT — это «прямая» платформа поставщика без B-префикса в имени
|
||||
* проекта (e.g. `client.carmoney.ru`, `cashmotor.ru`, числовые телефоны).
|
||||
* До Phase 3 такие webhook'и отвергались с 302-редиректом и терялись.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
|
||||
*
|
||||
* NB: chk_supplier_projects_b1_not_for_sms (B1+SMS deny) НЕ трогаем —
|
||||
* DIRECT+SMS этим constraint'ом не блокируется.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
|
||||
DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
|
||||
DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
|
||||
DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Перед откатом — убедиться что в БД нет rows с platform='DIRECT',
|
||||
// иначе constraint провалится при ADD. Это ответственность того, кто
|
||||
// запускает migrate:rollback. На prod — отдельный cleanup SQL до отката.
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
|
||||
DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
|
||||
DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
|
||||
DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test migration locally**
|
||||
|
||||
```
|
||||
cd app && php artisan migrate --pretend
|
||||
```
|
||||
|
||||
Expected: видим что DROP/ADD CONSTRAINT statements корректны, без ошибок.
|
||||
|
||||
```
|
||||
cd app && php artisan migrate
|
||||
```
|
||||
|
||||
Expected: migration applied. Проверка:
|
||||
```
|
||||
cd app && php artisan tinker --execute='echo DB::selectOne("SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint WHERE conname=\"chk_supplier_projects_platform\"")->def;'
|
||||
```
|
||||
|
||||
Должно содержать `'DIRECT'`.
|
||||
|
||||
- [ ] **Step 3: Commit migration**
|
||||
|
||||
```bash
|
||||
git add app/database/migrations/2026_05_25_120000_add_direct_platform_to_supplier_projects.php
|
||||
git commit -m "feat(db): extend supplier_projects.platform CHECK to include DIRECT
|
||||
|
||||
Adds DIRECT value to chk_supplier_projects_platform and chk_psl_platform
|
||||
constraints. DIRECT represents supplier projects without B[123]_ prefix
|
||||
(e.g. client.carmoney.ru, cashmotor.ru, numeric phone IDs) — currently
|
||||
67 leads/day lost to 302 redirects from webhook validation.
|
||||
|
||||
Schema-only change; no code yet uses DIRECT — code changes follow in
|
||||
subsequent commits. Migration is forward-compatible: old code continues
|
||||
to work with B1/B2/B3 rows."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Seed Supplier row with code='direct'
|
||||
|
||||
**Files:**
|
||||
- Create: `app/database/migrations/2026_05_25_120100_seed_direct_supplier.php`
|
||||
|
||||
- [ ] **Step 1: Inspect existing suppliers rows**
|
||||
|
||||
```
|
||||
cd app && php artisan tinker --execute='print_r(DB::table("suppliers")->get()->toArray());'
|
||||
```
|
||||
|
||||
Найти существующий `cost_rub` для одной из B-платформ. Использовать тот же (DIRECT — same supplier, разная платформа).
|
||||
|
||||
- [ ] **Step 2: Write seed migration**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 3 — DIRECT supplier row (used by LedgerService::resolveSupplierId
|
||||
* fallback for platform='DIRECT'). cost_rub matches B1 (same supplier,
|
||||
* different routing).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$b1 = DB::table('suppliers')->where('code', 'b1')->first();
|
||||
if ($b1 === null) {
|
||||
// Если B1 нет — significant prod drift, не должно произойти.
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('suppliers')->updateOrInsert(
|
||||
['code' => 'direct'],
|
||||
[
|
||||
'name' => 'BP-GR Direct',
|
||||
'cost_rub' => $b1->cost_rub,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('suppliers')->where('code', 'direct')->delete();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run migration**
|
||||
|
||||
```
|
||||
cd app && php artisan migrate
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
```
|
||||
cd app && php artisan tinker --execute='echo DB::table("suppliers")->where("code","direct")->first()->name;'
|
||||
```
|
||||
|
||||
Expected: `BP-GR Direct`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/database/migrations/2026_05_25_120100_seed_direct_supplier.php
|
||||
git commit -m "feat(db): seed suppliers.code='direct' for DIRECT platform billing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Failing test — DirectPlatformTest end-to-end
|
||||
|
||||
**Files:**
|
||||
- Create: `app/tests/Feature/Supplier/DirectPlatformTest.php`
|
||||
|
||||
- [ ] **Step 1: Write end-to-end test**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_webhook_secret')
|
||||
->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_ip_allowlist')
|
||||
->update(['value' => '[]']);
|
||||
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '1000.00',
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
$this->project = Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'client.carmoney.ru',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 100,
|
||||
'delivered_today' => 0,
|
||||
]);
|
||||
});
|
||||
|
||||
it('webhook with non-B-prefix project is accepted (202) and platform=DIRECT', function () {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 9999001,
|
||||
'project' => 'client.carmoney.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
$response->assertStatus(202);
|
||||
expect(SupplierLead::where('vid', 9999001)->exists())->toBeTrue();
|
||||
expect(SupplierLead::where('vid', 9999001)->first()->platform)->toBe('DIRECT');
|
||||
});
|
||||
|
||||
it('SupplierProjectResolver creates DIRECT supplier_project for non-B project', function () {
|
||||
$resolver = app(\App\Services\SupplierProjects\SupplierProjectResolver::class);
|
||||
$sp = $resolver->resolveOrStub('DIRECT', 'site', 'client.carmoney.ru');
|
||||
expect($sp->platform)->toBe('DIRECT');
|
||||
expect($sp->unique_key)->toBe('client.carmoney.ru');
|
||||
expect($sp->signal_type)->toBe('site');
|
||||
});
|
||||
|
||||
it('RouteSupplierLeadJob delivers DIRECT lead to matching Liderra project via signal_identifier fallback', function () {
|
||||
$lead = SupplierLead::create([
|
||||
'platform' => 'DIRECT',
|
||||
'phone' => '79991234567',
|
||||
'vid' => 9999002,
|
||||
'raw_payload' => ['project' => 'client.carmoney.ru', 'phone' => '79991234567', 'time' => time()],
|
||||
'received_at' => now(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
(new RouteSupplierLeadJob($lead->id))->handle(
|
||||
app(\App\Services\LeadRouter::class),
|
||||
app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
|
||||
app(\App\Services\NotificationService::class),
|
||||
app(\App\Services\Billing\LedgerService::class),
|
||||
app(\App\Services\LeadDistributor::class),
|
||||
app(\App\Services\RegionTagResolver::class),
|
||||
);
|
||||
|
||||
$deal = Deal::where('tenant_id', $this->tenant->id)->where('phone', '79991234567')->first();
|
||||
expect($deal)->not->toBeNull();
|
||||
expect($deal->project_id)->toBe($this->project->id);
|
||||
expect($deal->source_crm_id)->toBe(9999002);
|
||||
});
|
||||
|
||||
it('numeric-only project (e.g. 79135191264) accepted as DIRECT', function () {
|
||||
// Поставщик иногда шлёт project=телефонный номер (callback-проекты).
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 9999003,
|
||||
'project' => '79135191264',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
$response->assertStatus(202);
|
||||
});
|
||||
|
||||
it('existing B1/B2/B3 webhooks still work (regression)', function () {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 9999004,
|
||||
'project' => 'B1_krk-finance.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
$response->assertStatus(202);
|
||||
expect(SupplierLead::where('vid', 9999004)->first()->platform)->toBe('B1');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests, expect FAIL on most**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/DirectPlatformTest.php
|
||||
```
|
||||
|
||||
Expected: тесты #1, #2, #3, #4 FAIL (regex rejects non-B, resolver throws, job throws). Тест #5 PASS (B1 already works).
|
||||
|
||||
- [ ] **Step 3: Commit failing tests**
|
||||
|
||||
```bash
|
||||
git add app/tests/Feature/Supplier/DirectPlatformTest.php
|
||||
git commit -m "test(supplier): end-to-end DIRECT platform tests (failing)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Implement — webhook controller accepts non-B + parsePlatform returns DIRECT
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Http/Controllers/Api/SupplierWebhookController.php`
|
||||
|
||||
- [ ] **Step 1: Remove regex constraint on project field (line 86)**
|
||||
|
||||
```php
|
||||
'project' => ['required', 'string', 'max:255'], // снят regex /^B[123]_.+$/
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update parsePlatform (lines 183-188) to return 'DIRECT' for non-B**
|
||||
|
||||
```php
|
||||
private function parsePlatform(string $project): string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
return 'DIRECT';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests — DirectPlatformTest #1 should now PASS**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/DirectPlatformTest.php --filter='accepted (202) and platform=DIRECT'
|
||||
```
|
||||
|
||||
Expected: PASS. Также:
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php --filter='rejects invalid project format'
|
||||
```
|
||||
|
||||
Тест ('rejects invalid project format ... with 422') теперь будет **FAIL** — потому что мы изменили поведение. Это ожидаемое — переписываем тест в следующем step.
|
||||
|
||||
- [ ] **Step 4: Rewrite the obsolete test in SupplierWebhookTest.php line 95**
|
||||
|
||||
Перепиcать:
|
||||
```php
|
||||
it('accepts project without B[123]_ prefix as platform=DIRECT (Phase 3)', function () {
|
||||
Bus::fake();
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 1, 'project' => 'client.carmoney.ru', 'phone' => '79991234567', 'time' => time(),
|
||||
]);
|
||||
$response->assertStatus(202);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run full SupplierWebhookTest + DirectPlatformTest**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php tests/Feature/Supplier/DirectPlatformTest.php
|
||||
```
|
||||
|
||||
Expected: тесты #1 в DirectPlatformTest PASS, остальные новые — пока FAIL (resolver/job не готовы).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Controllers/Api/SupplierWebhookController.php app/tests/Feature/Http/Webhook/SupplierWebhookTest.php
|
||||
git commit -m "feat(supplier-webhook): accept non-B-prefix projects as platform=DIRECT
|
||||
|
||||
Drops regex /^B[123]_.+\$/ from project field validation; parsePlatform()
|
||||
returns 'DIRECT' for projects without B-prefix. SupplierLead created
|
||||
with platform='DIRECT' for these. Rewrites obsolete test that asserted
|
||||
invalid_format → 422 — now invalid_format → 202 with platform=DIRECT."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Implement — SupplierProjectResolver accepts DIRECT
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Services/SupplierProjects/SupplierProjectResolver.php`
|
||||
|
||||
- [ ] **Step 1: Extend ALLOWED_PLATFORMS**
|
||||
|
||||
```php
|
||||
private const ALLOWED_PLATFORMS = ['B1', 'B2', 'B3', 'DIRECT'];
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run DirectPlatformTest #2**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/DirectPlatformTest.php --filter='creates DIRECT supplier_project'
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Services/SupplierProjects/SupplierProjectResolver.php
|
||||
git commit -m "feat(supplier): SupplierProjectResolver accepts platform=DIRECT"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Implement — RouteSupplierLeadJob.parseProjectField + LeadRouter fallback for DIRECT
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php:172-200`
|
||||
- Modify: `app/app/Services/LeadRouter.php:45-76`
|
||||
|
||||
- [ ] **Step 1: parseProjectField — добавить DIRECT branch**
|
||||
|
||||
В RouteSupplierLeadJob, `parseProjectField` (lines 172-200), заменить начало с:
|
||||
|
||||
```php
|
||||
private function parseProjectField(string $project): array
|
||||
{
|
||||
if (preg_match('/^(B[123])_(.+)$/', $project, $m) === 1) {
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
} else {
|
||||
// Phase 3: проекты без B-префикса попадают в DIRECT.
|
||||
// Весь project считается identifier-частью; signal_type определяется
|
||||
// тем же regex'ом, что для $rest у B-префиксных.
|
||||
$platform = 'DIRECT';
|
||||
$rest = $project;
|
||||
}
|
||||
|
||||
// далее существующий код — определение signal_type/identifier на $rest
|
||||
// (call / site / sms по regex'ам), без изменений
|
||||
$domainRe = '/(?<![a-z0-9.\-])([a-z0-9][a-z0-9\-]*(?:\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,})/i';
|
||||
// ... existing logic ...
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: LeadRouter — добавить DIRECT fallback**
|
||||
|
||||
В LeadRouter::matchEligibleProjects, расширить SQL: для DIRECT supplier_projects использовать fallback по signal_type+signal_identifier matchу с Лидерра-проектами (если нет project_supplier_links для DIRECT).
|
||||
|
||||
```php
|
||||
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
|
||||
{
|
||||
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
|
||||
|
||||
// Phase 3: для DIRECT-supplier_project — fallback на signal_type+signal_identifier
|
||||
// match с Лидерра-проектами, потому что project_supplier_links для DIRECT-row'ов
|
||||
// ещё не настроены (это автоматический матчинг по сигналу). Для B1/B2/B3
|
||||
// продолжаем использовать explicit psl-link.
|
||||
if ($supplierProject->platform === 'DIRECT') {
|
||||
$sql = <<<'SQL'
|
||||
SELECT DISTINCT ON (projects.tenant_id) projects.*
|
||||
FROM projects
|
||||
WHERE projects.signal_type = ?
|
||||
AND LOWER(projects.signal_identifier) = LOWER(?)
|
||||
AND projects.is_active = true
|
||||
AND (projects.delivery_days_mask & ?) <> 0
|
||||
AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tenants
|
||||
WHERE tenants.id = projects.tenant_id
|
||||
AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
|
||||
)
|
||||
ORDER BY
|
||||
projects.tenant_id,
|
||||
(COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
|
||||
projects.created_at,
|
||||
projects.id
|
||||
SQL;
|
||||
$rows = DB::connection('pgsql_supplier')->select(
|
||||
$sql,
|
||||
[$supplierProject->signal_type, $supplierProject->unique_key, $todayBit]
|
||||
);
|
||||
|
||||
return Project::hydrate($rows)->values();
|
||||
}
|
||||
|
||||
// Existing B1/B2/B3 path — explicit psl link
|
||||
$sql = <<<'SQL'
|
||||
SELECT DISTINCT ON (projects.tenant_id) projects.*
|
||||
FROM projects
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM project_supplier_links psl
|
||||
WHERE psl.project_id = projects.id
|
||||
AND psl.supplier_project_id = ?
|
||||
)
|
||||
AND projects.is_active = true
|
||||
AND (projects.delivery_days_mask & ?) <> 0
|
||||
AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tenants
|
||||
WHERE tenants.id = projects.tenant_id
|
||||
AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
|
||||
)
|
||||
ORDER BY
|
||||
projects.tenant_id,
|
||||
(COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
|
||||
projects.created_at,
|
||||
projects.id
|
||||
SQL;
|
||||
$rows = DB::connection('pgsql_supplier')->select($sql, [$supplierProject->id, $todayBit]);
|
||||
|
||||
return Project::hydrate($rows)->values();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run DirectPlatformTest #3 — end-to-end DIRECT routing**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/DirectPlatformTest.php --filter='delivers DIRECT lead'
|
||||
```
|
||||
|
||||
Expected: PASS. Deal создан, project_id matched.
|
||||
|
||||
- [ ] **Step 4: Run full supplier regression**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/ tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Http/Webhook/
|
||||
```
|
||||
|
||||
Expected: все тесты PASS. Особенно регрессия B1/B2/B3 — proxy через `else` branch.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Jobs/RouteSupplierLeadJob.php app/app/Services/LeadRouter.php
|
||||
git commit -m "feat(supplier): RouteSupplierLeadJob + LeadRouter handle DIRECT platform
|
||||
|
||||
parseProjectField() returns ('DIRECT', signal_type, identifier) when project
|
||||
has no B-prefix; identifier-detection (call/site/sms regex) runs on full
|
||||
project string. LeadRouter::matchEligibleProjects has a DIRECT fast-path
|
||||
that matches Liderra projects by (signal_type, signal_identifier) directly
|
||||
without requiring project_supplier_links pivot — because DIRECT
|
||||
supplier_projects are auto-created on first webhook and don't have manual
|
||||
psl links.
|
||||
|
||||
B1/B2/B3 path unchanged (psl-based)."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Implement — LedgerService.resolveSupplierId fallback for DIRECT + CsvReconcileJob extractPlatform
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Services/Billing/LedgerService.php:127-148`
|
||||
- Modify: `app/app/Jobs/Supplier/CsvReconcileJob.php:237-244`
|
||||
|
||||
- [ ] **Step 1: Extend LedgerService.resolveSupplierId**
|
||||
|
||||
```php
|
||||
private function resolveSupplierId(SupplierLead $lead): ?int
|
||||
{
|
||||
if ($lead->supplier_project_id !== null) {
|
||||
$sp = DB::table('supplier_projects')->where('id', $lead->supplier_project_id)->first();
|
||||
if ($sp !== null) {
|
||||
if (in_array($sp->platform, ['B1', 'B2', 'B3'], true)) {
|
||||
$supplier = Supplier::where('code', strtolower($sp->platform))->first();
|
||||
if ($supplier !== null) {
|
||||
return (int) $supplier->id;
|
||||
}
|
||||
}
|
||||
if ($sp->platform === 'DIRECT') {
|
||||
$supplier = Supplier::where('code', 'direct')->first();
|
||||
return $supplier?->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: parse platform from raw_payload['project']
|
||||
$project = trim((string) ($lead->raw_payload['project'] ?? ''));
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
$code = strtolower($m[1]);
|
||||
$supplier = Supplier::where('code', $code)->first();
|
||||
return $supplier?->id;
|
||||
}
|
||||
// Phase 3: project без B-префикса — DIRECT
|
||||
if ($project !== '') {
|
||||
$supplier = Supplier::where('code', 'direct')->first();
|
||||
return $supplier?->id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update CsvReconcileJob.extractPlatform**
|
||||
|
||||
Сейчас extractPlatform возвращает null для не-B → строка увеличивает `unparseable_count` (правильный для МУСОРА типа phone/URL в поле project, но НЕ для DIRECT-проектов как `client.carmoney.ru`). Различение:
|
||||
|
||||
```php
|
||||
private function extractPlatform(string $project): ?string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
// Phase 3: пытаемся распарсить как DIRECT (валидный domain/call/sms identifier).
|
||||
// Только если строка содержит хотя бы одну букву или dot (= вероятно
|
||||
// domain/название), а не чистый-числовой (= скорее всего телефон в роли проекта).
|
||||
if (preg_match('/[a-zA-Zа-яА-Я.]/u', $project) === 1) {
|
||||
return 'DIRECT';
|
||||
}
|
||||
// Чисто цифры или мусор — оставляем как unparseable (как было).
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
NB: чисто-числовые проекты ('79135191264') у поставщика — это **callback-проекты**, они валидны и должны быть DIRECT. Уточняем regex:
|
||||
|
||||
```php
|
||||
private function extractPlatform(string $project): ?string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
// Phase 3: всё что выглядит как разумный identifier (домен / телефон / SMS-sender) → DIRECT.
|
||||
// unparseable_count теперь только для откровенного мусора (пустые / только спец-символы).
|
||||
$trimmed = trim($project);
|
||||
if ($trimmed !== '' && preg_match('/^[\w\-.а-яА-Я0-9\/() +]+$/u', $trimmed) === 1) {
|
||||
return 'DIRECT';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run regression — CsvReconcileJobTest + RouteSupplierLeadJobBillingTest**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/CsvReconcileJobTest.php tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php tests/Feature/Supplier/DirectPlatformTest.php
|
||||
```
|
||||
|
||||
Expected: все PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Services/Billing/LedgerService.php app/app/Jobs/Supplier/CsvReconcileJob.php
|
||||
git commit -m "feat(supplier): LedgerService + CsvReconcileJob recognise DIRECT platform
|
||||
|
||||
LedgerService::resolveSupplierId returns suppliers.code='direct' row for
|
||||
DIRECT-platform supplier_projects (and for parsed-from-payload non-B
|
||||
projects). CsvReconcileJob::extractPlatform now classifies most non-empty,
|
||||
non-junk project strings as DIRECT (instead of dumping them into
|
||||
unparseable_count) — this allows CSV recovery to also create DIRECT
|
||||
supplier_leads, mirroring the webhook path."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Sync db/schema.sql + CHANGELOG_schema.md
|
||||
|
||||
**Files:**
|
||||
- Modify: `db/schema.sql` — поправить constraint definitions
|
||||
- Modify: `db/CHANGELOG_schema.md`
|
||||
|
||||
- [ ] **Step 1: Update db/schema.sql constraint definitions**
|
||||
|
||||
В двух местах `chk_supplier_projects_platform` и `chk_psl_platform` — заменить `IN ('B1','B2','B3')` на `IN ('B1','B2','B3','DIRECT')`.
|
||||
|
||||
- [ ] **Step 2: Add CHANGELOG_schema.md entry**
|
||||
|
||||
```markdown
|
||||
## v8.X — 2026-05-25 — DIRECT platform support
|
||||
|
||||
- Extended `chk_supplier_projects_platform` to include `'DIRECT'`
|
||||
- Extended `chk_psl_platform` to include `'DIRECT'`
|
||||
- Seeded `suppliers.code='direct'` row (BP-GR Direct, cost_rub = same as B1)
|
||||
- Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add db/schema.sql db/CHANGELOG_schema.md
|
||||
git commit -m "docs(schema): sync DIRECT platform CHECK constraints to db/schema.sql"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Regression + prod-readiness
|
||||
|
||||
**Files:**
|
||||
- None
|
||||
|
||||
- [ ] **Step 1: /regression full**
|
||||
|
||||
```
|
||||
/regression full
|
||||
```
|
||||
|
||||
Expected: GREEN. Pest --parallel 700+ tests pass.
|
||||
|
||||
- [ ] **Step 2: Larastan**
|
||||
|
||||
```
|
||||
cd app && composer stan
|
||||
```
|
||||
|
||||
Expected: 0 errors над baseline.
|
||||
|
||||
- [ ] **Step 3: Manual webhook smoke на dev**
|
||||
|
||||
(если dev-сервер работает)
|
||||
```bash
|
||||
cd app && php artisan serve --port=8000 &
|
||||
sleep 2
|
||||
curl -X POST http://localhost:8000/api/webhook/supplier/<dev-secret> \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"vid":99999,"project":"client.carmoney.ru","phone":"79991234567","time":'$(date +%s)'}'
|
||||
pkill -f 'artisan serve' || true
|
||||
```
|
||||
|
||||
Expected: `{"status":"accepted","supplier_lead_id":...}` 202.
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Deploy to liderra.ru
|
||||
|
||||
**Files:**
|
||||
- None
|
||||
|
||||
- [ ] **Step 1: prod-deploy-validator agent**
|
||||
|
||||
```
|
||||
subagent_type: prod-deploy-validator
|
||||
prompt: проверь готовность liderra.ru к Phase 3 деплою. Меняется: миграция БД (2 CHECK constraints), seed (suppliers.code='direct'), 5 PHP-файлов (SupplierWebhookController/RouteSupplierLeadJob/CsvReconcileJob/SupplierProjectResolver/LeadRouter/LedgerService), сменён тест.
|
||||
|
||||
Особое внимание:
|
||||
1. Миграция ALTER CONSTRAINT не блокирует таблицу долго (DROP+ADD на 2 таблицах в одной транзакции).
|
||||
2. После миграции — обязательный queue:restart (RouteSupplierLeadJob memory-cached в воркерах).
|
||||
3. redeploy.sh должен сначала migrate потом optimize — проверь порядок.
|
||||
|
||||
Phase 1 + Phase 2 уже стоят ≥2h. 8 pre-flight + GO/NO-GO.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Merge feature branch → main**
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --ff-only feat/supplier-webhook-fixes
|
||||
git push origin main
|
||||
```
|
||||
|
||||
- [ ] **Step 3: redeploy.sh**
|
||||
|
||||
```bash
|
||||
ssh liderra "cd /var/www/liderra/app && sudo -u www-data ./redeploy.sh 2>&1 | tail -80"
|
||||
```
|
||||
|
||||
Expected: migration ran successfully, queue:restart fired, deploy complete.
|
||||
|
||||
- [ ] **Step 4: Prod smoke — webhook with non-B project**
|
||||
|
||||
```bash
|
||||
ssh liderra 'curl -sk -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"vid\":99999001,\"project\":\"client.carmoney.ru\",\"phone\":\"79991234567\",\"time\":'$(date +%s)'}" \
|
||||
https://liderra.ru/api/webhook/supplier/8c1c07ddb0768763661b357198e0625832f74ad0915d91b1'
|
||||
```
|
||||
|
||||
Expected: `{"status":"accepted","supplier_lead_id":...}` или `{"status":"already_processed",...}` если повтор. Status 202 / 200.
|
||||
|
||||
- [ ] **Step 5: Check supplier_projects has new DIRECT row**
|
||||
|
||||
```bash
|
||||
ssh liderra "sudo -u postgres psql -d liderra -c \"SELECT id, platform, signal_type, unique_key, created_at FROM supplier_projects WHERE platform='DIRECT' ORDER BY id DESC LIMIT 5\""
|
||||
```
|
||||
|
||||
Expected: видим только что созданную (или существующую) DIRECT-row с unique_key='client.carmoney.ru' (test smoke).
|
||||
|
||||
- [ ] **Step 6: Wait 6 hours, observe**
|
||||
|
||||
Через 6 часов:
|
||||
```bash
|
||||
ssh liderra "sudo grep '/api/webhook/supplier' /var/log/nginx/access.log | grep '$(date +%d/%b)' | awk '{print \$9}' | sort | uniq -c"
|
||||
ssh liderra "sudo -u postgres psql -d liderra -c \"SELECT platform, COUNT(*) FROM supplier_leads WHERE received_at > NOW() - interval '6 hours' GROUP BY platform\""
|
||||
ssh liderra "sudo -u postgres psql -d liderra -c \"SELECT COUNT(*) FILTER (WHERE source_crm_id IS NULL) AS no_crm_id, COUNT(*) FILTER (WHERE source_crm_id IS NOT NULL) AS with_crm_id, COUNT(*) AS total FROM deals WHERE tenant_id=2 AND created_at > NOW() - interval '6 hours'\""
|
||||
```
|
||||
|
||||
Expected:
|
||||
- nginx: 0 × 302 на webhook (все принимаются)
|
||||
- supplier_leads: видим записи с platform='DIRECT' (~ 67/24 = 2-3 в час)
|
||||
- deals: 0 unmerged duplicates (Phase 2 покрывает)
|
||||
|
||||
- [ ] **Step 7: Update ПИЛОТ.md + memory**
|
||||
|
||||
```bash
|
||||
# Update ПИЛОТ.md, memory entries
|
||||
git add ПИЛОТ.md
|
||||
git commit -m "docs(пилот): Phase 3 supplier DIRECT platform deployed, $X DIRECT leads in 6h"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Done criteria для Phase 3
|
||||
|
||||
- [ ] Все тесты в DirectPlatformTest.php + регрессия supplier/* + webhook/* PASS
|
||||
- [ ] /regression full GREEN
|
||||
- [ ] Larastan baseline clean
|
||||
- [ ] migration up/down работают на dev
|
||||
- [ ] Прод-smoke: webhook `project: "client.carmoney.ru"` → 202
|
||||
- [ ] 6 часов наблюдения: webhook 302 ушли в 0, новые DIRECT leads принимаются, нет дублей
|
||||
|
||||
---
|
||||
|
||||
## Откат
|
||||
|
||||
Сложнее остальных — есть миграция БД.
|
||||
|
||||
```bash
|
||||
# 1. Cleanup: убрать DIRECT-rows если они появились на проде
|
||||
ssh liderra "sudo -u postgres psql -d liderra -c \"DELETE FROM project_supplier_links WHERE platform='DIRECT'; DELETE FROM supplier_projects WHERE platform='DIRECT'\""
|
||||
|
||||
# 2. Migration down
|
||||
ssh liderra "cd /var/www/liderra/app && sudo -u www-data php artisan migrate:rollback --step=2"
|
||||
|
||||
# 3. Revert code
|
||||
ssh liderra "cd /var/www/liderra/app && git revert --no-edit HEAD~N..HEAD && sudo -u www-data ./redeploy.sh"
|
||||
```
|
||||
|
||||
Лиды с platform=DIRECT, уже превратившиеся в deals, остаются (deal.project_id указывает на валидный Лидерра-проект); supplier_lead.platform='B1' fallback не применится для уже сохранённых, но и не нужен — они уже обработаны.
|
||||
|
||||
Если откат нужен экстренно — можно ограничиться **revert кода без migration:rollback**: миграция оставляет DIRECT в enum, старый код просто никогда не создаст такую row. БД не сломается.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,641 +0,0 @@
|
||||
# Lead Region Resolution — Master Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
>
|
||||
> **This is a MASTER plan split into 6 sessions.** Each session is a self-contained, testable deliverable. Execute sessions **in order** (later sessions depend on earlier ones). Each session = one subagent-driven-development run with its own review checkpoints. Before starting a session, re-read this header + the session's "Preconditions".
|
||||
|
||||
**Goal:** Резолвить настоящий регион лида по телефону (DaData → Россвязь → tag-fallback) и переключить `LeadRouter` на каскадную маршрутизацию по региону, чтобы клиенты, делящие один источник с разными regions, получали только лиды своего региона.
|
||||
|
||||
**Architecture:** Новый сервис `LeadRegionResolver` вызывается в `RouteSupplierLeadJob::handle()` ДО транзакционного цикла, резолвит `subject_code` + оператора по телефону, персистит в `supplier_leads` + `lead_region_resolution_log`. `LeadRouter::matchEligibleProjects` получает новый параметр `?int $resolvedSubjectCode` и фильтрует кандидатов в 3 фазы (точное совпадение региона → «вся РФ» → запасной канал с подменой). Локальный реестр Россвязи (`phone_ranges`) — fallback когда DaData недоступна/неуверена.
|
||||
|
||||
**Tech Stack:** PHP 8.3, Laravel 13, PostgreSQL 16 (партиции, RLS, `INT[]`), Pest 4, Redis (кэш + token-bucket), DaData REST API (`cleaner.dadata.ru/api/v1/clean/phone`).
|
||||
|
||||
**Source spec:** [docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md](../specs/2026-05-29-lead-region-resolution-design.md) v0.5. Прочитать целиком перед стартом — этот план не дублирует §3-§12 спеки, а превращает их в исполнимые шаги.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ КРИТИЧЕСКИЕ ПОПРАВКИ К СПЕКЕ (читать ДО любого кода)
|
||||
|
||||
Эти расхождения спеки с фактическим кодом обнаружены прямым code-walking 30.05.2026. Implementer ОБЯЗАН следовать факту, а не цифрам/именам из спеки.
|
||||
|
||||
1. **Коды субъектов — НЕ автомобильные.** Спека §3.4.1 пишет «77 Москва, 50 МО, 78 СПб, 47 ЛО» — это НЕВЕРНО. Источник истины — [`app/app/Support/RussianRegions.php`](../../../app/app/Support/RussianRegions.php) `CODE_TO_NAME` (конституционный порядок ст. 65, 1..89):
|
||||
- **Москва = 82**, **Санкт-Петербург = 83**, **Московская область = 56**, **Ленинградская область = 53**.
|
||||
- Севастополь = 84, Республика Крым = 13.
|
||||
- Везде в коде/тестах/маппингах использовать ЭТИ коды.
|
||||
|
||||
2. **`RussianRegions` НЕ имеет `codeToName()`-метода.** Есть только `public const CODE_TO_NAME` (массив) и `public static function nameToCode(): array` (через `array_flip`). Если нужен code→name — читать константу `RussianRegions::CODE_TO_NAME[$code]`.
|
||||
|
||||
3. **`LeadRouter::matchEligibleProjects` имеет ДВА SQL-пути** — `DIRECT` (по `signal_type` + `unique_key`) и `B1/B2/B3` (через `project_supplier_links` pivot). Каскад (§3.9) спека показывает только для pivot-пути — **реализовать каскад для ОБОИХ путей**.
|
||||
|
||||
4. **`project_routing_snapshots` УЖЕ содержит `regions INT[] NOT NULL DEFAULT '{}'`** (миграция `2026_05_27_120000`). Колонку добавлять НЕ нужно — каскадный WHERE ложится на готовую колонку через `?::int = ANY(snap.regions)` и `snap.regions = '{}'::int[]`.
|
||||
|
||||
5. **`LeadDistributor::selectRecipients` сейчас берёт cap=3 СЛУЧАЙНО.** Каскад спеки требует упорядоченный отбор (точное → РФ → запасной, сортировка по остатку лимита DESC) внутри роутера. Реконсиляция: роутер сам обрезает до 3 упорядоченно → `LeadDistributor` при `count ≤ CAP` возвращает коллекцию как есть (без шаффла, строка 36-38). Это **смена поведения** (random → детерминированный по остатку лимита). Зафиксировано как сознательное решение — см. §«Открытый вопрос D1» ниже. НЕ менять `LeadDistributor`; роутер просто отдаёт ≤3.
|
||||
|
||||
6. **`subject_code` пишется в `deals` уже сейчас** (Job строка 405-406, через `?int $subjectCode` из `RegionTagResolver`). Интеграция — заменить источник, не добавить колонку. `deals.subject_code` уже существует (миграция `2026_05_20_102000`).
|
||||
|
||||
7. **Команда запуска тестов:** из каталога `app/`. Один файл: `cd app && ./vendor/bin/pest tests/Unit/Services/LeadRegionResolverTest.php`. Фильтр по имени: `cd app && ./vendor/bin/pest --filter="dadata qc 0"`. Полный прогон сервиса перед коммитом сессии. **NB Bash cwd persists** — всегда префиксить `cd app &&` или использовать subshell.
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы для заказчика (решить ДО Session 5-6)
|
||||
|
||||
- **D1 (поведение распределения):** Сейчас при >3 кандидатах лид раздаётся 3 СЛУЧАЙНЫМ клиентам. Новый каскад раздаёт 3 клиентам с НАИБОЛЬШИМ остатком дневного лимита (детерминированно). Это значит: клиент с большим остатком лимита систематически получает больше лидов, чем клиент с малым. Спека §3.9 явно выбрала «сортировка по остатку DESC». **Подтвердить, что random-распределение можно убрать.** (Если заказчик хочет сохранить случайность внутри региона — это +1 задача: random-shuffle внутри каждой фазы перед cap.)
|
||||
- **D2 (ambiguous-list staging):** Список «объединённых» регионов DaData (`'Санкт-Петербург и область'`, `'Москва и область'`) расширяется только по реальным наблюдениям на staging (спека §3.4.1). На старте — ровно эти 2 строки. Подтверждается smoke-прогоном (Session 6).
|
||||
|
||||
---
|
||||
|
||||
## Общие конвенции (применять во ВСЕХ сессиях)
|
||||
|
||||
### Тестовый сетап (Pest 4)
|
||||
|
||||
- **Unit-тесты** (`app/tests/Unit/...`): чистые, без БД где возможно; `Http::fake()` для DaData; `Cache::fake()`/`Cache::store('array')` для кэша.
|
||||
- **Feature-тесты** (`app/tests/Feature/...`): `uses(DatabaseTransactions::class)` + `uses(Tests\Concerns\SharesSupplierPdo::class)`. Tenant-контекст: `DB::statement("SELECT set_config('app.current_tenant_id', '0', true)")` в `beforeEach` (как [`LeadRouterTest.php`](../../../app/tests/Feature/Services/LeadRouterTest.php)).
|
||||
- Фабрики: `Tenant::factory()`, `Project::factory()`, `SupplierProject::factory()`/`::query()->create([...])`, `SupplierLead::factory()`.
|
||||
- Хелперы (в [`app/tests/Pest.php`](../../../app/tests/Pest.php)): `linkProjectToSupplier($project, $supplier)`, `createRoutingSnapshotFromProject($project, ...)` — **последний расширяется в Session 5** (добавить `string $regions = '{}'` параметр).
|
||||
- Pest-стиль: `it('...', function () { ... })`, `expect($x)->toBe(...)`. Никакого PHPUnit class-стиля в новых тестах.
|
||||
|
||||
### Паттерн миграции (raw SQL, образец — `2026_05_27_120000_create_project_routing_snapshots_table.php`)
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
// SET ROLE crm_migrator на проде; на dev/testing — fallback postgres superuser.
|
||||
try {
|
||||
DB::statement('SET ROLE crm_migrator');
|
||||
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
|
||||
if (!$canCreate || !$canCreate->ok) { DB::statement('RESET ROLE'); }
|
||||
} catch (\Throwable) { /* окружение без роли — продолжаем как superuser */ }
|
||||
|
||||
DB::unprepared(<<<'SQL'
|
||||
-- DDL здесь
|
||||
SQL);
|
||||
}
|
||||
public function down(): void
|
||||
{
|
||||
try {
|
||||
DB::statement('SET ROLE crm_migrator');
|
||||
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
|
||||
if (!$canCreate || !$canCreate->ok) { DB::statement('RESET ROLE'); }
|
||||
} catch (\Throwable) {}
|
||||
DB::statement('DROP TABLE IF EXISTS <table> CASCADE');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- GRANT'ы: SaaS-level read-таблицы → `crm_readonly` + `crm_supplier_worker` SELECT; запись через `crm_migrator`. Tenant-таблицы → RLS policy + GRANT `crm_app_user`/`crm_supplier_worker` (образец snapshot-миграции строки 49-55).
|
||||
- Партиционированные таблицы: явный `CREATE TABLE ..._y2026_m05 PARTITION OF ...` для текущего+следующего месяца + регистрация retention в `system_settings` (образец строки 57-78).
|
||||
- **`db/schema.sql` + `db/CHANGELOG_schema.md`** обновлять при каждой схемной правке (правило §4.2 / §5 п.8 CLAUDE.md). Bump версии schema в header.
|
||||
|
||||
### Git / коммиты
|
||||
|
||||
- Ветка: `feat/lead-region-resolution` (создаётся в Session 1, см. Preconditions).
|
||||
- Частые атомарные коммиты (per task). Conventional commits: `feat(region):`, `test(region):`, `chore(region):`.
|
||||
- Каждая сессия завершается зелёной регрессией затронутого слоя + push.
|
||||
|
||||
---
|
||||
|
||||
## SESSION 1 — Схема БД + регистрация партиций
|
||||
|
||||
**Deliverable:** Все таблицы и колонки фичи существуют, миграция up/down работает, партиции регистрируются. Никакой бизнес-логики.
|
||||
**Preconditions:** Чистый `main` (или согласованная база). Создать ветку: `git switch -c feat/lead-region-resolution`. Закоммитить spec (untracked) первым коммитом.
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php`
|
||||
- Modify: `app/app/Services/MonthlyPartitionManager.php:48-62` (PARTITIONED_TABLES map)
|
||||
- Modify: `db/schema.sql` (новые таблицы + ALTER, bump версии) + `db/CHANGELOG_schema.md`
|
||||
- Test: `app/tests/Feature/Migrations/PhoneRangesMigrationTest.php`
|
||||
|
||||
### Task 1.1 — Failing test: миграция создаёт таблицы и колонки
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
`app/tests/Feature/Migrations/PhoneRangesMigrationTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
it('creates phone_ranges with lookup index', function (): void {
|
||||
expect(DB::selectOne("SELECT to_regclass('public.phone_ranges') AS t")->t)->not->toBeNull();
|
||||
$cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name='phone_ranges'"))
|
||||
->pluck('column_name')->all();
|
||||
expect($cols)->toContain('def_code', 'from_num', 'to_num', 'operator', 'region', 'subject_code', 'import_id');
|
||||
});
|
||||
|
||||
it('creates lead_region_resolution_log as partitioned table', function (): void {
|
||||
$p = DB::selectOne("SELECT partattrs FROM pg_partitioned_table pt JOIN pg_class c ON c.oid=pt.partrelid WHERE c.relname='lead_region_resolution_log'");
|
||||
expect($p)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('adds resolution columns to supplier_leads and deals', function (): void {
|
||||
$sl = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name='supplier_leads'"))->pluck('column_name')->all();
|
||||
expect($sl)->toContain('resolved_subject_code', 'region_source', 'dadata_qc', 'phone_operator');
|
||||
$d = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name='deals'"))->pluck('column_name')->all();
|
||||
expect($d)->toContain('phone_operator', 'region_substituted');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — убедиться что падает** (`cd app && ./vendor/bin/pest tests/Feature/Migrations/PhoneRangesMigrationTest.php` → FAIL: relation does not exist)
|
||||
|
||||
- [ ] **Step 3: Написать миграцию.** DDL по спеке §4.1-§4.6 с поправками. Полный DDL (вставить в `DB::unprepared`):
|
||||
|
||||
```sql
|
||||
-- 1. phone_ranges_imports (журнал импортов — создаём ПЕРВЫМ, на него FK)
|
||||
CREATE TABLE phone_ranges_imports (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
source_url TEXT NOT NULL,
|
||||
rows_inserted INTEGER NOT NULL DEFAULT 0,
|
||||
rows_updated INTEGER NOT NULL DEFAULT 0,
|
||||
checksum_sha256 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'in_progress'
|
||||
CHECK (status IN ('in_progress','completed','failed','rolled_back')),
|
||||
error TEXT,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- 2. phone_ranges (реестр Россвязи, SaaS-level без RLS)
|
||||
CREATE TABLE phone_ranges (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
def_code SMALLINT NOT NULL,
|
||||
from_num BIGINT NOT NULL,
|
||||
to_num BIGINT NOT NULL,
|
||||
operator TEXT NOT NULL,
|
||||
region TEXT NOT NULL,
|
||||
region_normalized TEXT,
|
||||
subject_code SMALLINT,
|
||||
imported_at TIMESTAMPTZ NOT NULL,
|
||||
import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
|
||||
CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
|
||||
CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
|
||||
CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
|
||||
);
|
||||
CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num);
|
||||
GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_readonly, crm_supplier_worker;
|
||||
|
||||
-- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at)
|
||||
CREATE TABLE lead_region_resolution_log (
|
||||
id BIGSERIAL,
|
||||
supplier_lead_id BIGINT NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL,
|
||||
phone_masked TEXT NOT NULL,
|
||||
subject_code_resolved SMALLINT,
|
||||
subject_code_from_tag SMALLINT,
|
||||
region_source TEXT NOT NULL CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
|
||||
dadata_qc SMALLINT,
|
||||
dadata_provider TEXT,
|
||||
dadata_type TEXT,
|
||||
dadata_response_masked JSONB,
|
||||
rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
actual_subject_code SMALLINT CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
|
||||
substituted_subject_code SMALLINT CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
|
||||
routing_step SMALLINT CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
|
||||
phone_operator TEXT,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
duration_ms INTEGER,
|
||||
resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (id, received_at)
|
||||
) PARTITION BY RANGE (received_at);
|
||||
CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id);
|
||||
CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at);
|
||||
GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
|
||||
GRANT SELECT ON lead_region_resolution_log TO crm_readonly;
|
||||
CREATE TABLE lead_region_resolution_log_y2026_m05 PARTITION OF lead_region_resolution_log
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE lead_region_resolution_log_y2026_m06 PARTITION OF lead_region_resolution_log
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
|
||||
-- 4. supplier_leads +4 колонки (persistent idempotency + denormalized display)
|
||||
ALTER TABLE supplier_leads
|
||||
ADD COLUMN resolved_subject_code SMALLINT CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89),
|
||||
ADD COLUMN region_source TEXT CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
|
||||
ADD COLUMN dadata_qc SMALLINT,
|
||||
ADD COLUMN phone_operator TEXT;
|
||||
|
||||
-- 5. deals +2 колонки
|
||||
ALTER TABLE deals
|
||||
ADD COLUMN phone_operator TEXT,
|
||||
ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
```
|
||||
|
||||
В том же `up()` после `DB::unprepared`: зарегистрировать retention `lead_region_resolution_log` в `system_settings` (паттерн snapshot-миграции строки 67-78, `value => '12'`, 365 дней). `down()`: `DROP TABLE IF EXISTS lead_region_resolution_log, phone_ranges, phone_ranges_imports CASCADE` + `ALTER TABLE ... DROP COLUMN IF EXISTS ...` для supplier_leads/deals + удалить system_settings ключ.
|
||||
|
||||
> **Гайд по партициям:** новый партиционированный `lead_region_resolution_log` имеет ключ `received_at` (как `deals`). Партиции `deals` создаются помесячно — наши партиции на старте только m05/m06, дальше их подхватит `partitions:create-months` ПОСЛЕ регистрации в Task 1.2.
|
||||
|
||||
- [ ] **Step 4: Прогнать тест — PASS** (`cd app && ./vendor/bin/pest tests/Feature/Migrations/PhoneRangesMigrationTest.php`)
|
||||
|
||||
- [ ] **Step 5: Коммит** `git add -A && git commit -m "feat(region): schema — phone_ranges, resolution_log, supplier_leads/deals columns"`
|
||||
|
||||
### Task 1.2 — Регистрация новой партиц-таблицы в MonthlyPartitionManager
|
||||
|
||||
- [ ] **Step 1: Падающий тест** `app/tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
it('knows lead_region_resolution_log partition key', function (): void {
|
||||
expect(MonthlyPartitionManager::PARTITIONED_TABLES)->toHaveKey('lead_region_resolution_log');
|
||||
expect(MonthlyPartitionManager::PARTITIONED_TABLES['lead_region_resolution_log'])->toBe('received_at');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — FAIL.**
|
||||
- [ ] **Step 3: Добавить** в `MonthlyPartitionManager::PARTITIONED_TABLES` (после строки 61) `'lead_region_resolution_log' => 'received_at',`.
|
||||
- [ ] **Step 4: Прогнать — PASS.**
|
||||
- [ ] **Step 5: Коммит** `chore(region): register lead_region_resolution_log in MonthlyPartitionManager`.
|
||||
|
||||
### Task 1.3 — Синхронизация db/schema.sql + CHANGELOG
|
||||
|
||||
- [ ] **Step 1:** Добавить новые `CREATE TABLE`/`ALTER` в `db/schema.sql` (зеркало миграции), bump версии в header.
|
||||
- [ ] **Step 2:** Запись в `db/CHANGELOG_schema.md` (новая версия, перечень изменений).
|
||||
- [ ] **Step 3:** Коммит `chore(region): sync db/schema.sql + CHANGELOG for region resolution`.
|
||||
|
||||
**Session 1 завершение:** прогон `cd app && ./vendor/bin/pest tests/Feature/Migrations tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php` → GREEN. Push.
|
||||
|
||||
---
|
||||
|
||||
## SESSION 2 — Россвязь: реестр + lookup
|
||||
|
||||
**Deliverable:** `RossvyazPrefixLookup` находит регион+оператора по телефону через `phone_ranges`; `phone-ranges:import` команда импортирует реестр.
|
||||
**Preconditions:** Session 1 смержена/на ветке. Таблицы `phone_ranges*` существуют.
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/RossvyazPrefixLookup.php`, `app/app/Services/Dto/RossvyazRecord.php`
|
||||
- Create: `app/app/Console/Commands/PhoneRangesImportCommand.php`
|
||||
- Test: `app/tests/Unit/Services/RossvyazPrefixLookupTest.php`, `app/tests/Feature/Console/PhoneRangesImportCommandTest.php`
|
||||
|
||||
### Task 2.1 — RossvyazRecord DTO + Lookup (TDD)
|
||||
|
||||
- [ ] **Step 1: Падающие тесты** `RossvyazPrefixLookupTest.php` (Feature, нужна БД — `uses(DatabaseTransactions::class, SharesSupplierPdo::class)`; сидируем `phone_ranges` напрямую через `DB::table`):
|
||||
|
||||
```php
|
||||
it('mobile prefix returns correct region and operator', function (): void {
|
||||
DB::table('phone_ranges')->insert([
|
||||
'def_code'=>921,'from_num'=>5550000,'to_num'=>5559999,'operator'=>'МегаФон',
|
||||
'region'=>'Санкт-Петербург','subject_code'=>83,'imported_at'=>now(),'import_id'=>seedImport(),
|
||||
]);
|
||||
$rec = app(App\Services\RossvyazPrefixLookup::class)->find('7921555XXXX');
|
||||
expect($rec)->not->toBeNull()->and($rec->subjectCode)->toBe(83)->and($rec->region)->toBe('Санкт-Петербург');
|
||||
});
|
||||
it('prefers narrower range when two ranges overlap', function (): void { /* два диапазона, узкий выигрывает (ORDER BY to_num-from_num ASC) */ });
|
||||
it('returns null for unknown prefix', function (): void {
|
||||
expect(app(App\Services\RossvyazPrefixLookup::class)->find('7999XXXXXXX'))->toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
(`seedImport()` — локальный хелпер в тесте: вставляет строку `phone_ranges_imports` и возвращает id.)
|
||||
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация.** `RossvyazRecord` — readonly DTO (`subjectCode: ?int`, `region: string`, `operator: string`). `RossvyazPrefixLookup::find(string $phone): ?RossvyazRecord` по алгоритму спеки §3.7: `def_code = (int) substr($phone,1,3)`, `subscriber = (int) substr($phone,4)`, SQL `SELECT region, operator, subject_code FROM phone_ranges WHERE def_code=? AND from_num<=? AND to_num>=? ORDER BY (to_num-from_num) ASC LIMIT 1`. Запрос через `DB::connection('pgsql_supplier')` (BYPASSRLS, как LeadRouter).
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): RossvyazPrefixLookup + RossvyazRecord DTO`.
|
||||
|
||||
### Task 2.2 — PhoneRangesImportCommand (TDD)
|
||||
|
||||
- [ ] **Step 1: Падающий Feature-тест** — `phone-ranges:import --dry-run` парсит фикстурный XLSX/CSV в `phone_ranges_staging`, маппит region→subject_code через `RussianRegions::nameToCode()`, при `--dry-run` не свапает. (Фикстура: маленький CSV в `app/tests/Fixtures/rossvyaz/sample.csv`.)
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация** по спеке §6.2: staging-таблица → COPY → checksum-idempotency → atomic `RENAME` swap → `phone_ranges_imports.status`. Несматчившиеся регионы → лог в `phone_ranges_imports.error`. `--dry-run` останавливается до swap. **NB:** реальный источник — пакет ~500-600 файлов XLSX (§6.1); для теста парсим один CSV-фикстуру. Парсер XLSX — отдельный приватный метод, в тесте подменяется CSV-веткой через флаг формата.
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): phone-ranges:import command with atomic swap + idempotency`.
|
||||
|
||||
**Session 2 завершение:** GREEN сервис-слой Россвязи. Push. (Реальный первый импорт реестра — оператором в Session 6 раскатке, не в тесте.)
|
||||
|
||||
---
|
||||
|
||||
## SESSION 3 — DaData клиент + бюджет + rate-limit + region map
|
||||
|
||||
**Deliverable:** `DaDataPhoneClient` дёргает REST, `DaDataRegionMap` маппит имя→код, `DaDataBudgetGuard` режет по дневному лимиту, token-bucket защищает от 429. Никакой оркестрации (она в Session 4).
|
||||
**Preconditions:** Sessions 1-2 готовы.
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/DaData/DaDataPhoneClient.php`, `DaDataPhoneResponse.php`, `DaDataQualityCode.php`, `DaDataException.php`, `DaDataTimeoutException.php`
|
||||
- Create: `app/app/Services/DaData/DaDataBudgetGuard.php`
|
||||
- Create: `app/app/Support/DaDataRegionMap.php`
|
||||
- Modify: `app/config/services.php` (+`dadata` блок)
|
||||
- Test: `app/tests/Unit/Services/DaData/DaDataPhoneClientTest.php`, `DaDataBudgetGuardTest.php`, `app/tests/Unit/Support/DaDataRegionMapTest.php`
|
||||
|
||||
### Task 3.1 — config/services.php + DaDataQualityCode enum
|
||||
|
||||
- [ ] **Step 1:** Добавить в `config/services.php`:
|
||||
|
||||
```php
|
||||
'dadata' => [
|
||||
'api_key' => env('DADATA_API_KEY'),
|
||||
'secret' => env('DADATA_SECRET'),
|
||||
'timeout_ms' => (int) env('DADATA_TIMEOUT_MS', 2000),
|
||||
'retries' => (int) env('DADATA_RETRIES', 1),
|
||||
'daily_cap_rub' => (int) env('DADATA_DAILY_CAP_RUB', 10000),
|
||||
'enabled' => filter_var(env('LEAD_REGION_RESOLVER_ENABLED', false), FILTER_VALIDATE_BOOL),
|
||||
'cache_ttl_days' => (int) env('PHONE_REGION_CACHE_TTL_DAYS', 30),
|
||||
],
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** `DaDataQualityCode` — enum:int (CASE_RECOGNIZED=0, ASSUMPTIONS=1, EMPTY=2, MULTIPLE=3, FOREIGN=7). Без теста (тривиальный enum) — покрывается через клиент.
|
||||
- [ ] **Step 3: Коммит** `chore(region): config/services dadata + DaDataQualityCode enum`.
|
||||
|
||||
### Task 3.2 — DaDataRegionMap (TDD)
|
||||
|
||||
- [ ] **Step 1: Падающий unit-тест** `DaDataRegionMapTest.php`:
|
||||
|
||||
```php
|
||||
use App\Support\DaDataRegionMap;
|
||||
it('maps exact official names via RussianRegions', function (): void {
|
||||
expect(DaDataRegionMap::toSubjectCode('Москва'))->toBe(82);
|
||||
expect(DaDataRegionMap::toSubjectCode('Московская область'))->toBe(56);
|
||||
expect(DaDataRegionMap::toSubjectCode('Санкт-Петербург'))->toBe(83);
|
||||
expect(DaDataRegionMap::toSubjectCode('Ленинградская область'))->toBe(53);
|
||||
});
|
||||
it('flags ambiguous agglomeration strings', function (): void {
|
||||
expect(DaDataRegionMap::isAmbiguous('Санкт-Петербург и область'))->toBeTrue();
|
||||
expect(DaDataRegionMap::isAmbiguous('Москва и область'))->toBeTrue();
|
||||
expect(DaDataRegionMap::isAmbiguous('Москва'))->toBeFalse();
|
||||
});
|
||||
it('returns null for unmappable region', function (): void {
|
||||
expect(DaDataRegionMap::toSubjectCode('Атлантида'))->toBeNull();
|
||||
});
|
||||
it('resolves all 89 RussianRegions names', function (): void {
|
||||
foreach (App\Support\RussianRegions::CODE_TO_NAME as $code => $name) {
|
||||
expect(DaDataRegionMap::toSubjectCode($name))->toBe($code);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация.** `DaDataRegionMap`: `AMBIGUOUS_REGIONS = ['Санкт-Петербург и область','Москва и область']` (const). `OVERRIDES` — массив для несовпадающих имён (на старте пустой — заполняется findings). `toSubjectCode(string $name): ?int` → trim → `OVERRIDES[$name] ?? RussianRegions::nameToCode()[$name] ?? null`. `isAmbiguous(string $name): bool` → `in_array($name, self::AMBIGUOUS_REGIONS, true)`.
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): DaDataRegionMap with ambiguous-list + 89-region coverage`.
|
||||
|
||||
### Task 3.3 — DaDataPhoneClient (TDD, Http::fake)
|
||||
|
||||
> **Конвенция HTTP-клиента** — зеркалить [`app/app/Services/Supplier/SupplierPortalClient.php`](../../../app/app/Services/Supplier/SupplierPortalClient.php): инжектить `Illuminate\Http\Client\Factory $http`, кастомные исключения, приватный `request()`.
|
||||
|
||||
- [ ] **Step 1: Падающие unit-тесты** `DaDataPhoneClientTest.php` (по одному на qc 0/1/2/3/7 + timeout + 5xx-retry + 4xx-no-retry). Пример:
|
||||
|
||||
```php
|
||||
use App\Services\DaData\DaDataPhoneClient;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
it('parses qc=0 mobile response', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
|
||||
'qc'=>0,'qc_conflict'=>0,'type'=>'Мобильный','phone'=>'+7 921 555-12-34',
|
||||
'provider'=>'МегаФон','region'=>'Санкт-Петербург и область','timezone'=>'UTC+3',
|
||||
]], 200)]);
|
||||
$resp = app(DaDataPhoneClient::class)->cleanPhone('7921555XXXX');
|
||||
expect($resp->qc)->toBe(0)->and($resp->provider)->toBe('МегаФон')
|
||||
->and($resp->region)->toBe('Санкт-Петербург и область');
|
||||
});
|
||||
it('throws DaDataTimeoutException on connection timeout', function (): void {
|
||||
Http::fake(fn () => throw new Illuminate\Http\Client\ConnectionException('timeout'));
|
||||
expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('7921555XXXX'))
|
||||
->toThrow(App\Services\DaData\DaDataTimeoutException::class);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация** по §3.6: POST `https://cleaner.dadata.ru/api/v1/clean/phone`, headers `Authorization: Token <key>`, `X-Secret: <secret>`, body `["<phone>"]`, timeout из config, retry на сетевые/5xx. Парсинг массива[0] → `DaDataPhoneResponse` (readonly DTO, поля по §3.6). `ConnectionException`/таймаут → `DaDataTimeoutException`; не-2xx после retry → `DaDataException`.
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): DaDataPhoneClient + DTO + exceptions`.
|
||||
|
||||
### Task 3.4 — DaDataBudgetGuard + token-bucket (TDD)
|
||||
|
||||
- [ ] **Step 1: Падающий тест** — `canSpend()` true пока `phone_resolution.dadata.spent_today_kopecks < daily_cap`; false при превышении; `recordSpend()` делает Redis INCRBY. (`Cache::store('array')` или Redis-fake.)
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация** §5.3 + §3.13: `DaDataBudgetGuard` (canSpend/recordSpend через Redis-ключ с дневным TTL). Token-bucket 18 RPS — `RateLimiter::for('dadata-cleaner', ...)` зарегистрировать в провайдере; в клиенте обернуть вызов (или отдельный guard — решить в Session 4 при сборке).
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): DaDataBudgetGuard + rate-limit`.
|
||||
|
||||
**Session 3 завершение:** GREEN `tests/Unit/Services/DaData tests/Unit/Support/DaDataRegionMapTest.php`. Push.
|
||||
|
||||
---
|
||||
|
||||
## SESSION 4 — LeadRegionResolver (оркестратор)
|
||||
|
||||
**Deliverable:** `LeadRegionResolver::resolve(SupplierLead): RegionResolution` со всем каскадом qc-решений, кэшем, ambiguous-логикой, persistent-idempotency, cache-hit логированием. Это сердце фичи.
|
||||
**Preconditions:** Sessions 1-3. Все суб-компоненты существуют и зелёные.
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/LeadRegionResolver.php`, `app/app/Services/Dto/RegionResolution.php`
|
||||
- Test: `app/tests/Unit/Services/LeadRegionResolverTest.php` (12 кейсов из спеки §9.1)
|
||||
|
||||
### Task 4.1 — RegionResolution DTO + source rank
|
||||
|
||||
- [ ] **Step 1: Падающий тест** на DTO: поля `subjectCode: ?int`, `actualSubjectCode: ?int`, `source: string` ('dadata'|'rossvyaz'|'tag'|'unknown'), `phoneOperator: ?string`, `qc: ?int`, `cacheHit: bool`, `dadataResponseMasked: ?array`, `durationMs: ?int`, `rossvyazMatched: bool`. + статик `SOURCE_RANK` const `['dadata'=>4,'rossvyaz'=>3,'tag'=>2,'unknown'=>1]`. + фабрики `fromTag()`, `fromSupplierLead()` (для persistent-idempotency).
|
||||
- [ ] **Step 2-4:** реализация readonly DTO, PASS.
|
||||
- [ ] **Step 5: Коммит** `feat(region): RegionResolution DTO + SOURCE_RANK`.
|
||||
|
||||
### Task 4.2 — LeadRegionResolver: 12 кейсов (TDD, по одному тесту за раз)
|
||||
|
||||
Реализация по алгоритму спеки §3.3 + §3.4 (decision-таблица). Кэш-ключ `sha256("phone-region:".$phone)`, TTL = `config('services.dadata.cache_ttl_days')` дней. Persistent-idempotency: в начале `resolve()` если `$lead->resolved_subject_code !== null || $lead->region_source !== null` → `RegionResolution::fromSupplierLead($lead)` без DaData. Валидация телефона `/^7\d{10}$/` (как в Job/Controller).
|
||||
|
||||
Каждый тест из списка спеки §9.1 — отдельный TDD-цикл (Step write→fail→implement→pass→commit). Имена тестов (Pest `it('...')`):
|
||||
|
||||
- [ ] `dadata qc 0 returns dadata source` — `Http::fake` qc=0 region не-ambiguous → source='dadata', subjectCode маппится.
|
||||
- [ ] `dadata qc 0 ambiguous region falls to rossvyaz but keeps dadata provider` — region='Санкт-Петербург и область' → идём в Россвязь за subjectCode=83, provider остаётся от DaData (И-2). **Ключевой тест ambiguous-логики.**
|
||||
- [ ] `dadata qc 3 returns dadata with multiple flag`.
|
||||
- [ ] `dadata qc 1 falls back to rossvyaz`.
|
||||
- [ ] `dadata qc 2 falls back to tag skipping rossvyaz`.
|
||||
- [ ] `dadata qc 7 falls back to tag skipping rossvyaz`.
|
||||
- [ ] `dadata timeout falls back to rossvyaz`.
|
||||
- [ ] `dadata network error falls back to rossvyaz`.
|
||||
- [ ] `budget cap exceeded skips dadata directly to rossvyaz` (`DaDataBudgetGuard::canSpend()` false).
|
||||
- [ ] `cache hit skips dadata and rossvyaz` — второй вызов того же телефона не дёргает Http (assert `Http::assertSentCount`).
|
||||
- [ ] `invalid phone skips dadata returns tag`.
|
||||
- [ ] `qc 0 region null falls through to rossvyaz` (мобильный без региона, §3.4 Q6/Q7).
|
||||
- [ ] `unmappable dadata region falls through to rossvyaz` (qc=0 но region не в справочнике).
|
||||
- [ ] `all three layers fail returns unknown with null subject_code`.
|
||||
|
||||
После каждого — Step «commit» `feat(region): LeadRegionResolver — <case>` (или батч-коммит на 3-4 связанных кейса).
|
||||
|
||||
**Session 4 завершение:** `cd app && ./vendor/bin/pest tests/Unit/Services/LeadRegionResolverTest.php` все GREEN. Push. **Это самая важная сессия — не торопиться, ревью каждого кейса.**
|
||||
|
||||
---
|
||||
|
||||
## SESSION 5 — LeadRouter каскад + подмена региона
|
||||
|
||||
**Deliverable:** `LeadRouter::matchEligibleProjects` принимает `?int $resolvedSubjectCode`, фильтрует в 3 фазы (точное→РФ→запасной) для ОБОИХ путей (DIRECT + pivot), отдаёт ≤3 кандидата с атрибутом `routing_step`.
|
||||
**Preconditions:** Sessions 1-4. **Решён вопрос D1** (random→deterministic подтверждён заказчиком).
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/LeadRouter.php` (новый параметр + queryCandidates 3-фазы)
|
||||
- Modify: `app/tests/Pest.php` (расширить `createRoutingSnapshotFromProject` параметром `string $regions = '{}'`)
|
||||
- Test: `app/tests/Feature/Services/LeadRouterCascadeTest.php`
|
||||
|
||||
### Task 5.1 — Расширить тест-хелпер
|
||||
|
||||
- [ ] **Step 1:** В `createRoutingSnapshotFromProject` (Pest.php строки 128-150) добавить параметр `string $regions = '{}'` и подставить в insert вместо хардкода `'{}'` (строка 141). Существующие вызовы не ломаются (дефолт сохранён).
|
||||
- [ ] **Step 2:** Прогнать существующий `LeadRouterTest.php` — GREEN (регресс не сломан).
|
||||
- [ ] **Step 3: Коммит** `test(region): createRoutingSnapshotFromProject accepts regions param`.
|
||||
|
||||
### Task 5.2 — Каскад: сигнатура + 3 фазы (TDD)
|
||||
|
||||
> **Подход:** обернуть существующий SQL приватным `queryCandidates(string $activeDate, SupplierProject $sp, string $regionFilter, ?int $code, array $excludeTenantIds, int $limit): Collection`. Он содержит развилку DIRECT vs pivot (как сейчас) + добавляет WHERE-фрагмент по фильтру. `matchEligibleProjects(SupplierProject $sp, ?int $resolvedSubjectCode = null)` оркестрирует 3 фазы (§3.9 псевдокод), проставляет `routing_step` на каждый Project через `$project->setAttribute('routing_step', N)`.
|
||||
|
||||
WHERE-фрагменты:
|
||||
|
||||
- `exact`: `AND ?::int = ANY(snap.regions)` (bind `$code`)
|
||||
- `all_ru`: `AND snap.regions = '{}'::int[]`
|
||||
- `any`: без региона-фильтра (текущее поведение)
|
||||
|
||||
- [ ] **Step 1: Падающие тесты** `LeadRouterCascadeTest.php` (Pest, `DatabaseTransactions` + `SharesSupplierPdo`, tenant-context '0'):
|
||||
|
||||
```php
|
||||
it('step 1: exact region match wins', function (): void {
|
||||
$sp = SupplierProject::query()->create(['platform'=>'B1','signal_type'=>'site','unique_key'=>'ex.ru','subject_code'=>82,'current_limit'=>0,'sync_status'=>'ok']);
|
||||
// tenant A — регион 83 (СПб); tenant B — регион 82 (Москва)
|
||||
$a = makeLinkedProject($sp, regions: '{83}'); // helper inline
|
||||
$b = makeLinkedProject($sp, regions: '{82}');
|
||||
$matched = app(LeadRouter::class)->matchEligibleProjects($sp, resolvedSubjectCode: 82);
|
||||
expect($matched->pluck('id')->all())->toBe([$b->id]) // только Москва-проект
|
||||
->and($matched->first()->routing_step)->toBe(1);
|
||||
});
|
||||
it('step 2: falls to all-RF when no exact match', function (): void {
|
||||
// кандидат только с regions='{}' → routing_step=2 для resolvedSubjectCode=82
|
||||
});
|
||||
it('step 3: fallback channel when nobody subscribed to region', function (): void {
|
||||
// кандидат с regions='{83}' только; resolvedSubjectCode=82 → никто не подписан, нет РФ →
|
||||
// возвращается с routing_step=3 (подмена в Job, не здесь)
|
||||
});
|
||||
it('exact + all-RF combine up to cap=3', function (): void { /* 2 точных + 2 РФ → 3 взяты, точные первыми */ });
|
||||
it('null resolvedSubjectCode skips exact, uses all-RF then fallback', function (): void { /* резолвер не сработал */ });
|
||||
it('cascade works for DIRECT supplier_project path too', function (): void { /* platform=DIRECT */ });
|
||||
```
|
||||
|
||||
(`makeLinkedProject($sp, regions)` — inline-хелпер в файле теста: создаёт tenant с балансом, project, `linkProjectToSupplier`, `createRoutingSnapshotFromProject($p, regions: $regions)`.)
|
||||
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация** каскада. Сохранить fail-loud `logIfNoSnapshot` (вызывать на финальном результате). `excludeTenantIds` для шага 2 = tenant_id из шага 1.
|
||||
- [ ] **Step 4: PASS** + регресс `LeadRouterTest.php` GREEN (старые вызовы без 2-го параметра используют дефолт `null` → ведут себя как «any», но теперь через каскад → проверить что 0-региональные тесты не сломались; при необходимости старые snapshot'ы имеют `regions='{}'` → попадают в шаг 2 all_ru).
|
||||
|
||||
> **⚠️ Регрессионный риск:** существующие `LeadRouterTest` создают snapshot с `regions='{}'` и вызывают `matchEligibleProjects($sp)` без 2-го арг. С каскадом `resolvedSubjectCode=null` → шаг 1 пропускается → шаг 2 all_ru матчит `regions='{}'` → те же результаты. **Проверить это явно**; если расходится — поправить дефолтную ветку, чтобы `null` + любой regions вёл себя как старое «any» (backward-compat). Это решение зафиксировать в коммит-сообщении.
|
||||
|
||||
- [ ] **Step 5: Коммит** `feat(region): LeadRouter cascade routing (exact→all-RF→fallback) with routing_step`.
|
||||
|
||||
**Session 5 завершение:** `cd app && ./vendor/bin/pest tests/Feature/Services/LeadRouterTest.php tests/Feature/Services/LeadRouterCascadeTest.php` GREEN. Push.
|
||||
|
||||
---
|
||||
|
||||
## SESSION 6 — Интеграция в Job + CSV-merge + flag + раскатка
|
||||
|
||||
**Deliverable:** `RouteSupplierLeadJob` использует `LeadRegionResolver`, персистит резолв, передаёт `routing_step`, подменяет регион на шаге 3; CSV-merge обновляет по рангу источника; feature-flag; метрики; staging-smoke.
|
||||
**Preconditions:** Sessions 1-5 все зелёные и смержены.
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php` (handle + createDealCopyForProject + CSV-merge)
|
||||
- Create: `app/app/Console/Commands/PhoneRegionSmokeCommand.php` (staging-smoke §9.4)
|
||||
- Test: `app/tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php`
|
||||
|
||||
### Task 6.1 — Резолв до транзакции + persist (TDD)
|
||||
|
||||
> **Точка вставки** ([RouteSupplierLeadJob.php:151-160](../../../app/app/Jobs/RouteSupplierLeadJob.php#L151)). Сейчас: `$matched = $router->matchEligibleProjects($supplier); $selected = $distributor->selectRecipients($matched); $subjectCode = $tagResolver->resolve(...)`. Становится: резолв региона ДО `matchEligibleProjects`, persist в одной короткой `DB::transaction()`, затем `matchEligibleProjects($supplier, $resolution->subjectCode)`.
|
||||
|
||||
- [ ] **Step 1: Падающий тест** `RouteSupplierLeadJobRegionResolutionTest.php`:
|
||||
|
||||
```php
|
||||
it('lead with phone uses dadata region not tag', function (): void {
|
||||
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc'=>0,'type'=>'Мобильный','provider'=>'МТС','region'=>'Москва']], 200)]);
|
||||
// lead с raw_payload tag='Санкт-Петербург' но phone резолвится в Москву(82)
|
||||
// → deal.subject_code = 82, supplier_leads.resolved_subject_code=82, region_source='dadata'
|
||||
// → строка в lead_region_resolution_log
|
||||
});
|
||||
it('region resolution logged per lead with cache_hit flag', function (): void { /* 1 строка в log */ });
|
||||
it('lead with invalid phone falls back to tag', function (): void { /* phone='123' → region_source='tag' */ });
|
||||
it('lead with resolver disabled via flag uses tag', function (): void { /* config dadata.enabled=false → tag-flow */ });
|
||||
it('persistent idempotency: retry does not re-call dadata', function (): void { /* resolved_subject_code уже set → Http::assertNothingSent */ });
|
||||
```
|
||||
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация.** Инжектить `LeadRegionResolver $regionResolver` в `handle()`. После `$lead->update(['supplier_project_id'...])`:
|
||||
|
||||
```php
|
||||
$resolution = $regionResolver->resolve($lead);
|
||||
// persist в одной короткой транзакции (ДО циклов по проектам — HTTP не висит в tenant-tx)
|
||||
DB::transaction(function () use ($lead, $resolution): void {
|
||||
$lead->update([
|
||||
'resolved_subject_code' => $resolution->subjectCode,
|
||||
'region_source' => $resolution->source,
|
||||
'dadata_qc' => $resolution->qc,
|
||||
'phone_operator' => $resolution->phoneOperator,
|
||||
]);
|
||||
$this->logRegionResolution($lead, $resolution); // INSERT lead_region_resolution_log
|
||||
});
|
||||
$matched = $router->matchEligibleProjects($supplier, $resolution->subjectCode);
|
||||
$selected = $distributor->selectRecipients($matched);
|
||||
```
|
||||
|
||||
Удалить старый `$subjectCode = $tagResolver->resolve(...)`. `RegionTagResolver` остаётся injected (его использует `LeadRegionResolver` как fallback — DI цепочка). Приватный `logRegionResolution()` пишет в `lead_region_resolution_log` через `pgsql_supplier`, телефон маскируется (§7.1: `7XXX***YYYY`).
|
||||
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): wire LeadRegionResolver into RouteSupplierLeadJob + persist`.
|
||||
|
||||
### Task 6.2 — Подмена subject_code на шаге 3 (TDD)
|
||||
|
||||
- [ ] **Step 1: Падающий тест** — `routing_step=3` проект получает deal с `subject_code` = первый из `project->regions`, `region_substituted=true`; `lead_region_resolution_log.actual_subject_code` = настоящий резолв. `routing_step<3` → настоящий subjectCode, `region_substituted=false`.
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация** §3.10. `createDealCopyForProject` получает `RegionResolution $resolution` (вместо `?int $subjectCode`). Внутри:
|
||||
|
||||
```php
|
||||
$dealSubjectCode = ($project->routing_step ?? 1) < 3
|
||||
? $resolution->subjectCode
|
||||
: $this->pickSubstituteRegion($project, $resolution->subjectCode);
|
||||
$dealRegionSubstituted = ($project->routing_step ?? 1) === 3;
|
||||
// Deal::create([... 'subject_code'=>$dealSubjectCode, 'phone_operator'=>$resolution->phoneOperator, 'region_substituted'=>$dealRegionSubstituted])
|
||||
```
|
||||
|
||||
`pickSubstituteRegion(Project $p, ?int $resolved): ?int` — пустой `$p->regions` → `$resolved`; иначе `$p->regions[0]`. Дописать `lead_region_resolution_log` UPDATE с `routing_step`/`actual_subject_code`/`substituted_subject_code` (или включить в Task 6.1 лог — решить при сборке, лог пишется ПОСЛЕ маршрутизации когда routing_step известен; возможно перенести запись лога из 6.1 в конец handle()).
|
||||
|
||||
> **NB порядок записи лога:** `routing_step` известен только ПОСЛЕ `matchEligibleProjects`. Значит INSERT в `lead_region_resolution_log` логичнее делать ПОСЛЕ цикла (с агрегатом routing_step) ИЛИ писать базовую строку в 6.1 и UPDATE'ить routing-поля после. Выбрать: **одна строка на лид** пишется в конце `handle()` с финальными routing-полями (subject_code лида один, routing_step берётся от первого selected-проекта или max). Зафиксировать решение в коммите.
|
||||
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): step-3 fallback subject_code substitution + region_substituted`.
|
||||
|
||||
### Task 6.3 — CSV-merge update по рангу источника (TDD)
|
||||
|
||||
- [ ] **Step 1: Падающий тест** — CSV-recovered deal `region_source='tag'`, subject_code=99; webhook даёт `dadata` subject=82 → merge обновляет subject_code/phone_operator/region_source (rank 4>2). Равный/худший ранг → НЕ обновляет.
|
||||
- [ ] **Step 2: FAIL.**
|
||||
- [ ] **Step 3: Реализация** §3.12 в merge-блоке (строки 340-369). При наличии `$existingMergeable` и нового `$resolution`: сравнить `RegionResolution::SOURCE_RANK`, если новый выше — добавить `subject_code`/`phone_operator`/`region_source` в `DB::table('deals')->where('id')->where('received_at')->update([...])`. **Сохранить `received_at` в WHERE** (partition pruning + FK, как в существующем коде, строки 357-360).
|
||||
- [ ] **Step 4: PASS.**
|
||||
- [ ] **Step 5: Коммит** `feat(region): CSV-merge updates subject_code/operator by source rank`.
|
||||
|
||||
### Task 6.4 — Staging-smoke команда + метрики
|
||||
|
||||
- [ ] **Step 1:** `PhoneRegionSmokeCommand` (`phone-region:smoke --phone=...`) §9.4 — дёргает живой DaData+Россвязь, печатает решение, НЕ пишет в БД. Тест: команда с `Http::fake` печатает структуру.
|
||||
- [ ] **Step 2:** Метрики §8.1 — инкременты `phone_resolution.source.*` / `dadata.qc.*` / `cache.{hit,miss}` через существующий механизм метрик проекта (проверить как проект шлёт в Sentry/Prometheus — grep `metric`/`Sentry::` в `app/app/Services`). Если механизма нет — отложить в отдельную задачу, отметить в коммите.
|
||||
- [ ] **Step 3: Коммит** `feat(region): staging smoke command + resolution metrics`.
|
||||
|
||||
### Task 6.5 — Регрессия + handoff раскатки
|
||||
|
||||
- [ ] **Step 1:** Полная регрессия затронутого слоя: `cd app && ./vendor/bin/pest tests/Unit/Services tests/Feature/Services tests/Feature/Jobs tests/Feature/Migrations`. GREEN.
|
||||
- [ ] **Step 2:** `superpowers:requesting-code-review` на весь диапазон фичи.
|
||||
- [ ] **Step 3:** Документ-handoff раскатки (§10): порядок прод-шагов (миграция → импорт реестра → деплой с `LEAD_REGION_RESOLVER_ENABLED=false` → 1% → 100%), включая `DADATA_API_KEY`/`DADATA_SECRET` в YC Lockbox. Файл: `docs/superpowers/runbooks/2026-05-31-lead-region-resolution-rollout.md`.
|
||||
- [ ] **Step 4: Финальный коммит + PR.** `superpowers:finishing-a-development-branch`.
|
||||
|
||||
**Session 6 завершение:** вся фича зелёная, code-review пройден, runbook готов. Фактический первый импорт реестра Россвязи + раскатка — оператором по runbook, ВНЕ этого плана.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (выполнено автором плана)
|
||||
|
||||
**Spec coverage:** §3.3 резолвер→Session 4; §3.4/§3.4.1 qc+ambiguous→Session 4; §3.7 Россвязь→Session 2; §3.6 DaData→Session 3; §3.9 каскад→Session 5; §3.10 подмена→Session 6.2; §3.11 persist/idempotency→Session 6.1; §3.12 CSV-merge→Session 6.3; §3.13 rate-limit→Session 3.4; §4 схема→Session 1; §5 config→Session 3.1; §6 импорт→Session 2.2; §8 метрики→Session 6.4; §9 тесты→распределены; §11 бюджет→config+guard Session 3. **Gap:** §7 (152-ФЗ маскирование) — покрыто частично (phone_masked в логе, Session 6.1); pg_anonymizer-маски (§7.2) НЕ выделены в задачу → **добавить в Session 1 Task 1.3 как комментарий схемы ИЛИ отдельную задачу раскатки** (low-risk, отметить для заказчика).
|
||||
|
||||
**Type consistency:** `RegionResolution` поля (`subjectCode`/`source`/`phoneOperator`/`qc`/`actualSubjectCode`) согласованы между Session 4 (определение), Session 5 (роутер не зависит от DTO), Session 6 (потребитель). `routing_step` — атрибут на `Project` (Session 5 пишет, Session 6 читает). `SOURCE_RANK` — один источник в `RegionResolution` (Session 4), потребляется в Session 6.3.
|
||||
|
||||
**Placeholders:** DDL, сигнатуры, имена тестов, точка интеграции — конкретны. Полные TDD-шаги для рутинных тестов внутри Session 4/6 описаны именами кейсов + поведением; при subagent-driven-development каждый кейс разворачивается исполнителем в write→fail→implement→pass (имена и ожидаемое поведение заданы точно).
|
||||
|
||||
---
|
||||
|
||||
## Порядок выполнения и ветки
|
||||
|
||||
1. Все 6 сессий — на одной ветке `feat/lead-region-resolution`, последовательно.
|
||||
2. Каждая сессия = отдельный subagent-driven-development прогон с ревью между задачами (Pravila §15.1 — субагенты git только Sonnet/Opus, верификация commit-базы после каждого).
|
||||
3. Между сессиями — пауза/чекпойнт заказчику (можно разнести по календарным дням).
|
||||
4. Изоляция от параллельных сессий: если router-gate v4 streams ещё активны — работать в worktree (`superpowers:using-git-worktrees`), мерж в main отдельным чекпойнтом.
|
||||
@@ -1,427 +0,0 @@
|
||||
# Supplier webhook fast-fail + stuck leads cleanup Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Остановить retry-шторм в `failed_webhook_jobs` (256k записей от 2 застрявших supplier_leads id=1110, 1157) через (а) минимальный resolve этих 2 + (б) код-фикс на fast-fail для будущих случаев, чтобы один битый лид не генерировал >100 000 retry-записей в день.
|
||||
|
||||
**Architecture:** Шторм имеет два слоя:
|
||||
|
||||
1. **Data layer** — 2 supplier_leads с `error LIKE '%does not support%'` имеют `processed_at IS NULL` → поставщик при каждом новом webhook'е/CSV-recovery их видит как «нужно повторить» → пишет новую попытку обработки → 3 retries → 1 строка в failed_webhook_jobs. Решение: `UPDATE supplier_leads SET processed_at=NOW(), error=...` чтобы пометить как terminal-failed.
|
||||
2. **Application logic layer** — `SupplierLeadRouter` (или эквивалент) при получении webhook'а проверяет `chk_supplier_projects_b1_not_for_sms` constraint и кидает Exception **без проверки** что этот же supplier_lead уже failed N раз с тем же message. Решение: short-circuit в job handler — если supplier_lead.error содержит «does not support» и нет нового события (new vid / different signal_identifier), пропускаем без retry.
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16, Pest 4, Queue jobs (Redis backend).
|
||||
|
||||
**Контекст находки:** docs/superpowers/plans/2026-05-29-stage5-monitoring-checklist.md → day 1 → Finding 2. Полный data dump — GitHub Actions run `26616602381` artifact `investigate-day1-round3/investigate3.log`. Распределение failed_webhook_jobs:
|
||||
|
||||
```
|
||||
sl_id=1110 — 152 464 fails за 24h (phone 7933***4038, project «<client-project>.рф», B1+SMS-tag «ваши деньги»)
|
||||
sl_id=1157 — 104 318 fails за 24h (тот же phone+project, source=webhook+vid)
|
||||
все остальные sl_id — по 1 fail (норма)
|
||||
```
|
||||
|
||||
Все 5 supplier_projects.platform='B1' имеют signal_type='call' — нет ни одной B1+SMS записи (constraint chk не позволяет). Поставщик crm.bp-gr.ru шлёт нам этот лид как **SMS-tag «ваши деньги»** → не находит matching supplier_project → Exception.
|
||||
|
||||
**Hypothesis:** Либо у поставщика legacy mapping (тэг «ваши деньги» исторически отправлялся как SMS, но клиент перешёл на call-канал в B1 без обновления mapping'а на стороне поставщика), либо это новый клиент с настройкой которая мы должны разрешить (но constraint запрещает).
|
||||
|
||||
**Out of scope этого плана:** разговор с поставщиком про правильный mapping. Этот план — техническая защита от подобных штормов независимо от того что поставщик пришлёт.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Manual cleanup застрявших лидов (5 минут)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `.github/workflows/sql-runner.yml` (универсальный SQL-runner для прод-операций)
|
||||
- Create: `docs/ops/2026-05-29-stage5-stuck-leads-cleanup.md` (rollback log)
|
||||
|
||||
- [ ] **Step 1: SQL-runner workflow** (расширение GH Actions для произвольных whitelisted SQL запросов)
|
||||
|
||||
```yaml
|
||||
name: Run whitelisted SQL on liderra.ru
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sql:
|
||||
description: 'SQL query (SELECT only by default; UPDATE/DELETE need confirm_mutating=true)'
|
||||
required: true
|
||||
type: string
|
||||
confirm_mutating:
|
||||
description: 'Подтверждаю UPDATE/DELETE на проде'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
run:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
SQL: ${{ github.event.inputs.sql }}
|
||||
CONFIRM_MUT: ${{ github.event.inputs.confirm_mutating }}
|
||||
|
||||
steps:
|
||||
- name: Whitelist check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SQL_LOWER=$(echo "$SQL" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
|
||||
# Allow: SELECT / WITH (CTE) / \d / EXPLAIN
|
||||
READ_RE='^(select |with |explain |\\d|\\df|\\di|\\dt)'
|
||||
|
||||
# Mutating allowed if confirm=true: targeted UPDATE/DELETE on specific tables
|
||||
MUTATING_RE='^(update supplier_leads|update failed_webhook_jobs|update scheduler_heartbeats|delete from failed_webhook_jobs|delete from incidents_log) '
|
||||
|
||||
if [[ "$SQL_LOWER" =~ $READ_RE ]]; then
|
||||
echo "::notice::SELECT/read-only — allowed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$SQL_LOWER" =~ $MUTATING_RE ]]; then
|
||||
if [[ "$CONFIRM_MUT" != "true" ]]; then
|
||||
echo "::error::Mutating SQL requires confirm_mutating=true."
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::Mutating SQL authorized."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error::SQL not in whitelist: $SQL_LOWER"
|
||||
exit 1
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Run on prod
|
||||
run: |
|
||||
set -o pipefail
|
||||
SQL_B64=$(printf '%s' "$SQL" | base64 -w0)
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"SQL_B64='$SQL_B64' bash -s" <<'REMOTE' | tee /tmp/sql.log
|
||||
SQL=$(echo "$SQL_B64" | base64 -d)
|
||||
echo "=== Running on $(hostname) at $(date -u) ==="
|
||||
echo "SQL: $SQL"
|
||||
echo
|
||||
sudo -u postgres psql -d liderra -c "$SQL"
|
||||
RC=$?
|
||||
echo
|
||||
echo "=== Exit code: $RC ==="
|
||||
exit $RC
|
||||
REMOTE
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## SQL on prod"
|
||||
echo
|
||||
echo '```sql'
|
||||
echo "$SQL"
|
||||
echo '```'
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/sql.log 2>/dev/null
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Snapshot before mutation**
|
||||
|
||||
Через `gh workflow run sql-runner.yml -f sql="SELECT id, phone, error, processed_at FROM supplier_leads WHERE id IN (1110, 1157);"` — сохранить вывод в `docs/ops/2026-05-29-stage5-stuck-leads-cleanup.md` для rollback.
|
||||
|
||||
- [ ] **Step 3: Resolve stuck supplier_leads**
|
||||
|
||||
```
|
||||
gh workflow run sql-runner.yml \
|
||||
-f sql="UPDATE supplier_leads SET processed_at = NOW(), error = COALESCE(error,'') || ' [admin-resolved 2026-05-29: B1+SMS unsupported, see plan 2026-05-29-supplier-webhook-fast-fail]' WHERE id IN (1110, 1157) AND processed_at IS NULL;" \
|
||||
-f confirm_mutating=true
|
||||
```
|
||||
|
||||
Expected: 2 rows updated.
|
||||
|
||||
- [ ] **Step 4: Resolve связанные failed_webhook_jobs**
|
||||
|
||||
```
|
||||
gh workflow run sql-runner.yml \
|
||||
-f sql="UPDATE failed_webhook_jobs SET resolved_at = NOW(), retried_by = 'admin-cleanup-2026-05-29' WHERE raw_payload->>'supplier_lead_id' IN ('1110','1157') AND resolved_at IS NULL;" \
|
||||
-f confirm_mutating=true
|
||||
```
|
||||
|
||||
Expected: ~256 000 rows updated.
|
||||
|
||||
- [ ] **Step 5: Verify**
|
||||
|
||||
```
|
||||
gh workflow run sql-runner.yml \
|
||||
-f sql="SELECT COUNT(*) FROM failed_webhook_jobs WHERE failed_at > NOW() - INTERVAL '1 hour' AND resolved_at IS NULL;"
|
||||
```
|
||||
|
||||
Через 1 час после Step 4: ожидаем count <100 (если поставщик продолжает слать те же 2 лида — будут новые попытки, Task 2 это закроет).
|
||||
|
||||
- [ ] **Step 6: Commit + ops log**
|
||||
|
||||
```
|
||||
git add .github/workflows/sql-runner.yml docs/ops/2026-05-29-stage5-stuck-leads-cleanup.md
|
||||
git commit -m "ops: cleanup 2 stuck supplier_leads (1110, 1157) + 256k failed_webhook_jobs (manual resolve, root cause: poставщик шлёт B1+SMS combo, constraint chk запрещает; Task 2 plan — fast-fail в job handler чтобы будущие подобные кейсы не нагнетали)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Fast-fail в SupplierWebhookJob
|
||||
|
||||
**Files:**
|
||||
|
||||
- Locate: `app/app/Jobs/Supplier/ProcessSupplierWebhookJob.php` (или эквивалент — нужно найти конкретный класс)
|
||||
- Modify: добавить short-circuit в начале `handle()`
|
||||
- Test: `app/tests/Feature/Supplier/SupplierWebhookFastFailTest.php`
|
||||
|
||||
- [ ] **Step 1: Locate the actual job class**
|
||||
|
||||
```
|
||||
cd /var/www/liderra/app
|
||||
grep -rln "failed_webhook_jobs" app/ | head -5
|
||||
grep -rln "does not support SMS" app/ | head -5
|
||||
```
|
||||
|
||||
Найти PHP файл где Exception про «B1 does not support SMS» бросается. Записать точный путь.
|
||||
|
||||
- [ ] **Step 2: Failing test**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Supplier;
|
||||
|
||||
use App\Jobs\Supplier\ProcessSupplierWebhookJob; // или реальное имя
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
it('webhook job fast-fails when supplier_lead already errored with does-not-support', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement("SET LOCAL app.current_tenant_id = " . $tenant->id);
|
||||
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => '7933***4038',
|
||||
'raw_payload' => ['tag' => 'ваши деньги', 'project' => 'B1_<client-project>.рф'],
|
||||
'error' => 'B1 platform does not support SMS signals (supplier limitation: chk_supplier_projects_b1_not_for_sms)',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
$startFails = DB::table('failed_webhook_jobs')->count();
|
||||
|
||||
// Dispatch handler synchronously — should fast-fail without writing failed_webhook_jobs
|
||||
$job = new ProcessSupplierWebhookJob($lead->id);
|
||||
$job->handle();
|
||||
|
||||
$endFails = DB::table('failed_webhook_jobs')->count();
|
||||
expect($endFails)->toBe($startFails); // 0 new failures
|
||||
expect($lead->fresh()->processed_at)->not->toBeNull(); // marked processed
|
||||
});
|
||||
|
||||
it('webhook job processes lead normally when error is empty or transient', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement("SET LOCAL app.current_tenant_id = " . $tenant->id);
|
||||
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'error' => null,
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
$job = new ProcessSupplierWebhookJob($lead->id);
|
||||
// Этот тест может бросить (нет matching project) — главное что fast-fail НЕ срабатывает
|
||||
try { $job->handle(); } catch (\Throwable) {}
|
||||
|
||||
// Lead либо processed (matched), либо вернулся в queue — но fast-fail terminal mark не произошёл
|
||||
$fresh = $lead->fresh();
|
||||
$isFastFailed = $fresh->processed_at !== null && str_contains($fresh->error ?? '', 'fast-failed');
|
||||
expect($isFastFailed)->toBeFalse();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run failing test**
|
||||
|
||||
Expected: FAIL — fast-fail логики нет, обработчик идёт по обычному пути, ловит Exception, пишет в failed_webhook_jobs.
|
||||
|
||||
- [ ] **Step 4: Implementation — short-circuit в handle()**
|
||||
|
||||
В найденном на Step 1 классе (предположим `ProcessSupplierWebhookJob`):
|
||||
|
||||
```php
|
||||
public function handle(): void
|
||||
{
|
||||
$lead = SupplierLead::find($this->supplierLeadId);
|
||||
if ($lead === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-fail: if lead already failed with a terminal error and no new
|
||||
// distinguishing data arrived, mark processed and exit.
|
||||
// Closes failed_webhook_jobs storm class (Finding 2 plan).
|
||||
$isTerminal = $lead->error !== null && (
|
||||
str_contains($lead->error, 'does not support')
|
||||
|| str_contains($lead->error, 'platform mismatch')
|
||||
|| str_contains($lead->error, 'no matching supplier_project')
|
||||
);
|
||||
if ($isTerminal && $lead->processed_at === null) {
|
||||
$lead->update([
|
||||
'processed_at' => now(),
|
||||
'error' => $lead->error . ' [fast-failed by ProcessSupplierWebhookJob]',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Existing normal flow...
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test, expect PASS**
|
||||
|
||||
Expected: PASS — fast-fail path активируется когда lead.error содержит terminal-pattern и processed_at IS NULL → НЕ пишет в failed_webhook_jobs → marks processed.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
git add app/app/Jobs/Supplier/ProcessSupplierWebhookJob.php app/tests/Feature/Supplier/SupplierWebhookFastFailTest.php
|
||||
git commit -m "feat(supplier): fast-fail in webhook job for terminal errors (closes 256k failed_webhook_jobs storm class — if supplier_lead.error contains 'does not support'/'platform mismatch'/'no matching supplier_project' and processed_at IS NULL, mark processed and exit without writing failed_webhook_jobs)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Метрика и алерт на новый шторм
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Console/Commands/IncidentsWatchFailures.php` (порог per-supplier_lead_id)
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Incidents;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
it('watch-failures detects single-lead storm (>=1000 failures per supplier_lead_id in 1h)', function (): void {
|
||||
// Insert 1500 failed_webhook_jobs for the same sl_id
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 1500; $i++) {
|
||||
$rows[] = [
|
||||
'raw_payload' => json_encode(['supplier_lead_id' => 9999]),
|
||||
'exception' => 'Test storm',
|
||||
'retry_count' => 3,
|
||||
'failed_at' => now()->subMinutes(rand(1, 30)),
|
||||
];
|
||||
}
|
||||
DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->insert($rows);
|
||||
|
||||
$exitCode = $this->artisan('incidents:watch-failures')->run();
|
||||
expect($exitCode)->toBe(0); // command succeeds
|
||||
|
||||
// Incident logged for the storm
|
||||
$incident = DB::table('incidents_log')
|
||||
->where('root_cause', 'LIKE', '%single-lead-storm%')
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
expect($incident)->not->toBeNull();
|
||||
expect($incident->severity)->toBe('high');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2-5: Implement single-lead-storm detection** в `incidents:watch-failures` (новый порог `--threshold-single-lead=1000`)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Deploy + verify
|
||||
|
||||
> **NB (2026-05-29):** Task 2 — PHP-код fast-fail в RouteSupplierLeadJob.
|
||||
> Task 3 — опция --threshold-single-lead в incidents:watch-failures.
|
||||
> **Миграций нет** — только PHP. Деплой активирует fast-fail немедленно.
|
||||
|
||||
- [ ] **Step 1: Merge + деплой**
|
||||
|
||||
```bash
|
||||
# Controller merges agent branch to main, then:
|
||||
gh workflow run deploy.yml --ref main
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Manual cleanup застрявших лидов (Task 1 Steps 3-4)**
|
||||
|
||||
```bash
|
||||
# UPDATE 2 stuck supplier_leads (processed_at IS NULL)
|
||||
gh workflow run sql-runner.yml \
|
||||
-f sql="UPDATE supplier_leads SET processed_at = NOW(), error = COALESCE(error,'') || ' [admin-resolved 2026-05-29: B1+SMS unsupported, see plan 2026-05-29-supplier-webhook-fast-fail]' WHERE id IN (1110, 1157) AND processed_at IS NULL;" \
|
||||
-f confirm_mutating=true
|
||||
|
||||
# UPDATE ~256k failed_webhook_jobs (mark resolved)
|
||||
gh workflow run sql-runner.yml \
|
||||
-f sql="UPDATE failed_webhook_jobs SET resolved_at = NOW(), retried_by = 'admin-cleanup-2026-05-29' WHERE raw_payload->>'supplier_lead_id' IN ('1110','1157') AND resolved_at IS NULL;" \
|
||||
-f confirm_mutating=true
|
||||
```
|
||||
|
||||
Заполнить лог в `docs/ops/2026-05-29-stage5-stuck-leads-cleanup.md`.
|
||||
|
||||
- [ ] **Step 3: Verify storm stopped (через 1 час после Step 2)**
|
||||
|
||||
```bash
|
||||
gh workflow run sql-runner.yml \
|
||||
-f sql="SELECT COUNT(*) FROM failed_webhook_jobs WHERE failed_at > NOW() - INTERVAL '1 hour' AND resolved_at IS NULL;"
|
||||
```
|
||||
|
||||
Expected: < 100. Если поставщик шлёт те же лиды повторно:
|
||||
- 1-й webhook → новый SupplierLead (error=NULL) → ≤3 retries → failed() записывает error
|
||||
- Следующий retry (RetryFailedSupplierJobsCommand) → fast-fail срабатывает → processed
|
||||
- Net: ≤3 строки failed_webhook_jobs вместо 256k.
|
||||
|
||||
- [ ] **Step 4: Verify incidents:watch-failures алерт**
|
||||
|
||||
```bash
|
||||
gh workflow run sql-runner.yml \
|
||||
-f sql="SELECT root_cause, severity, detected_at FROM incidents_log WHERE root_cause LIKE '%single-lead-storm%' ORDER BY detected_at DESC LIMIT 5;"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Memory update**
|
||||
- `project_state.md` — запись о Task 2+3+4 closure
|
||||
- новый `memory/feedback_supplier_webhook_fast_fail.md` — паттерн + поведение шторма
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
- ✅ Остановить шторм на 1110/1157 → Task 1 manual resolve.
|
||||
- ✅ Запретить будущим подобным лидам нагнетать → Task 2 fast-fail.
|
||||
- ✅ Алерт если новый шторм всё-таки случится → Task 3 single-lead-storm threshold.
|
||||
- ⚠️ Out of scope: связаться с поставщиком про правильный mapping B1+«ваши деньги» — это бизнес-задача, не код.
|
||||
|
||||
**Risks/edge cases:**
|
||||
|
||||
- Task 2 фaст-fail срабатывает на любую ошибку содержащую «does not support» / «platform mismatch» / «no matching supplier_project». **Риск:** если эти patterns появятся transient'ом (например БД временно недоступна → пишет такую ошибку, лид кажется terminal'ным), мы ошибочно пометим processed. Mitigation: список patterns короткий и специфичный, для transient ошибок используется retry с другими error message.
|
||||
- Task 1 Step 4 update 256k rows — может занять несколько минут на проде. БД 16 GB, table small column count. Verify perf на dev перед prod.
|
||||
- Task 1 SQL-runner whitelist разрешает UPDATE/DELETE на 5 конкретных таблиц с confirm. Перед merge'ом — review кем именно из админ-аккаунтов GH Actions можно запускать workflow_dispatch (`Settings → Actions → Workflow permissions`).
|
||||
|
||||
**Что НЕ покрыто (out of scope):**
|
||||
|
||||
- Структурная проблема «constraint vs supplier mapping» — нужно решение от бизнес-сторон (либо снять constraint chk и позволить B1+SMS, либо договориться с поставщиком о mapping). Этот план — техническая защита, не бизнес-решение.
|
||||
@@ -1,347 +0,0 @@
|
||||
# Spec: Спринт 1 «Hygiene» — исправление дефектов аудита 2026-05-09
|
||||
|
||||
**Версия:** 1.0
|
||||
**Дата:** 09.05.2026
|
||||
**Автор:** Claude Code (skill: superpowers:brainstorming)
|
||||
**Заказчик:** Дмитрий
|
||||
**Статус:** черновик, ждёт review заказчика
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Закрыть быстрые/безопасные находки аудита [docs/audit_2026-05-09.md](../../audit_2026-05-09.md) (commit `b6ae8dd`):
|
||||
|
||||
- **Все 3 P0** (security/RLS + сломанный конфиг) — обязательно.
|
||||
- **10 из 12 P1** (P1-10/P1-11 → реестр, не правки).
|
||||
- **Все 3 P2** (мелочи).
|
||||
- **6 low-risk O-\*** (effort=S, risk=low): 2 missing FK indices, password rules trait, ESLint anti-vuetify-import, npm outdated CI workflow, font-display docs.
|
||||
|
||||
**Не входит в Sprint 1:** все большие рефакторинги (DealController/AuthController/12 Vue-компонентов >300 строк), все O-perf кроме индексов, любые миграции стека (Pest 4 browser-tests, Vue 3.5 фичи). Это Спринт 2/3 — отдельный flow.
|
||||
|
||||
**Wall-clock:** 3-5 часов агентов в субагентном режиме.
|
||||
|
||||
## 2. Scope — 22 правки + 3 записи в реестр
|
||||
|
||||
### Дефекты (16)
|
||||
|
||||
- **3 P0:** P0-01, P0-02, P0-03
|
||||
- **10 P1:** P1-01, P1-02, P1-03, P1-04, P1-05, P1-06, P1-07, P1-08, P1-09, P1-12
|
||||
- **3 P2:** P2-01, P2-02, P2-03
|
||||
|
||||
### Out-of-scope (2 P1 → реестр без правок)
|
||||
|
||||
- **P1-10** (auth на /api/deals) — добавить в `docs/Открытые_вопросы_v8_3.md` как новый CTO-вопрос pre-prod migration.
|
||||
- **P1-11** (auth на /api/admin) — уже = Б-1; добавить cross-link.
|
||||
|
||||
### Возможности low-risk (6 из 24 O-\*)
|
||||
|
||||
- **O-perf-02:** index `failed_webhook_jobs.webhook_log_id`
|
||||
- **O-perf-03:** index `rejected_deals_log.webhook_log_id`
|
||||
- **O-refactor-03:** `HasPasswordRules` trait для FormRequest
|
||||
- **O-refactor-05:** ESLint `no-restricted-imports` rule против `vuetify/components`
|
||||
- **O-stack-08:** GitHub Actions workflow с `npm outdated` (еженедельно)
|
||||
- **O-stack-09:** документация `font-display: swap` + WOFF2 в handoff
|
||||
|
||||
## 3. Архитектура — 6 фаз / 6 коммитов
|
||||
|
||||
```
|
||||
[A. DB] → schema.sql v8.10 → v8.11 (RLS + 2 FK indices) + CHANGELOG_schema
|
||||
↓
|
||||
[B. Backend] → app/ register middleware + trait + test fix
|
||||
↓
|
||||
[C. Configs] → 6 config files (npm/lychee/pa11y/composer/eslint/ci)
|
||||
↓
|
||||
[D. Docs (narrative)] → README + CLAUDE.md + Pravila + Tooling + cspell-words
|
||||
↓
|
||||
[E. Docs (handoff)] → BRANDBOOK + DEVELOPER_HANDOFF (status mapping + axe doc + font-display)
|
||||
↓
|
||||
[F. Registry] → Открытые_вопросы (CTO + Histoire-Vite trigger)
|
||||
```
|
||||
|
||||
**Зависимости:**
|
||||
|
||||
- B зависит от A (B упоминает CHANGELOG schema v8.11, который пишется в A).
|
||||
- D зависит от A (D обновляет метрики schema в README/CLAUDE.md).
|
||||
- E/F не зависят от A-D — могут идти параллельно после A-D, но для простоты выполняются последовательно.
|
||||
|
||||
Каждая фаза — отдельный git commit со своим осмысленным scope. После каждой фазы (где применимо) — verification (см. §6).
|
||||
|
||||
## 4. Детальный scope по фазам
|
||||
|
||||
### Фаза A — DB
|
||||
|
||||
**Файлы:** `db/schema.sql`, `db/CHANGELOG_schema.md`.
|
||||
|
||||
**Изменения:**
|
||||
|
||||
1. **P0-02:** добавить после `CREATE TABLE impersonation_tokens` (строка 510):
|
||||
|
||||
```sql
|
||||
ALTER TABLE impersonation_tokens ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON impersonation_tokens
|
||||
USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
```
|
||||
|
||||
2. **O-perf-02:** добавить после индексов `failed_webhook_jobs`:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_failed_webhook_jobs_log ON failed_webhook_jobs(webhook_log_id);
|
||||
```
|
||||
|
||||
3. **O-perf-03:** добавить после индексов `rejected_deals_log`:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_rejected_deals_log_webhook ON rejected_deals_log(webhook_log_id);
|
||||
```
|
||||
|
||||
4. **CHANGELOG_schema.md:** добавить запись v8.10 → v8.11 со ссылкой на audit P0-02 + O-perf-02/03.
|
||||
|
||||
**Шапка schema.sql:** обновить версию v8.10 → v8.11, метрики 37 RLS → 38 RLS, 95 индексов → 97 индексов.
|
||||
|
||||
**Verification:** squawk на schema.sql; повторный grep на ENABLE RLS = 40, POLICY = 38; визуальная проверка diff.
|
||||
|
||||
### Фаза B — Backend
|
||||
|
||||
**Файлы:** `app/app/Http/Kernel.php`, `app/routes/web.php`, `app/app/Http/Requests/Auth/Concerns/HasPasswordRules.php` (создать), `app/app/Http/Requests/Auth/LoginRequest.php`, `app/app/Http/Requests/Auth/RegisterRequest.php`, `app/tests/Feature/AdminIncidentsIndexTest.php`.
|
||||
|
||||
**Изменения:**
|
||||
|
||||
1. **P0-01:** в `app/Http/Kernel.php` зарегистрировать alias:
|
||||
|
||||
```php
|
||||
protected $middlewareAliases = [
|
||||
// ... existing
|
||||
'tenant' => \App\Http\Middleware\SetTenantContext::class,
|
||||
];
|
||||
```
|
||||
|
||||
В `routes/web.php` обернуть tenant-маршруты (Deal/Notification/Reminder/Report/Webhook) в группу `Route::middleware(['tenant'])->group(...)`. Сохранить тестовый flow с `tenant_id` query-param (через middleware читать оба источника: header + query).
|
||||
|
||||
**Risk note:** middleware дополняет, не заменяет существующий MVP-flow. На prod-миграции (Б-1) `tenant_id`-param будет удалён, останется только middleware.
|
||||
|
||||
2. **O-refactor-03:** создать trait `HasPasswordRules`:
|
||||
|
||||
```php
|
||||
namespace App\Http\Requests\Auth\Concerns;
|
||||
|
||||
trait HasPasswordRules
|
||||
{
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', 'min:8'];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Подключить в `LoginRequest::rules()` и `RegisterRequest::rules()`.
|
||||
|
||||
3. **P2-01:** в `tests/Feature/AdminIncidentsIndexTest.php` заменить `bcrypt('test')` на `bcrypt('test1234')` (≥8 chars).
|
||||
|
||||
**Verification:** `cd app && composer test` — Pest 416/416 PASS. `composer stan` — 0 errors.
|
||||
|
||||
### Фаза C — Configs
|
||||
|
||||
**Файлы:** `package.json`, `.lychee.toml`, `pa11y.config.json`, `app/composer.json`, `app/eslint.config.js` (или новый `.eslintrc-vuetify-rules.js`), `.github/workflows/dependency-check.yml` (создать).
|
||||
|
||||
**Изменения:**
|
||||
|
||||
1. **P0-03:** в `package.json:13` заменить `/tmp/schema-formatted.sql` на `db/.schema-formatted.tmp.sql`. Добавить `db/.schema-formatted.tmp.sql` в `.gitignore`.
|
||||
|
||||
2. **P1-02:** в `.lychee.toml` добавить exclude для `web/v8/*.html` (root-relative ссылки концептов):
|
||||
|
||||
```toml
|
||||
exclude = [
|
||||
# ... existing
|
||||
"^/(login|register|legal|dashboard|deals|admin|reports|reminders|billing|impersonation|notifications)",
|
||||
]
|
||||
```
|
||||
|
||||
3. **P1-12:** в `pa11y.config.json` обновить пути с `web/01-login.html` (несуществующих) на `liderra_v8_handoff/concepts/v8_*.html` (фактические).
|
||||
|
||||
4. **P1-07:** в `app/composer.json` `scripts` добавить:
|
||||
|
||||
```json
|
||||
"audit-offline": "@composer audit --locked --no-network"
|
||||
```
|
||||
|
||||
5. **O-refactor-05:** в `app/eslint.config.js` добавить правило `no-restricted-imports`:
|
||||
|
||||
```js
|
||||
{
|
||||
'no-restricted-imports': ['error', {
|
||||
paths: [{
|
||||
name: 'vuetify/components',
|
||||
message: 'Use auto-import via vite-plugin-vuetify (see vite.config.ts)',
|
||||
}],
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
6. **O-stack-08:** создать `.github/workflows/dependency-check.yml`:
|
||||
|
||||
```yaml
|
||||
name: Dependency Check
|
||||
on:
|
||||
schedule: [{ cron: '0 9 * * 1' }] # каждый понедельник 09:00 UTC
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
outdated:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '20' }
|
||||
- run: npm install --ignore-scripts
|
||||
- run: npm outdated --json > outdated.json || true
|
||||
- name: Open issue if outdated
|
||||
if: ${{ hashFiles('outdated.json') != '' }}
|
||||
run: gh issue create --title "Weekly outdated check $(date)" --body "$(cat outdated.json)" --label dependencies
|
||||
env: { GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} }
|
||||
```
|
||||
|
||||
**Verification:** `npm run format:sql:check` (на Windows) — больше не «system cannot find path»; `npm run links` — 0 errors на `docs/**` (web/v8/* исключён); ESLint smoke на правках Vue-файлов; pre-commit hooks PASS.
|
||||
|
||||
### Фаза D — Docs (narrative)
|
||||
|
||||
**Файлы:** `README.md`, `CLAUDE.md`, `docs/Pravila_raboty_Claude_v1_1.md`, `docs/Tooling_v8_3.md`, `cspell-words.txt`.
|
||||
|
||||
**Изменения:**
|
||||
|
||||
1. **P1-01:** в `README.md:83-90` синхронизировать с CLAUDE.md:
|
||||
- Tooling v1.0 → v1.10
|
||||
- Pravila v1.2 → v1.6
|
||||
- schema v8.5 → v8.11 (после Фазы A)
|
||||
- 54 таблицы / 91 индекс / 34 RLS → 56 таблиц / 97 индексов / 38 RLS (после Фазы A)
|
||||
|
||||
2. **P1-03:** в `CLAUDE.md:192` (и §3.3) обновить «Histoire 21/28» → «Histoire 21/43».
|
||||
|
||||
3. **P1-06:** в `docs/Pravila_raboty_Claude_v1_1.md:613` (приблизительно) добавить версию в ссылку: `[Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) (v1.3)`.
|
||||
|
||||
4. **P1-08:** в `docs/Tooling_v8_3.md` Прил. Н §4.2 п.22 дополнить: `stylelint-config-standard ^40.0.0`.
|
||||
|
||||
5. **P2-02:** добавить `ребрендинга` в `cspell-words.txt`. (**Уже добавлено** во время self-review аудита 09.05.2026 — verify, не дублировать.)
|
||||
|
||||
6. **P2-03:** в шапку `CLAUDE.md` рядом с упоминанием «6 трений F–K» добавить ссылку: «(детали в [Plugin_stack_rules_v1.md История версий](../../Plugin_stack_rules_v1.md))».
|
||||
|
||||
**CLAUDE.md правки — через `claude-md-management:claude-md-improver`** (CLAUDE.md §5 п.10 — единственный путь). Pravila/Tooling — то же самое (плагин охватывает их по §0 priority chain).
|
||||
|
||||
**Verification:** `npm run check:docs` (markdownlint + cspell + lychee + a11y); pre-commit hooks PASS.
|
||||
|
||||
### Фаза E — Docs (handoff)
|
||||
|
||||
**Файлы:** `liderra_v8_handoff/docs/BRANDBOOK_v2.md`, `liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md`.
|
||||
|
||||
**Изменения:**
|
||||
|
||||
1. **P1-04:** в `DEVELOPER_HANDOFF.md:430+518` явно указать дату прогона axe-core (если есть в архиве) или пометить как «требует подтверждения после фикса pa11y.config (см. P1-12)». Не удалять заявление, но связать с воспроизводимым evidence.
|
||||
|
||||
2. **P1-05:** в `BRANDBOOK_v2.md` после §3.6 (14 OKLCH-статусов) добавить таблицу:
|
||||
|
||||
| Slug (schema) | Имя BRANDBOOK | OKLCH | Статус-код |
|
||||
|---|---|---|---|
|
||||
| new | Новая | ... | ... |
|
||||
| viewed | Просмотрена | ... | ... |
|
||||
| ... (14 строк всего) | | | |
|
||||
|
||||
Извлечь slug'и из `db/schema.sql:2172-2186` (INSERT lead_statuses), сопоставить с 14 цветами BRANDBOOK по hue-порядку. Если сопоставление неоднозначно — оставить TODO с явным вопросом дизайнеру.
|
||||
|
||||
3. **O-stack-09:** в `DEVELOPER_HANDOFF.md:250-258` (§4 Типографика) добавить раздел «Font loading strategy»:
|
||||
- Объяснить `&display=swap` (FOUT, fallback сразу).
|
||||
- WOFF2 формат по умолчанию из Google Fonts (лучше сжатие).
|
||||
- `<link rel="preconnect">` на `fonts.gstatic.com` для ускорения.
|
||||
|
||||
**Verification:** `npm run check:docs`; визуальная проверка таблицы.
|
||||
|
||||
**Note:** handoff правки — в репозитории `liderra_v8_handoff/`. Если этот dir не входит в наш git (sub-module / external) — Фаза E пропускается, фиксируется как «требует push в handoff-репо отдельно».
|
||||
|
||||
### Фаза F — Registry
|
||||
|
||||
**Файлы:** `docs/Открытые_вопросы_v8_3.md`.
|
||||
|
||||
**Изменения:**
|
||||
|
||||
1. **P1-10 → новый CTO-вопрос:**
|
||||
|
||||
```markdown
|
||||
### CTO-XX (новый, открыт 09.05.2026)
|
||||
**Заголовок:** auth+tenant middleware на /api/deals на pre-prod миграции
|
||||
**Контекст:** MVP использует tenant_id query-param. На prod-миграции
|
||||
обязательно перейти на header `X-Tenant-Id` + middleware('tenant').
|
||||
**Trigger закрытия:** prod-миграция (после Б-1).
|
||||
**Связанные находки:** audit_2026-05-09.md P1-10.
|
||||
```
|
||||
|
||||
2. **P1-11 → cross-link к Б-1:**
|
||||
|
||||
```markdown
|
||||
В разделе Б-1 (admin SSO) добавить упоминание /api/admin/* без auth = тот же блокер.
|
||||
```
|
||||
|
||||
3. **P1-09 → новый OPEN-вопрос:**
|
||||
|
||||
```markdown
|
||||
### OPEN-XX — Histoire ↔ Vite 8 миграционный долг
|
||||
**Контекст:** Histoire 1.0-beta.1 установлен через --legacy-peer-deps.
|
||||
**Trigger закрытия:** релиз Histoire с peerDep `vite ^8`.
|
||||
```
|
||||
|
||||
**Verification:** `npm run links` — 0 errors на новые ссылки.
|
||||
|
||||
## 5. Definition of Done
|
||||
|
||||
Sprint 1 завершён, когда:
|
||||
|
||||
1. ✅ Все 22 правки из §2 + §4 применены, по одной фазе на коммит.
|
||||
2. ✅ `cd app && composer test` — Pest 416/416 PASS (после Фазы B).
|
||||
3. ✅ `cd app && composer stan` — 0 errors above baseline.
|
||||
4. ✅ `cd app && npm run type-check` — 0 type errors.
|
||||
5. ✅ `cd app && npm run test:vue` — Vitest 393/393 PASS.
|
||||
6. ✅ `npm run check:docs` (markdownlint + cspell + lychee + a11y) — 0 errors.
|
||||
7. ✅ Pre-commit hooks lefthook PASS на каждом коммите.
|
||||
8. ✅ schema метрики обновлены до v8.11 (38 RLS / 97 индексов).
|
||||
9. ✅ Git log: 6 коммитов с осмысленными scope-сообщениями.
|
||||
10. ✅ После Фазы F: 3 новые/обновлённые записи в `docs/Открытые_вопросы_v8_3.md`.
|
||||
|
||||
**Не входит в DoD:**
|
||||
|
||||
- Реализация O-* за пределами 6 заявленных.
|
||||
- Прогон Pa11y на handoff концептах (требует браузер-сессии — следствие P1-12 fix, может быть pre-prod manual step).
|
||||
- Спринт 2/3 (modernization, big refactors).
|
||||
|
||||
## 6. Бюджет
|
||||
|
||||
| Фаза | Wall-clock |
|
||||
|---|---|
|
||||
| A. DB | 15-20 мин |
|
||||
| B. Backend | 30-40 мин |
|
||||
| C. Configs | 30-40 мин |
|
||||
| D. Docs (narrative) | 30-45 мин (через claude-md-management) |
|
||||
| E. Docs (handoff) | 30-45 мин |
|
||||
| F. Registry | 10-15 мин |
|
||||
| **Итого** | **2.5-3.5 часа** |
|
||||
|
||||
## 7. Риски
|
||||
|
||||
- **Риск:** RLS-policy на impersonation_tokens может сломать существующие тесты, если они не используют `SET LOCAL app.current_tenant_id`. Митигация: после Фазы A прогнать Pest, если что-то падает — добавить `SET LOCAL` в test setup.
|
||||
- **Риск:** SetTenantContext middleware регистрация может перехватить тестовые маршруты с tenant_id query-param. Митигация: в Фазе B middleware читает оба источника (header + query) до prod-миграции.
|
||||
- **Риск:** Фаза E (handoff) может попасть в внешний репозиторий — если так, фаза формально пропускается с пометкой «требует ручной push».
|
||||
- **Риск:** Фаза D правки CLAUDE.md/Pravila обязательно через `claude-md-management:claude-md-improver` (CLAUDE.md §5 п.10). Прямые Edit/Write этих файлов без skill = нарушение.
|
||||
|
||||
## 8. Плагины и MCP
|
||||
|
||||
| Плагин / Skill | Применение |
|
||||
|---|---|
|
||||
| `superpowers:writing-plans` | Сразу после approve этого spec'а (для implementation plan) |
|
||||
| `superpowers:subagent-driven-development` | Исполнение plan'а в этой же сессии |
|
||||
| `superpowers:verification-before-completion` | После каждой фазы перед коммитом |
|
||||
| `claude-md-management:claude-md-improver` | **Обязательно** для Фазы D правок CLAUDE.md/Pravila/Tooling (CLAUDE.md §5 п.10) |
|
||||
| `laravel-boost` MCP | По необходимости в Фазе B (Eloquent, маршруты) |
|
||||
| `github` MCP | По необходимости в Фазе C для GitHub Actions workflow |
|
||||
| Frontend Design plugin | НЕ призывается (R10: аудит-фиксы ≠ дизайн) |
|
||||
| simplify / security-review / review / init / ui-ux-pro-max | НЕ призываются (R10: только по явному /команде) |
|
||||
|
||||
## 9. Не входит в этот spec
|
||||
|
||||
- Спринт 2 «Modernization» (Pest 4 browser/mutation, Vue 3.5 фичи, Vuetify 3.12 типизированные слоты, Laravel 13 lazy-loading) — отдельный spec→plan→execute.
|
||||
- Спринт 3 «Big refactors» (DealController split, AuthController split, 12 Vue-компонентов >300 строк, OFFSET → keyset, export streaming, CLAUDE.md §0 reorg) — отдельный spec→plan→execute.
|
||||
- Pa11y prod-режим на handoff — manual user step после Фазы C (P1-12 fix).
|
||||
- Регистрация Frontend Design plugin в `~/.claude/settings.json` (O-stack-06) — manual user step, файл вне git.
|
||||
@@ -1,80 +0,0 @@
|
||||
# Spec: Спринт 2 «Modernization» — 12 O-\* находок аудита 2026-05-09
|
||||
|
||||
**Версия:** 1.0
|
||||
**Дата:** 09.05.2026
|
||||
**Заказчик:** Дмитрий
|
||||
**Статус:** утверждён («а» в auto-mode после Sprint 1)
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Закрыть 12 low/medium-risk O-\* находок аудита (после Sprint 1 «Hygiene»). Без архитектурных рефакторингов — только модернизация стека, оптимизация O-perf без breaking-changes, гигиена tooling. Wall-clock: 5-6 часов агентов.
|
||||
|
||||
## 2. Scope — 12 правок
|
||||
|
||||
### O-stack — модернизация (8)
|
||||
|
||||
- **O-stack-01:** Pest 4 browser-tests setup. **Default:** 2 smoke E2E — login-flow (`/login → /dashboard`) и deal-create-flow (`/deals → создать → видна в списке`). Выбор — 2 ключевых сценария, не полное E2E-покрытие.
|
||||
- **O-stack-02:** mutation testing setup. **Default:** установить `infection/infection`, конфиг с базовым thresholdMin=50%, прогон в CI weekly (не блокировать pre-commit).
|
||||
- **O-stack-03:** Laravel 13 string-based lazy-loading контроллеров в `routes/web.php` (заменить `use App\...` на строки `[App\...::class, 'method']` где имеет смысл).
|
||||
- **O-stack-04:** Vue 3.5 фичи в диалогах. **Default:** 3 диалога с `v-model` паттернами — `ImpersonationDialog`, `DealDetailDrawer`, `ConfirmDialog` (если есть). Migrate на `defineModel()` + `useTemplateRef()`.
|
||||
- **O-stack-05:** Vuetify 3.12 типизированные слоты VDataTable. **Default:** 2 таблицы — `DealsView` (списке сделок) и `AdminTenantsView` (SaaS-админка).
|
||||
- **O-stack-06:** Verify Frontend Design plugin в `~/.claude/settings.json`. **Manual user step** — Claude может только распечатать checklist + текущее состояние, не править файл вне git.
|
||||
- **O-stack-07:** ESLint flat-config check — `app/eslint.config.js` уже flat-config (подтверждено в Phase C); просто verify + добавить comment-метку.
|
||||
- **O-stack-10:** Google Fonts API v2 + @font-face fallback в `liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md`. **Документация only**, не правка кода (handoff уже использует Google Fonts).
|
||||
|
||||
### O-perf — оптимизация (2)
|
||||
|
||||
- **O-perf-06:** lazy-imports на 3 views >300 строк — `DealsView` (852), `ReportsView` (592), `DealDetailDrawer` (580). `defineAsyncComponent()` для тяжёлых внутренних диалогов.
|
||||
- **O-perf-07:** Larastan в pre-commit → перенос в pre-push ИЛИ включение result cache. **Default:** result cache в pre-commit (быстрее без жёсткого изменения flow).
|
||||
|
||||
### O-refactor — гигиена (2)
|
||||
|
||||
- **O-refactor-06:** dead-code detection. **Default:** запустить `npm run build -- --analyze` + `knip` smoke; найденные unused-exports — выписать в `.tmp/audit/sprint2_dead_code.md` (НЕ удалять автоматически — отдельное решение).
|
||||
- **O-refactor-07:** CLAUDE.md §0 reorg. **Default:** вынести историю версий из CLAUDE.md в `docs/CHANGELOG_claude_md.md` (там уже есть такой файл!), оставить в CLAUDE.md только последние 2 версии. Через `claude-md-management:claude-md-improver`.
|
||||
|
||||
## 3. Не входит в Sprint 2 (= Sprint 3)
|
||||
|
||||
- O-perf-01 N+1 DealController bulk-actions — high risk, нужны новые тесты
|
||||
- O-perf-04 OFFSET → keyset — breaking API change
|
||||
- O-perf-05 export() streaming — новая зависимость
|
||||
- O-refactor-01/02 DealController/AuthController split — архитектурное (Pravila §4.5)
|
||||
- O-refactor-04 12 Vue-компонентов >300 строк — cross-cutting refactor (R0.6)
|
||||
|
||||
## 4. Архитектура — 4 фазы / 4 коммита
|
||||
|
||||
```
|
||||
[A. Backend modernization] → Pest 4 browser + mutation + lazy-loading + Larastan cache
|
||||
↓
|
||||
[B. Frontend modernization] → Vue 3.5 + Vuetify 3.12 + lazy-imports + ESLint check
|
||||
↓
|
||||
[C. Docs] → Google Fonts API + CLAUDE.md §0 reorg + FD plugin verify checklist
|
||||
↓
|
||||
[D. Hygiene] → dead-code detection report
|
||||
```
|
||||
|
||||
Каждая фаза = 1 коммит. После A — Pest + Larastan; после B — Vitest + vue-tsc + ESLint; после C — `npm run check:docs`; после D — отчёт без правок (анализ).
|
||||
|
||||
## 5. DoD
|
||||
|
||||
- 4 коммита, каждый с осмысленным scope.
|
||||
- `cd app && composer test` — 416/416 + новые browser tests (минимум 2).
|
||||
- `cd app && composer stan` — 0 errors above baseline.
|
||||
- `cd app && npm run type-check` — 0 errors.
|
||||
- `cd app && npm run test:vue` — 393/393 (или больше).
|
||||
- `npm run check:docs` — 0 errors на narrative.
|
||||
- `.tmp/audit/sprint2_dead_code.md` создан с отчётом (анализ без удалений).
|
||||
- `~/.claude/settings.json` checklist распечатан в финальном отчёте Sprint 2.
|
||||
|
||||
## 6. Риски
|
||||
|
||||
- **O-stack-01 Pest 4 browser-tests на Windows** — Pest 4 browser использует Playwright под капотом, может потребовать `npx playwright install chromium`. На Windows native может быть тормоза/конфликты. Митигация — установить, если падает на setup → smoke не пишем, фиксируем как «требует Linux CI» в реестре.
|
||||
- **O-stack-02 infection/infection** — может быть медленным на 416 тестах (>5-10 минут). Митигация — конфиг с filter по `--filter='App\Http'` (только наш код, не vendor), CI-only.
|
||||
- **O-stack-04/05 Vue 3.5 migration** — `defineModel()` ломает type signatures props/events; нужно проверить ESLint + vue-tsc после каждой миграции.
|
||||
- **O-perf-06 lazy-imports** — `defineAsyncComponent()` меняет shape компонента, может ломать tests. Митигация — Vitest после каждой миграции.
|
||||
- **O-refactor-07 CLAUDE.md §0 reorg** — обязательно через `claude-md-management:claude-md-improver` (CLAUDE.md §5 п.10).
|
||||
|
||||
## 7. Не делаем
|
||||
|
||||
- Pa11y prod-режим (пользователь delegated в Sprint 1 P1-12, требует браузер-сессии).
|
||||
- Pre-prod migration P1-10/11 (уже в реестре, ждут Б-1).
|
||||
- Любые из 6 Sprint 3 пунктов (high risk, отдельный spec→plan).
|
||||
@@ -1,460 +0,0 @@
|
||||
# Sprint 6 «Post-MVP backend» — Design Spec
|
||||
|
||||
**Версия:** 1.0
|
||||
**Дата:** 10.05.2026
|
||||
**Автор:** Claude Code (skill: superpowers:brainstorming)
|
||||
**Заказчик:** Дмитрий
|
||||
**Статус:** approved
|
||||
**Базовый HEAD:** `e5848bd` (Sprint 5 Pre-prod tooling закрыт)
|
||||
**Источники:** codebase exploration (Reports/Notifications/AdminTenantDetail), roadmap Sprint 6, memory `project_state.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Закрыть Post-MVP backend backlog, не зависящий от Б-1: расширить Reports (3 новых провайдера + файловый download + S3-абстракция), реализовать оставшиеся Notification интеграции (new_device_login + deep-link bell + ЮKassa scaffold) и урезанный AdminTenantDetail (contact_phone + role).
|
||||
|
||||
**Definition of done:**
|
||||
|
||||
- 3 новых типа отчётов генерируются и скачиваются через API
|
||||
- `GET /api/reports/jobs/{id}/file` отдаёт файл с правильным Content-Disposition
|
||||
- `REPORT_DISK` переменная позволяет переключиться на S3 без кода
|
||||
- При логине с нового устройства пользователь получает email
|
||||
- Клик по колокольчику с deal_id навигирует на нужный раздел
|
||||
- `POST /api/webhooks/yukassa` принимает платёж и обновляет invoice
|
||||
- `tenants.contact_phone` и `users.role` в схеме, API и Vue
|
||||
- Регрессия зелёная: Pest / Vitest / Larastan / vue-tsc / ESLint
|
||||
|
||||
---
|
||||
|
||||
## 2. Non-goals
|
||||
|
||||
- `inn`, `legal_address` для тенантов — ждут Б-1
|
||||
- Реальная интеграция ЮKassa (боевые ключи, тестовые платежи) — ждёт Б-1 / DO-2
|
||||
- Push-уведомления (FCM / APNS) — Post-MVP
|
||||
- PDF-формат отчётов — Post-MVP (`PdfStubFormatter` остаётся)
|
||||
- Новые модули CRM (аналитика второго порядка и т.п.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase A: Reports backend доделки
|
||||
|
||||
### 3.1 Три новых провайдера
|
||||
|
||||
Все провайдеры реализуют `App\Services\Reports\Providers\ReportDataProvider`:
|
||||
|
||||
```php
|
||||
interface ReportDataProvider {
|
||||
public function headers(): array;
|
||||
public function rows(ReportJob $job): array;
|
||||
public function slug(): string;
|
||||
}
|
||||
```
|
||||
|
||||
Фильтры берутся из `$job->parameters` (JSONB): `date_from`, `date_to` обязательные (уже валидируются в `ReportJobController::store()`).
|
||||
|
||||
#### ManagersSummaryProvider
|
||||
|
||||
- **Slug:** `managers_summary`
|
||||
- **Файл:** `app/app/Services/Reports/Providers/ManagersSummaryProvider.php`
|
||||
- **Колонки:** Менеджер | Всего заявок | Принято | Отклонено | Конверсия % | Выручка ₽ | Средняя стоимость ₽
|
||||
- **Источник данных:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COALESCE(u.first_name || ' ' || u.last_name, u.email) AS manager,
|
||||
COUNT(d.id) AS total,
|
||||
COUNT(d.id) FILTER (WHERE d.status = 'accepted') AS accepted,
|
||||
COUNT(d.id) FILTER (WHERE d.status = 'rejected') AS rejected,
|
||||
ROUND(COUNT(d.id) FILTER (WHERE d.status = 'accepted') * 100.0 / NULLIF(COUNT(d.id), 0), 1) AS conversion,
|
||||
COALESCE(SUM(slc.cost_rub), 0) AS revenue_rub,
|
||||
ROUND(AVG(slc.cost_rub), 2) AS avg_cost_rub
|
||||
FROM deals d
|
||||
LEFT JOIN users u ON u.id = d.assigned_user_id
|
||||
LEFT JOIN supplier_lead_costs slc ON slc.deal_id = d.id
|
||||
WHERE d.received_at BETWEEN :date_from AND :date_to
|
||||
AND d.deleted_at IS NULL
|
||||
GROUP BY u.id, u.first_name, u.last_name, u.email
|
||||
ORDER BY total DESC
|
||||
```
|
||||
|
||||
- **Тест-кейсы:** headers() возвращает 7 колонок; rows() с данными считает конверсию; rows() на пустой период → пустой массив.
|
||||
|
||||
#### SourcesSummaryProvider
|
||||
|
||||
- **Slug:** `sources_summary`
|
||||
- **Файл:** `app/app/Services/Reports/Providers/SourcesSummaryProvider.php`
|
||||
- **Колонки:** Источник | Всего заявок | Принято | Конверсия % | Итого ₽ | Среднее ₽
|
||||
- **Источник данных:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
p.name AS source,
|
||||
COUNT(d.id) AS total,
|
||||
COUNT(d.id) FILTER (WHERE d.status = 'accepted') AS accepted,
|
||||
ROUND(COUNT(d.id) FILTER (WHERE d.status = 'accepted') * 100.0 / NULLIF(COUNT(d.id), 0), 1) AS conversion,
|
||||
COALESCE(SUM(slc.cost_rub), 0) AS total_rub,
|
||||
ROUND(AVG(slc.cost_rub), 2) AS avg_cost_rub
|
||||
FROM deals d
|
||||
LEFT JOIN projects p ON p.id = d.project_id
|
||||
LEFT JOIN supplier_lead_costs slc ON slc.deal_id = d.id
|
||||
WHERE d.received_at BETWEEN :date_from AND :date_to
|
||||
AND d.deleted_at IS NULL
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY total DESC
|
||||
```
|
||||
|
||||
#### BillingSummaryProvider
|
||||
|
||||
- **Slug:** `billing_summary`
|
||||
- **Файл:** `app/app/Services/Reports/Providers/BillingSummaryProvider.php`
|
||||
- **Колонки:** Дата | Тип операции | Сумма ₽ | Баланс после ₽ | Описание
|
||||
- **Источник данных:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
bt.created_at::date AS date,
|
||||
bt.transaction_type,
|
||||
bt.amount_rub,
|
||||
bt.balance_after_rub,
|
||||
bt.description
|
||||
FROM balance_transactions bt
|
||||
WHERE bt.created_at BETWEEN :date_from AND :date_to
|
||||
ORDER BY bt.created_at ASC
|
||||
```
|
||||
|
||||
(RLS обеспечивает изоляцию tenant'а через `SET LOCAL app.current_tenant_id`)
|
||||
- **Параметры:** только `date_from`/`date_to`, без `project_id`/`manager_id`
|
||||
|
||||
### 3.2 ReportGeneratorRegistry — расширение
|
||||
|
||||
**Файл:** `app/app/Services/Reports/ReportGeneratorRegistry.php`
|
||||
|
||||
Обновить `isSupported()` — добавить 3 новых типа в `SUPPORTED_TYPES` константу (или аналог):
|
||||
|
||||
```php
|
||||
private const SUPPORTED_TYPES = [
|
||||
'deals_export',
|
||||
'managers_summary',
|
||||
'sources_summary',
|
||||
'billing_summary',
|
||||
];
|
||||
```
|
||||
|
||||
Зарегистрировать 3 новых провайдера в `provider()` switch/match — по аналогии с `DealsExportProvider`.
|
||||
|
||||
### 3.3 File download endpoint
|
||||
|
||||
**Route:** `GET /api/reports/jobs/{id}/file` (под `auth:sanctum` + `tenant` middleware)
|
||||
**Controller:** `ReportJobController::download(ReportJob $job)`
|
||||
**Файл:** `app/app/Http/Controllers/Api/ReportJobController.php` (добавить метод)
|
||||
|
||||
Логика:
|
||||
|
||||
1. Проверить принадлежность job текущему tenant (уже делает Route Model Binding + RLS, дополнительно явная проверка `$job->tenant_id === auth()->user()->tenant_id`)
|
||||
2. Если `$job->status !== 'done'` → 422 с `{"message": "Report not ready"}`
|
||||
3. Если `$job->file_path === null` → 410 Gone (`{"message": "Report file expired"}`)
|
||||
4. Если файл физически не существует → 410 Gone
|
||||
5. Определить MIME по формату из `$job->parameters['format']`:
|
||||
- csv → `text/csv; charset=utf-8`
|
||||
- xlsx → `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
|
||||
- json → `application/json`
|
||||
6. `StreamedResponse` через `Storage::disk(config('filesystems.reports_disk'))->readStream($job->file_path)`
|
||||
7. Заголовки: `Content-Disposition: attachment; filename="report-{$job->id}.{ext}"`, `Content-Type`, `Content-Length`
|
||||
|
||||
### 3.4 S3-абстракция
|
||||
|
||||
**Файл:** `config/filesystems.php`
|
||||
Добавить в секцию `disks`:
|
||||
|
||||
```php
|
||||
'reports' => [
|
||||
'driver' => env('REPORT_DISK', 'local'),
|
||||
// При driver=s3 подхватит стандартные AWS_* env vars
|
||||
// При driver=local — файлы в storage/app/
|
||||
],
|
||||
```
|
||||
|
||||
**Файл:** `app/app/Jobs/GenerateReportJob.php`
|
||||
Заменить `Storage::disk('local')` на `Storage::disk('reports')`.
|
||||
|
||||
**Файл:** `app/app/Http/Controllers/Api/ReportJobController.php` (download метод)
|
||||
Использовать `Storage::disk('reports')`.
|
||||
|
||||
**Файл:** `.env.example`
|
||||
Добавить `REPORT_DISK=local`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase B: Notifications
|
||||
|
||||
### 4.1 new_device_login
|
||||
|
||||
#### Миграция: `user_known_devices`
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_known_devices (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
fingerprint VARCHAR(64) NOT NULL,
|
||||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_user_known_devices UNIQUE (user_id, fingerprint)
|
||||
);
|
||||
CREATE INDEX idx_user_known_devices_user_id ON user_known_devices(user_id);
|
||||
```
|
||||
|
||||
**Миграция-файл:** `app/database/migrations/XXXX_XX_XX_create_user_known_devices_table.php`
|
||||
|
||||
#### Логика определения нового устройства
|
||||
|
||||
**Файл:** `app/app/Http/Controllers/Auth/AuthenticatedSessionController.php` (метод `store()`)
|
||||
|
||||
После успешной аутентификации (`Auth::attempt(...)` прошёл):
|
||||
|
||||
```php
|
||||
$fingerprint = hash('sha256', $request->ip() . '|' . $request->userAgent());
|
||||
$isNew = ! UserKnownDevice::where('user_id', $user->id)
|
||||
->where('fingerprint', $fingerprint)
|
||||
->exists();
|
||||
|
||||
UserKnownDevice::firstOrCreate(
|
||||
['user_id' => $user->id, 'fingerprint' => $fingerprint],
|
||||
['first_seen_at' => now()]
|
||||
);
|
||||
|
||||
if ($isNew) {
|
||||
app(NotificationService::class)->notifyNewDeviceLogin(
|
||||
$user,
|
||||
$request->ip(),
|
||||
$request->userAgent()
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### NotificationService::notifyNewDeviceLogin()
|
||||
|
||||
**Файл:** `app/app/Services/NotificationService.php` (добавить метод)
|
||||
|
||||
```php
|
||||
public function notifyNewDeviceLogin(User $user, string $ip, string $userAgent): void
|
||||
{
|
||||
if (!($user->notification_preferences['new_device_login']['email'] ?? true)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Mail::to($user->email)->send(new NewDeviceLoginMail($user, $ip, $userAgent));
|
||||
} catch (Throwable $e) {
|
||||
Log::error('new_device_login mail failed', ['user' => $user->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Mailable + Blade
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `app/app/Mail/NewDeviceLoginMail.php`
|
||||
- `app/resources/views/emails/new-device-login.blade.php`
|
||||
|
||||
`NewDeviceLoginMail` получает `$user`, `$ip`, `$userAgent`. Blade-шаблон показывает: IP-адрес, браузер/ОС (из userAgent, без парсинга — raw строка), время, ссылку на смену пароля (`/settings#security`). Стиль — аналогичен существующим шаблонам проекта.
|
||||
|
||||
#### Модель UserKnownDevice
|
||||
|
||||
**Файл:** `app/app/Models/UserKnownDevice.php`
|
||||
|
||||
```php
|
||||
class UserKnownDevice extends Model {
|
||||
public $timestamps = false;
|
||||
protected $fillable = ['user_id', 'fingerprint', 'first_seen_at'];
|
||||
protected $casts = ['first_seen_at' => 'datetime'];
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Deep-link bell (Vue frontend)
|
||||
|
||||
**Файл:** `app/resources/js/components/AppTopbar.vue` (или субкомпонент BellNotifications)
|
||||
|
||||
При клике на уведомление в списке:
|
||||
|
||||
```typescript
|
||||
function handleNotificationClick(notification: InAppNotification) {
|
||||
markAsRead(notification.id) // существующий PATCH endpoint
|
||||
|
||||
if (notification.deal_id) {
|
||||
router.push({ name: 'deals', query: { open: String(notification.deal_id) } })
|
||||
} else if (notification.event === 'reminder') {
|
||||
router.push({ name: 'reminders' })
|
||||
} else if (['low_balance', 'zero_balance', 'topup_success', 'invoice_paid'].includes(notification.event)) {
|
||||
router.push({ name: 'billing' })
|
||||
}
|
||||
// new_device_login, marketing, new_lead без deal_id — только markAsRead
|
||||
}
|
||||
```
|
||||
|
||||
`DealsView` — нужно добавить логику `query.open`: при монтировании если `route.query.open` задан → открыть DealDetailDrawer для этого deal_id. Это часть коммита #6.
|
||||
|
||||
**Тип InAppNotification** (обновить в `api/notifications.ts` если не хватает полей):
|
||||
|
||||
```typescript
|
||||
interface InAppNotification {
|
||||
id: number
|
||||
event: string
|
||||
title: string
|
||||
body: string
|
||||
deal_id: number | null
|
||||
payload: Record<string, unknown>
|
||||
read_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 ЮKassa webhook scaffold
|
||||
|
||||
#### Route
|
||||
|
||||
**Файл:** `app/routes/api.php`
|
||||
|
||||
```php
|
||||
Route::post('/webhooks/yukassa', [WebhookYukassaController::class, 'handle'])
|
||||
->name('webhooks.yukassa');
|
||||
// БЕЗ auth:sanctum и tenant middleware — внешний webhook
|
||||
```
|
||||
|
||||
#### WebhookYukassaController
|
||||
|
||||
**Файл:** `app/app/Http/Controllers/Api/WebhookYukassaController.php`
|
||||
|
||||
```php
|
||||
class WebhookYukassaController extends Controller
|
||||
{
|
||||
public function handle(Request $request, NotificationService $notifications): JsonResponse
|
||||
{
|
||||
// 1. Валидация подписи
|
||||
$secret = config('services.yukassa.secret');
|
||||
$body = $request->getContent();
|
||||
$signature = hash('sha256', $body . $secret);
|
||||
if (!hash_equals($signature, $request->header('HTTP_SHOP_ID', ''))) {
|
||||
return response()->json(['error' => 'Invalid signature'], 401);
|
||||
}
|
||||
// NOTE: точный формат подписи ЮKassa уточнить по docs.yukassa.ru при активации.
|
||||
// SHA-256(body . secret) — scaffold-реализация; заголовок HTTP_SHOP_ID предположительный.
|
||||
|
||||
// 2. Парсинг тела
|
||||
$data = json_decode($body, true);
|
||||
$event = $data['event'] ?? null;
|
||||
$invoiceId = $data['object']['metadata']['invoice_id'] ?? null;
|
||||
|
||||
if (!$invoiceId) {
|
||||
return response()->json(['error' => 'Missing invoice_id'], 422);
|
||||
}
|
||||
|
||||
$invoice = SaasInvoice::find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return response()->json(['error' => 'Invoice not found'], 404);
|
||||
}
|
||||
|
||||
// 3. Обновление статуса
|
||||
if ($event === 'payment.succeeded') {
|
||||
$invoice->update(['status' => 'paid', 'paid_at' => now()]);
|
||||
$tenant = Tenant::find($invoice->tenant_id);
|
||||
$notifications->notifyInvoicePaid($tenant, $invoice);
|
||||
} elseif ($event === 'payment.canceled') {
|
||||
$invoice->update(['status' => 'canceled']);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Конфиг
|
||||
|
||||
**Файл:** `config/services.php` — добавить:
|
||||
|
||||
```php
|
||||
'yukassa' => [
|
||||
'secret' => env('YUKASSA_SECRET_KEY', ''),
|
||||
],
|
||||
```
|
||||
|
||||
**Файл:** `.env.example` — добавить `YUKASSA_SECRET_KEY=`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase C: AdminTenantDetail (reduced)
|
||||
|
||||
### 5.1 Миграция 1: tenants.contact_phone
|
||||
|
||||
**Файл:** `app/database/migrations/XXXX_add_contact_phone_to_tenants.php`
|
||||
|
||||
```php
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->string('contact_phone', 20)->nullable()->after('contact_email');
|
||||
});
|
||||
```
|
||||
|
||||
Обновить `app/Models/Tenant.php` — добавить `contact_phone` в `$fillable`.
|
||||
|
||||
Обновить `AdminTenantsController::show()` — включить `contact_phone` в tenant base секцию ответа.
|
||||
|
||||
**Vue:** Обновить `TenantDetailHeader.vue` — показать `contact_phone` рядом с `contact_email` (через `v-if`).
|
||||
|
||||
### 5.2 Миграция 2: users.role
|
||||
|
||||
**Файл:** `app/database/migrations/XXXX_add_role_to_users.php`
|
||||
|
||||
```php
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('role', 20)->default('manager')->after('last_name');
|
||||
});
|
||||
```
|
||||
|
||||
Допустимые значения: `manager`, `owner`, `admin`. Валидация в UserController при обновлении профиля — `in:manager,owner,admin`.
|
||||
|
||||
Обновить `AdminTenantsController::show()` users секцию — заменить захардкоженный `'manager'` на `$user->role`.
|
||||
|
||||
Обновить `app/Models/User.php` — добавить `role` в `$fillable`.
|
||||
|
||||
**Vue:** Обновить `TenantDetailTabs.vue` (users tab) — брать `role` из API вместо hardcode.
|
||||
|
||||
---
|
||||
|
||||
## 6. Тестовое покрытие
|
||||
|
||||
| Phase | Тесты Pest | Тесты Vitest |
|
||||
|-------|-----------|-------------|
|
||||
| A1: 3 провайдера | +9 (3×3: headers, rows with data, rows empty) | — |
|
||||
| A2: registry | +3 (isSupported для каждого нового типа) | — |
|
||||
| A3: file download | +4 (200, 422 not ready, 410 expired, 403 wrong tenant) | — |
|
||||
| B1: new_device_login | +3 (новое устройство → уведомление; повтор → нет; preference off → нет) | — |
|
||||
| B2: deep-link bell | — | +3 (deal_id → router.push deals; reminder → reminders; billing event → billing) |
|
||||
| B3: ЮKassa webhook | +4 (payment.succeeded, payment.canceled, bad signature, unknown invoice) | — |
|
||||
| C: AdminTenantDetail | +2 (contact_phone в ответе; role из БД а не хардкод) | +1 (role отображается в TenantDetailTabs) |
|
||||
| **Итого** | **+25 Pest** | **+4 Vitest** |
|
||||
|
||||
---
|
||||
|
||||
## 7. Порядок коммитов
|
||||
|
||||
| # | Содержание | Phase |
|
||||
|---|------------|-------|
|
||||
| 1 | feat(reports): ManagersSummaryProvider + SourcesSummaryProvider + BillingSummaryProvider + registry update | A |
|
||||
| 2 | feat(reports): GET /api/reports/jobs/{id}/file download endpoint | A |
|
||||
| 3 | feat(reports): REPORT_DISK env var — S3-ready storage abstraction | A |
|
||||
| 4 | feat(auth): user_known_devices table + new_device_login notification | B |
|
||||
| 5 | feat(notifications): NewDeviceLoginMail + blade template | B |
|
||||
| 6 | feat(frontend): deep-link bell navigation on notification click | B |
|
||||
| 7 | feat(webhooks): POST /api/webhooks/yukassa scaffold + notifyInvoicePaid trigger | B |
|
||||
| 8 | feat(tenants): add contact_phone column + API + Vue | C |
|
||||
| 9 | feat(users): add role column + remove hardcoded 'manager' + Vue | C |
|
||||
|
||||
---
|
||||
|
||||
## 8. Архитектурные решения (recap)
|
||||
|
||||
| Решение | Выбор | Обоснование |
|
||||
|---------|-------|-------------|
|
||||
| S3-абстракция | `REPORT_DISK` env var → Laravel Storage disk | YAGNI; Storage facade уже абстрагирует |
|
||||
| new_device_login хранилище | Таблица `user_known_devices` | Чисто; позволит «доверенные устройства» в будущем |
|
||||
| AdminTenantDetail scope | contact_phone + role только; inn/legal_address → Б-1 | Не заблокировано; immediate value |
|
||||
| ЮKassa scope | Scaffold только (endpoint + HMAC + model update) | Боевые ключи ждут Б-1 |
|
||||
@@ -1,295 +0,0 @@
|
||||
# Дизайн интеграции Лидерра ↔ Поставщик crm.bp-gr.ru
|
||||
|
||||
**Дата:** 2026-05-10
|
||||
**Статус:** черновик дизайна (brainstorming output)
|
||||
**Заказчик:** Дмитрий (владелец Лидерры)
|
||||
|
||||
## 1. Контекст
|
||||
|
||||
Лидерра — реселлер лидов. Покупает поток номеров у поставщика [crm.bp-gr.ru](https://crm.bp-gr.ru) (бренд «BG») и перепродаёт своим тенантам через собственный портал.
|
||||
|
||||
У поставщика нет публичного REST API. Доступны:
|
||||
|
||||
- **PUSH webhook** для приёма лидов: настройка на [/admin/user/api](https://crm.bp-gr.ru/admin/user/api), payload `{vid, project, tag, phone, phones, time}`.
|
||||
- **Внутренние AJAX endpoints** для управления проектами: `rt-project-save`, `rt-project-update`, `rt-project-delete`, `rt-projects-load` и т.д. Требуют cookie-сессии и CSRF-токена.
|
||||
|
||||
## 2. Модель данных
|
||||
|
||||
### 2.1. Проект Лидерры
|
||||
|
||||
Один проект Лидерры = один сигнал-источник лидов:
|
||||
|
||||
| Тип сигнала | Идентификатор | Пример |
|
||||
|---|---|---|
|
||||
| **Сайт** | домен конкурента | `vashinvestor.ru` |
|
||||
| **Звонок** | номер конкурента (11 цифр, начинается с 7) | `7XXXXXXXXXX` |
|
||||
| **СМС** | пара (имя отправителя, ключевое слово) | sender=`TINKOFF`, keyword=`ипотека` |
|
||||
|
||||
Поля проекта:
|
||||
|
||||
- `id`, `tenant_id` (клиент Лидерры), `name`, `signal_type`, `signal_identifier`
|
||||
- `daily_limit` (число лидов в день, заказ от клиента)
|
||||
- `workdays` (массив 1–7 = Пн–Вс)
|
||||
- `regions` (массив кодов регионов РФ; пустой = все регионы)
|
||||
- `status` (`active` / `paused` / `archived`)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
Для СМС вместо одного `signal_identifier` хранится:
|
||||
|
||||
- `sms_senders[]` — массив имён отправителей (буквы / цифры до 6, не 11-значные)
|
||||
- `sms_keyword` — фраза/слово в SMS (опциональное)
|
||||
|
||||
### 2.2. Поставщик-проект
|
||||
|
||||
Уникальность у поставщика зависит от типа сигнала и платформы:
|
||||
|
||||
| Тип | Платформы | Ключ уникальности supplier-проекта |
|
||||
|---|---|---|
|
||||
| Сайт | B1, B2, B3 | `(signal_identifier)` = домен |
|
||||
| Звонок | B1, B2, B3 | `(signal_identifier)` = номер |
|
||||
| СМС | **B2** (B1 недоступен) | `(sms_senders, sms_keyword)` |
|
||||
| СМС | **B3** (B1 недоступен) | `(sms_senders)` — keyword игнорируется |
|
||||
|
||||
**Правило объединения для СМС:**
|
||||
|
||||
- На B3 все Лидерра-проекты с одним и тем же отправителем (любым keyword'ом) шарят один supplier-проект.
|
||||
- На B2 Лидерра-проекты с одним отправителем, но разными keyword'ами — отдельные supplier-проекты.
|
||||
- Если у Лидерра-клиента keyword пуст — он подключается только к B3, на B2 не идёт.
|
||||
|
||||
В Лидерре отдельная сущность `supplier_projects` (агрегатная):
|
||||
|
||||
- `id`, `platform` (`B1`/`B2`/`B3`), `signal_type`, `unique_key` (домен / номер / `sender+keyword` / `sender`)
|
||||
- `supplier_id` (внутренний id у поставщика)
|
||||
- `current_limit`, `current_workdays` (синхронизировано с поставщиком)
|
||||
- `last_synced_at`, `sync_status` (`ok` / `pending` / `failed`)
|
||||
|
||||
Маппинг N:M — один Лидерра-проект СМС может ссылаться на 1 проект B2 + 1 проект B3 (или только B3, если keyword пуст). Проекты Сайт/Звонок ссылаются на 3 supplier-проекта (B1+B2+B3).
|
||||
|
||||
### 2.3. Sharing-модель лидов
|
||||
|
||||
Один входящий лид от поставщика → потенциально создаёт несколько `deal`-записей (по одной на каждого клиента Лидерры, активного на этом источнике сегодня и не исчерпавшего квоту).
|
||||
|
||||
Каждая deal-копия:
|
||||
|
||||
- независимый статус, комментарии, история
|
||||
- ссылается на исходный `supplier_lead` (для трассировки)
|
||||
|
||||
## 3. Жизненный цикл проекта Лидерры
|
||||
|
||||
### 3.1. Создание
|
||||
|
||||
1. Клиент Лидерры в UI указывает `signal_type`, `signal_identifier`, `daily_limit`, `workdays`.
|
||||
2. Лидерра валидирует формат идентификатора:
|
||||
- **Сайт:** валидный домен (regex `^[a-z0-9-]+(\.[a-z0-9-]+)+$` нижний регистр)
|
||||
- **Звонок:** 11 цифр, начинается с `7`
|
||||
- **СМС sender:** 1–30 символов, буквы латиницы/цифры (до 6 цифр — короткий номер); 11-значные номера отвергаем (ограничение поставщика)
|
||||
- **СМС keyword:** 1–60 символов, любые символы; опционально (если пусто — используется только B3)
|
||||
3. Лидерра ищет существующий `supplier_projects` по `(signal_type, signal_identifier)`:
|
||||
- **Если есть:** просто привязывает новый Лидерра-проект к нему. Push настроек у поставщика — следующим cron'ом.
|
||||
- **Если нет:** ставит задачу `create_supplier_project` в очередь синхронизации (см. §4).
|
||||
|
||||
### 3.2. Изменение
|
||||
|
||||
Клиент может править:
|
||||
|
||||
- `daily_limit` (количество)
|
||||
- `workdays` (дни сбора)
|
||||
- `status` (active ↔ paused — пауза без удаления)
|
||||
|
||||
Все правки немедленно сохраняются в Лидерре. На поставщика — следующим cron'ом.
|
||||
|
||||
### 3.3. Удаление (архивирование)
|
||||
|
||||
Soft delete: `status = 'archived'`.
|
||||
|
||||
**Lifecycle на стороне supplier_project:**
|
||||
|
||||
- Как только у supplier_project не остаётся активных Лидерра-проектов (все паузнули/удалили) → следующим cron'ом выключаем у поставщика (`status=false` через `rt-project-update`). Запоминаем `inactive_since`.
|
||||
- Если активный клиент возвращается до истечения TTL — supplier_project переиспользуется (просто `status=true` + актуальные limit/workdays).
|
||||
- Через **180 дней** без активных клиентов supplier_project удаляется у поставщика (`rt-project-delete`) и в нашей БД.
|
||||
|
||||
### 3.4. UI-подсказка
|
||||
|
||||
В форме создания/редактирования отображается:
|
||||
|
||||
> Лиды равномерно распределяются по 3 платформам сбора. Чтобы увеличить приток — увеличьте общее количество. Изменения применятся со следующего дня.
|
||||
|
||||
## 4. Синхронизация Лидерра → Поставщик
|
||||
|
||||
### 4.1. Cron-задача в 20:30 МСК
|
||||
|
||||
Каждый день в 20:30 МСК Лидерра запускает `SyncSupplierProjects`:
|
||||
|
||||
1. Для каждого `supplier_projects` — собирает все активные Лидерра-проекты, привязанные к нему.
|
||||
2. Считает на день D+1:
|
||||
- `total_quota[day]` = сумма `daily_limit` клиентов, у которых день `D+1` в `workdays`
|
||||
- `target_workdays` = объединение всех `workdays` активных клиентов
|
||||
3. Распределяет `total_quota` по платформам:
|
||||
- **Сайт / Звонок** (3 платформы): `B1 = ⌈total/3⌉`, `B2 = ⌈(total-B1)/2⌉`, `B3 = total-B1-B2`
|
||||
- **СМС с keyword** (B2+B3): `B2 = ⌈total/2⌉`, `B3 = ⌊total/2⌋`
|
||||
- **СМС без keyword** (только B3): `B3 = total`
|
||||
- Остаток после деления отдаётся приоритетом старшим платформам (B1 → B2)
|
||||
4. Объединяет геофильтр: `regions` у поставщика = объединение `regions` всех активных Лидерра-проектов на этом источнике; `regions_reverse` всегда `false` (включающий список).
|
||||
5. Сравнивает с текущими настройками поставщика:
|
||||
- Если изменилось — POST `rt-project-update` для каждой из 3 платформ.
|
||||
- Если новый источник — POST `rt-project-save`.
|
||||
- Если активных клиентов 0 — выключение/удаление.
|
||||
6. Запас 30 минут до 21:00 МСК (дедлайн поставщика).
|
||||
|
||||
### 4.2. Cutoff для клиента
|
||||
|
||||
- До 20:30 МСК — изменения попадают в текущий cron, применятся завтра.
|
||||
- После 20:30 МСК — изменения сохраняются, в UI баннер «Применится послезавтра», следующий cron подхватит.
|
||||
- 22:00–00:00 МСК — у поставщика API заблокирован (для контекста; cron в 20:30 не попадает).
|
||||
|
||||
### 4.3. Резервирование push'а настроек
|
||||
|
||||
**Основной канал:** AJAX-автоматизация через cookie-сессию + CSRF.
|
||||
|
||||
**Поддержание сессии:** отдельный cron-job `RefreshSupplierSession` (запускается каждый час и за 15 мин до основного 20:30 cron'а):
|
||||
|
||||
1. Headless Playwright заходит на crm.bp-gr.ru.
|
||||
2. Если уже залогинен (по предыдущей cookie из Redis) — извлекает свежий CSRF-токен со страницы.
|
||||
3. Если нет — логинится с credentials из `.env` (`SUPPLIER_LOGIN`, `SUPPLIER_PASSWORD`, защищены через Laravel encrypter).
|
||||
4. Сохраняет `cookie` (PHPSESSID) и `csrf_token` в Redis с TTL 6 часов.
|
||||
5. Основной sync-cron читает их оттуда и подмешивает в HTTP-запросы к `rt-*` endpoints.
|
||||
6. При 401/403 от поставщика — Лидерра запускает `RefreshSupplierSession` внепланово.
|
||||
|
||||
**Резерв (при сбоях):**
|
||||
|
||||
1. **Очередь pending-changes**: каждое неуспешное обновление помещается в очередь, повтор каждые 10 минут до 21:00.
|
||||
2. **Алерт менеджеру**: если очередь не пуста к 21:00 — уведомление в Telegram/email с инструкцией ручной правки через UI поставщика.
|
||||
3. **Откат к предыдущему успешному состоянию**: если новые настройки не дошли — клиенты Лидерры работают на вчерашних квотах (известное состояние, никакой потери).
|
||||
4. **Health-check сессии**: перед cron'ом проверяем `rt-projects-load` — если 401/403, заранее обновляем cookie/CSRF.
|
||||
|
||||
## 5. Приём лидов Поставщик → Лидерра
|
||||
|
||||
### 5.1. Основной канал — webhook
|
||||
|
||||
Поставщик POST'ит на `https://liderra.ru/webhook/{tenant_token}` (общий URL, не per-client).
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"vid": 432176649,
|
||||
"project": "B1_vashinvestor.ru",
|
||||
"tag": "Ваш инвестор",
|
||||
"phone": "7XXXXXXXXXX",
|
||||
"phones": ["7XXXXXXXXXX"],
|
||||
"time": 1703781939
|
||||
}
|
||||
```
|
||||
|
||||
Обработчик (`HandleSupplierWebhook`):
|
||||
|
||||
1. Проверяет защиту в два слоя:
|
||||
- **IP allowlist** — запрос отвергается, если IP отправителя не из списка известных IP поставщика (хранится в `settings.supplier_ip_allowlist`, обновляется админом).
|
||||
- **Секретный токен в URL** — `tenant_token` это длинный случайный секрет (≥32 символа), генерируется при регистрации интеграции; известен только Лидерре и поставщику. Неверный токен → 404 (не 403, чтобы не палить существование endpoint'а).
|
||||
2. Парсит `project` → определяет `(signal_type, signal_identifier)`:
|
||||
- префикс `B1_` / `B2_` / `B3_` → B-платформа
|
||||
- остальная часть → identifier
|
||||
3. Сохраняет `supplier_lead` (raw payload + метаданные).
|
||||
4. Запускает `RouteLead` (см. §6).
|
||||
5. Возвращает 200 OK.
|
||||
|
||||
### 5.2. Резерв — CSV reconciliation
|
||||
|
||||
**Раз в час** (или 4 раза в день) Лидерра запрашивает CSV-экспорт `/admin/report/index?type=49` (или archive endpoint) за последние сутки и сверяет:
|
||||
|
||||
1. Список `vid` из CSV vs `supplier_leads` в БД.
|
||||
2. Если в CSV есть `vid`, которого нет у нас — pending: восстанавливаем с тем же `RouteLead` flow (но помечаем как `recovered_from_csv`).
|
||||
3. Если расхождение > 5% за день — алерт менеджеру.
|
||||
|
||||
Это даёт стабильность даже при кратковременных сбоях нашего webhook'а.
|
||||
|
||||
## 6. Routing к клиентам Лидерры
|
||||
|
||||
При поступлении лида (`RouteLead`):
|
||||
|
||||
1. Находим `supplier_projects` по `(signal_type, signal_identifier)`.
|
||||
2. Определяем регион номера по коду телефона (справочник DEF-кодов).
|
||||
3. Берём список Лидерра-проектов на этом источнике с условиями:
|
||||
- `status = 'active'`
|
||||
- сегодняшний день в `workdays`
|
||||
- не превышен дневной счётчик: `delivered_today < daily_limit`
|
||||
- регион номера попадает в `regions` клиента (или `regions` пуст = все)
|
||||
4. Сортируем по `created_at ASC` (старые проекты первыми — детерминированный порядок).
|
||||
5. Для каждого подходящего проекта:
|
||||
- Создаём `deal` с независимой копией данных лида.
|
||||
- Инкрементируем `delivered_today` и `delivered_in_month`.
|
||||
- Списываем с баланса по текущей ступени `pricing_tiers`.
|
||||
- Шлём уведомление клиенту по его настройкам.
|
||||
6. Один и тот же лид → может попасть N клиентам (sharing).
|
||||
7. Если клиент уже исчерпал квоту — пропускаем.
|
||||
|
||||
### 6.1. Reset счётчиков
|
||||
|
||||
Cron в 00:00 МСК сбрасывает `delivered_today = 0` для всех Лидерра-проектов.
|
||||
|
||||
## 7. Биллинг и тарифы клиентам Лидерры
|
||||
|
||||
### 7.1. Закупка у поставщика
|
||||
|
||||
Поставщик берёт **фиксированную цену** за лид (значение хранится в `settings`, поле редактируемое админом Лидерры).
|
||||
|
||||
### 7.2. Тариф для клиентов Лидерры — ступенчатый (volume tiers)
|
||||
|
||||
Клиент Лидерры платит по ступенчатой схеме: чем больше лидов получено в текущем месяце — тем дешевле каждый следующий.
|
||||
|
||||
**Структура** (таблица `pricing_tiers`, конфигурируется админом Лидерры):
|
||||
|
||||
- `tier_no` — номер ступени (1..7)
|
||||
- `leads_in_tier` — сколько лидов покрывает эта ступень (для последней — может быть `NULL` = «всё свыше»)
|
||||
- `price_per_lead` — цена за один лид в рублях
|
||||
|
||||
И объём ступени, и цена редактируются в админке Лидерры. Изменения тарифной сетки применяются с 1-го числа следующего месяца (кроме ситуации когда изменение увеличивает цену — тут TBD по бизнесу).
|
||||
|
||||
### 7.3. Логика начисления
|
||||
|
||||
- Счётчик `delivered_in_month` для каждого `(tenant_id)` обнуляется автоматически 1-го числа каждого месяца в 00:00 МСК.
|
||||
- При доставке очередного лида: `delivered_in_month++`, цена этого лида = `price_per_lead` той ступени, в которую попадает текущий `delivered_in_month`.
|
||||
- Списание с баланса клиента происходит немедленно по доставке (атомарно с созданием `deal`).
|
||||
- Если баланс недостаточен — лид не доставляется, проект автоматически переходит в `paused` до пополнения; уведомление клиенту.
|
||||
|
||||
### 7.4. История
|
||||
|
||||
Хранится в таблице `lead_charges`: `tenant_id, deal_id, tier_no, price_per_lead, charged_at`. Используется для отчётов клиенту и аудита.
|
||||
|
||||
## 8. Бизнес-правила и инварианты
|
||||
|
||||
- **Источников у поставщика на одного `(signal_type, signal_identifier)` всегда ровно 3** (B1/B2/B3).
|
||||
- **Лимит у поставщика равен `Total/3` с приоритетом остатка B1→B2.**
|
||||
- **Workdays у поставщика = объединение workdays активных Лидерра-клиентов.**
|
||||
- **Если у `supplier_project` 0 активных Лидерра-проектов — выключаем у поставщика, не удаляем сразу** (теплый кэш на случай быстрого возврата).
|
||||
|
||||
## 9. Схема БД (предварительно)
|
||||
|
||||
Новые таблицы:
|
||||
|
||||
- `signal_sources` (источники: домены/номера/SMS-тексты)
|
||||
- `liderra_projects` (проекты клиентов Лидерры)
|
||||
- `supplier_projects` (агрегатное состояние у поставщика)
|
||||
- `supplier_leads` (raw payload от поставщика)
|
||||
- `liderra_project_deliveries` (счётчики delivered_today, history)
|
||||
- `supplier_sync_jobs` (очередь и история синхронизаций)
|
||||
|
||||
Существующая таблица `deals` дополняется FK на `liderra_project` и `supplier_lead`.
|
||||
|
||||
Точная DDL — отдельным шагом после утверждения дизайна.
|
||||
|
||||
## 10. Открытые вопросы
|
||||
|
||||
| # | Вопрос | Кто решает |
|
||||
|---|---|---|
|
||||
| 1 | ~~Правила валидации SMS-содержания~~ | ✅ закрыт 10.05.2026 (см. §2.1, §3.1) |
|
||||
| 2 | ~~Цена за лид от поставщика — фиксирована или по типу B1/B2/B3?~~ | ✅ закрыт 10.05.2026: фикс. (см. §7.1) |
|
||||
| 3 | ~~Как Лидерра билит клиентов: за каждый delivered лид или подписочно?~~ | ✅ закрыт 10.05.2026: 7-ступенчатый volume tier, месячный сброс (см. §7.2–7.4) |
|
||||
| 4 | ~~Геофильтр (регионы) — поддерживаем уже сейчас или v2?~~ | ✅ закрыт 10.05.2026: MVP, объединение на supplier + фильтр на нашей стороне (см. §2.1, §4.1, §6) |
|
||||
| 5 | ~~HMAC-подпись поставщика — есть или только IP allowlist?~~ | ✅ закрыт 10.05.2026: defense-in-depth (IP allowlist + секретный токен в URL, см. §5.1) |
|
||||
| 6 | ~~Точный механизм cookie/CSRF refresh для AJAX-автоматизации~~ | ✅ закрыт 10.05.2026: headless Playwright cron каждый час + по 401/403 (см. §4.3) |
|
||||
| 7 | ~~TTL для «теплого кэша» выключенных supplier_projects~~ | ✅ закрыт 10.05.2026: сразу выключение, удаление через 180 дней (см. §3.3) |
|
||||
|
||||
## 11. План реализации
|
||||
|
||||
После подтверждения дизайна — следующий шаг через skill `writing-plans`: декомпозиция на спринты, schema-миграции, контроллеры, jobs, тесты.
|
||||
@@ -1,618 +0,0 @@
|
||||
# Plan 3 (Supplier Sync) — Implementation Design
|
||||
|
||||
**Дата:** 2026-05-11
|
||||
**Статус:** черновик дизайна (brainstorming output, готов к writing-plans)
|
||||
**Заказчик:** Дмитрий (владелец Лидерры)
|
||||
**Parent spec:** [2026-05-10-supplier-integration-design.md](2026-05-10-supplier-integration-design.md) (общий дизайн интеграции Лидерра ↔ crm.bp-gr.ru)
|
||||
**Зависит от:** Plan 1 (Foundation) `001d781` + Plan 2 (Webhook+Routing) `d5aa972` + Plan 2.5 (concurrency+retry hotfix) `c1ae195`+`1ba1df8` + Plan 2.6 (cleanup CV.11) `7899071`
|
||||
**Назначение:** implementation-уровень дизайна для Plan 3 — конкретные компоненты, data flow, error handling, testing strategy. Закрывает BLOCKER #6 (`failed_webhook_jobs` RLS NULL tenant) + WARN #2/#3 (LeadRouter/ResetCmd под `crm_app_user` без tenant-context) + добавляет supplier sync (Playwright session + 20:30 МСК cron + AJAX rt-* + 180d cleanup + автоматический retry).
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и связь с parent spec
|
||||
|
||||
Plan 1 + Plan 2 + Plan 2.5 + Plan 2.6 закрыли направление **поставщик → Лидерра** (приём входящих лидов через webhook, sharing-routing, RLS-изоляция, idempotency, operational deploy gates). Plan 3 закрывает направление **Лидерра → поставщик** (управление supplier-проектами через AJAX-эмуляцию личного кабинета), плюс закрывает перетекающий backlog Plan 2.6: BLOCKER #6 и архитектурные WARN #2/#3 о работе jobs под `crm_app_user`.
|
||||
|
||||
**Scope Plan 3:** полный sync-блок без CSV reconcile.
|
||||
|
||||
- ✅ BLOCKER #6 закрывается через переключение supplier-flow на `crm_supplier_worker` BYPASSRLS-роль (создана в Plan 2.6 #iv `7899071`).
|
||||
- ✅ WARN #2 (LeadRouter под crm_app_user не видит tenant projects) — закрывается тем же переключением.
|
||||
- ✅ WARN #3 (ResetDeliveredTodayCommand тот же) — закрывается тем же переключением.
|
||||
- ✅ Playwright session manager + Redis cache cookie/CSRF.
|
||||
- ✅ 20:30 МСК cron `SyncSupplierProjectsJob` с distribution-логикой B1/B2/B3.
|
||||
- ✅ 180d cleanup cron `CleanupInactiveSupplierProjectsJob`.
|
||||
- ✅ Автоматический retry failed webhook jobs (Console command + hourly cron).
|
||||
|
||||
**Out of Plan 3 scope (перенесено):** CSV reconciliation (parent spec §5.2) → Plan 4 (billing/tariffs); frontend forms (signal-type wizards, region-picker) → Plan 5.
|
||||
|
||||
**Архитектурное решение по BLOCKER #6 — вариант C (BYPASSRLS-role):** согласовано с заказчиком 2026-05-11 в brainstorming-сессии. Альтернативы A (schema bump v8.18→v8.19 с INSERT WITH CHECK (true) policy) и B (отдельная SaaS-таблица supplier_failed_jobs) отвергнуты: C даёт 0 schema changes, 1 fix закрывает 3 элемента backlog'а, согласуется с архитектурным направлением Plan 2.6 #iv brainstorm-выбора (вариант C из 3 опций для queue worker).
|
||||
|
||||
**Архитектурное решение по headless browser — Playwright real (Node.js subprocess):** согласовано с заказчиком 2026-05-11. Альтернативы chrome-php (PHP-only) и Guzzle (без headless) отвергнуты: Playwright real даёт страховку при изменениях на стороне поставщика (Cloudflare/reCAPTCHA/JS-login/2FA в будущем), кросс-платформенно (Linux CI prod + Windows native dev через MCP), supported Microsoft.
|
||||
|
||||
**Discovery подход — вариант А с явным локальным снятием Pravila §6:** согласовано с заказчиком 2026-05-11. Я через `mcp__playwright__browser_*` MCP-tools залогинюсь в crm.bp-gr.ru с credentials заказчика (передаются в `app/.env` через Laravel encrypter, НЕ plaintext в чат), создам тестовый проект `__claude_probe_<timestamp>`, запишу 5 HTTP-фиксаций (login + projects-load + project-save + project-update + project-delete), удалю тестовый проект, сохраню фиксации как baseline.
|
||||
|
||||
---
|
||||
|
||||
## 2. Архитектура — 9 Tasks, 2 фазы, параллелизм
|
||||
|
||||
### 2.1. Фаза I — Discovery (Tasks 1–2), блокирует фазу II в основной части
|
||||
|
||||
| Task | Файлы | Зависит от |
|
||||
|---|---|---|
|
||||
| **Task 1 — Discovery через Playwright MCP** | `app/tests/Fixtures/SupplierPortal/*.json` (5 fixtures) | Credentials поставщика в `app/.env` (заказчик передаёт перед стартом) |
|
||||
| **Task 2 — Spec update v1.0 → v1.1** | [2026-05-10-supplier-integration-design.md](2026-05-10-supplier-integration-design.md) §4.4 «AJAX endpoints — observed formats» | Task 1 |
|
||||
|
||||
**Stop-gate после Task 2:** ждём заказчика «ок, формат разобран корректно» перед переходом в фазу II основной частью.
|
||||
|
||||
### 2.2. Фаза II — Implementation (Tasks 3–9)
|
||||
|
||||
| Task | Назначение | Зависит от |
|
||||
|---|---|---|
|
||||
| **Task 3 — Switch supplier-flow на pgsql_supplier (BYPASSRLS)** | Закрывает BLOCKER #6 + WARN #2 + WARN #3 | Task 1 (параллельно — не зависит от discovery, чисто DB-connection change) |
|
||||
| **Task 4 — SupplierPortalClient** | HTTP-клиент над rt-* endpoints | Task 2 (нужны fixtures), Task 3 (использует pgsql_supplier connection) |
|
||||
| **Task 5 — RefreshSupplierSessionJob + PlaywrightBridge** | Headless login + Redis cache cookie/CSRF | Task 2, Task 4 |
|
||||
| **Task 6 — SyncSupplierProjectsJob + SupplierQuotaAllocator** | 20:30 МСК cron, distribution B1/B2/B3 | Tasks 4, 5 |
|
||||
| **Task 7 — CleanupInactiveSupplierProjectsJob** | Daily 02:00 МСК cron, Phase A re-activate → B mark inactive → C delete 180d | Tasks 4, 5 |
|
||||
| **Task 8 — RetryFailedSupplierJobsCommand** | Console + hourly cron, авто-восстановление от transient outage | Task 3 |
|
||||
| **Task 9 — E2E Integration test (Linux CI only)** | Mock supplier-server → полный flow | Tasks 3–8 |
|
||||
|
||||
**Параллелизм Task 1 ‖ Task 3:** discovery (Task 1) — отдельная ветка работы (HTTP-эксплорация поставщика), Task 3 — code change (DB connection switch в 3 файлах). Независимы. Можно идти в разных commit-сериях, но code-review subagent для Task 3 уходит до начала Task 4 (SupplierPortalClient зависит от Task 3 connection).
|
||||
|
||||
### 2.3. 0 schema changes
|
||||
|
||||
Проверено [db/schema.sql:889](../../db/schema.sql#L889) — колонка `inactive_since TIMESTAMPTZ NULL` уже добавлена в v8.13 (Plan 1/5 Task 2). Индекс [db/schema.sql:908-909](../../db/schema.sql#L908-L909) `supplier_projects_inactive_since_index` тоже есть. Plan 3 — code-only, schema.sql не трогается. Это понижает риск ошибки: меньше surface for breakage, не трогаем RLS-политики, не нужен `migrate:fresh` + проверка метрик.
|
||||
|
||||
Готовые элементы schema, используемые Plan 3:
|
||||
|
||||
- [db/schema.sql:877-902](../../db/schema.sql#L877) — таблица `supplier_projects` (платформа, signal_type, unique_key, supplier_external_id, current_limit, current_workdays JSONB, current_regions JSONB, sync_status pending/ok/failed, last_synced_at, inactive_since, CHECK constraints).
|
||||
- [db/schema.sql:904-905](../../db/schema.sql#L904) — UNIQUE INDEX `supplier_projects_platform_unique_key_unique (platform, unique_key)` — защита от race на create.
|
||||
- [db/schema.sql:1819](../../db/schema.sql#L1819) — `supplier_sync_log` SaaS-level audit log.
|
||||
- [db/00_create_roles.sql:70-73](../../db/00_create_roles.sql#L70) — `crm_supplier_worker` BYPASSRLS-роль v1.1.
|
||||
|
||||
---
|
||||
|
||||
## 3. Компоненты — файлы и изменения
|
||||
|
||||
### 3.1. Новый PG-connection
|
||||
|
||||
**Файл [app/config/database.php](../../app/config/database.php):** добавить ключ `pgsql_supplier` рядом с существующим `pgsql`:
|
||||
|
||||
```php
|
||||
'pgsql_supplier' => array_merge(
|
||||
config('database.connections.pgsql'),
|
||||
[
|
||||
'username' => env('DB_SUPPLIER_USERNAME', 'crm_supplier_worker'),
|
||||
'password' => env('DB_SUPPLIER_PASSWORD'),
|
||||
]
|
||||
),
|
||||
```
|
||||
|
||||
На dev (Windows native, `postgres` superuser) — env'ы могут совпадать с основными `DB_USERNAME`/`DB_PASSWORD`. На prod — отдельная роль `crm_supplier_worker` с BYPASSRLS.
|
||||
|
||||
### 3.2. Изменения существующих файлов (Task 3)
|
||||
|
||||
| Файл | Изменение |
|
||||
|---|---|
|
||||
| [app/app/Jobs/RouteSupplierLeadJob.php](../../app/app/Jobs/RouteSupplierLeadJob.php) | `protected $connection = 'pgsql_supplier'` на уровне класса. INSERT в `failed_webhook_jobs` в `failed()` callback автоматически идёт под BYPASSRLS → NULL `tenant_id` проходит. Inline-warnings lines 258-263 удаляем. |
|
||||
| [app/app/Services/LeadRouter.php](../../app/app/Services/LeadRouter.php) | `Project::on('pgsql_supplier')->where(...)` в `matchEligibleProjects`. Inline-warnings lines 25-29 удаляем. |
|
||||
| [app/app/Console/Commands/ResetDeliveredTodayCommand.php](../../app/app/Console/Commands/ResetDeliveredTodayCommand.php) | `Project::on('pgsql_supplier')->where('delivered_today', '!=', 0)->update(['delivered_today' => 0])`. Inline-warnings lines 16-18 удаляем. |
|
||||
| `.env.example` | + `DB_SUPPLIER_USERNAME=crm_supplier_worker` + `DB_SUPPLIER_PASSWORD=` + `SUPPLIER_LOGIN=` + `SUPPLIER_PASSWORD=` + `SUPPLIER_PORTAL_URL=https://crm.bp-gr.ru` + `SUPPLIER_ALERT_EMAIL=` |
|
||||
|
||||
### 3.3. Новые файлы
|
||||
|
||||
```
|
||||
app/app/Services/Supplier/
|
||||
├── SupplierPortalClient.php # HTTP-клиент над rt-*, читает cookie/CSRF из Redis
|
||||
├── SupplierQuotaAllocator.php # Distribution-логика B1/B2/B3 (pure function)
|
||||
├── PlaywrightBridge.php # Spawn Node.js subprocess, parse stdout JSON
|
||||
└── Dto/
|
||||
└── SupplierProjectDto.php # Read-only DTO: platform, signal_type, unique_key, limit, workdays, regions, regions_reverse, status
|
||||
|
||||
app/app/Exceptions/Supplier/
|
||||
├── SupplierException.php # abstract base
|
||||
├── SupplierAuthException.php # 401/403 sticky после refresh-retry
|
||||
├── SupplierTransientException.php # 5xx, network, timeout
|
||||
└── SupplierClientException.php # 4xx 400/404/422 (наша ошибка payload'а)
|
||||
|
||||
app/app/Jobs/Supplier/
|
||||
├── RefreshSupplierSessionJob.php # Spawn PlaywrightBridge, put session в Redis 6h TTL
|
||||
├── SyncSupplierProjectsJob.php # 20:30 МСК cron, per-supplier_project failure isolation
|
||||
└── CleanupInactiveSupplierProjectsJob.php # Daily 02:00 МСК, Phase A→B→C
|
||||
|
||||
app/app/Console/Commands/
|
||||
├── SupplierSessionRefreshCommand.php # supplier:session:refresh (ручной запуск)
|
||||
└── RetryFailedSupplierJobsCommand.php # supplier:retry-failed (hourly cron + лимит 10/24h)
|
||||
|
||||
app/app/Mail/
|
||||
└── SupplierCriticalAlertMail.php # Mailable для critical alert: subject + body + details
|
||||
|
||||
app/playwright/
|
||||
├── package.json # playwright npm dep (изолировано от app/package.json)
|
||||
└── refresh-session.js # ~50 строк Node, stdin {login, password, url} → stdout {phpsessid, csrf, refreshed_at}
|
||||
|
||||
app/tests/Fixtures/SupplierPortal/ # из Task 1 discovery
|
||||
├── login.json
|
||||
├── projects-load.json
|
||||
├── project-save.json
|
||||
├── project-update.json
|
||||
└── project-delete.json
|
||||
```
|
||||
|
||||
### 3.4. Schedule entries
|
||||
|
||||
[app/routes/console.php](../../app/routes/console.php) (Laravel 13 syntax):
|
||||
|
||||
```php
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||||
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
|
||||
use App\Console\Commands\RetryFailedSupplierJobsCommand;
|
||||
|
||||
Schedule::job(new RefreshSupplierSessionJob)->hourly()->onOneServer();
|
||||
Schedule::job(new RefreshSupplierSessionJob)->dailyAt('20:15')->timezone('Europe/Moscow')->onOneServer();
|
||||
Schedule::job(new SyncSupplierProjectsJob)->dailyAt('20:30')->timezone('Europe/Moscow')->onOneServer();
|
||||
Schedule::job(new CleanupInactiveSupplierProjectsJob)->dailyAt('02:00')->timezone('Europe/Moscow')->onOneServer();
|
||||
Schedule::command('supplier:retry-failed')->hourly()->onOneServer();
|
||||
```
|
||||
|
||||
`onOneServer()` требует `cache_locks` таблицу в схеме. Plan 2 deferred WARNING #5 — таблица должна быть добавлена при необходимости. Plan 3 это явно реализует (либо `php artisan cache:table` + миграция в schema.sql v8.18, либо использовать существующий Redis driver через `Cache::store('redis')` — простая опция).
|
||||
|
||||
**Решение:** использовать Redis driver для `onOneServer()` через `RATELIMITER`/`CACHE_STORE` env. Не требует таблицы. Это уточняем в Task 6/7 при implementation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data flow
|
||||
|
||||
### 4.1. Lead routing flow (после Task 3, при поступлении входящего лида)
|
||||
|
||||
```
|
||||
POST /api/webhook/supplier/{secret}
|
||||
↓ SupplierWebhookController::receive (под crm_app_user, RLS-active — НЕ меняется)
|
||||
↓ verifySecret + verifyIpAllowlist + timestamp ±24h validation
|
||||
↓ supplier_leads.insert (raw payload, без tenant)
|
||||
↓ dispatch RouteSupplierLeadJob (queue, processed-async)
|
||||
↓
|
||||
RouteSupplierLeadJob::handle (под crm_supplier_worker BYPASSRLS — НОВОЕ Task 3)
|
||||
↓ guard processed_at IS NULL (idempotency, fix Plan 2.5 #3)
|
||||
↓ LeadRouter::matchEligibleProjects(...) — НА pgsql_supplier (НОВОЕ Task 3)
|
||||
↓ ← теперь видит все tenant projects через BYPASSRLS (WARN #2 закрыт)
|
||||
↓ foreach project:
|
||||
↓ lockForUpdate(Tenant) (Plan 2.5 #2)
|
||||
↓ lockForUpdate(Project) + recheck delivered_today (Plan 2.5 #2)
|
||||
↓ createDealCopyForProject + LeadCharge + delivered_today++
|
||||
↓ SET LOCAL app.current_tenant_id = $project->tenant_id ← defense-in-depth
|
||||
↓ supplier_lead.update processed_at=NOW(), deals_created_count=N
|
||||
↓ если упало 3 раза → failed_webhook_jobs.insert(tenant_id=NULL) ← BLOCKER #6 ЗАКРЫТ
|
||||
↓ ← BYPASSRLS пропускает NULL, no silent fail
|
||||
```
|
||||
|
||||
Минимальное изменение: `protected $connection = 'pgsql_supplier'` в 3 классах. Бизнес-логика не меняется.
|
||||
|
||||
### 4.2. Session refresh flow (Task 5)
|
||||
|
||||
```
|
||||
Triggers:
|
||||
[1] Schedule hourly (routes/console.php)
|
||||
[2] Schedule daily 20:15 МСК (15 мин до sync deadline)
|
||||
[3] SupplierPortalClient получил 401/403 → dispatch_sync(RefreshSupplierSessionJob)
|
||||
|
||||
RefreshSupplierSessionJob::handle
|
||||
↓ Cache::lock('supplier:session:refresh', 30)->block(35, function() { ... })
|
||||
↓ # защита от concurrent refresh; ждёт до 35s если другой воркер уже рефрешит
|
||||
↓ PlaywrightBridge::refreshSession()
|
||||
↓ spawn `node app/playwright/refresh-session.js` через Symfony\Process
|
||||
↓ stdin (JSON):
|
||||
↓ {login: env(SUPPLIER_LOGIN), password: env(SUPPLIER_PASSWORD), url: env(SUPPLIER_PORTAL_URL)}
|
||||
↓ Node-скрипт (~50 lines):
|
||||
↓ const {chromium} = require('playwright');
|
||||
↓ const browser = await chromium.launch({headless: true});
|
||||
↓ const page = await browser.newPage();
|
||||
↓ await page.goto(args.url);
|
||||
↓ await page.fill('input[name=login]', args.login);
|
||||
↓ await page.fill('input[name=password]', args.password);
|
||||
↓ await page.click('button[type=submit]');
|
||||
↓ await page.waitForLoadState('networkidle');
|
||||
↓ const csrf = await page.locator('meta[name=csrf-token]').getAttribute('content');
|
||||
↓ const cookies = await page.context().cookies();
|
||||
↓ const phpsessid = cookies.find(c => c.name === 'PHPSESSID')?.value;
|
||||
↓ await browser.close();
|
||||
↓ process.stdout.write(JSON.stringify({phpsessid, csrf, refreshed_at: Date.now()}));
|
||||
↓ timeout 60s; non-zero exit → throw SupplierAuthException
|
||||
↓ Cache::store('redis')->put('supplier:session', $data, now()->addHours(6))
|
||||
↓ Log::info('supplier.session.refreshed', ['ttl' => 21600])
|
||||
```
|
||||
|
||||
DOM-селекторы `input[name=login]`, `meta[name=csrf-token]` — placeholder'ы; точные селекторы будут известны после Task 1 discovery и могут потребовать корректировку refresh-session.js.
|
||||
|
||||
### 4.3. Sync flow (Task 6, 20:30 МСК cron)
|
||||
|
||||
```
|
||||
Schedule::job(SyncSupplierProjectsJob)->dailyAt('20:30')->timezone('Europe/Moscow')->onOneServer()
|
||||
|
||||
SyncSupplierProjectsJob::handle (под crm_supplier_worker BYPASSRLS)
|
||||
↓ $consecutiveTransientCount = 0;
|
||||
↓ foreach SupplierProject::query()->whereNull('deleted_at')->cursor():
|
||||
↓ $startTime = now();
|
||||
↓ if ($startTime->copy()->timezone('Europe/Moscow')->format('H:i') >= '20:55') {
|
||||
↓ Log::warning('supplier.sync.time_budget_reached');
|
||||
↓ break; // 5-мин запас до 21:00 deadline
|
||||
↓ }
|
||||
↓ try {
|
||||
↓ $activeProjects = $supplierProject->liderraProjects()
|
||||
↓ ->where('status', 'active')
|
||||
↓ ->get();
|
||||
↓ if ($activeProjects->isEmpty()) {
|
||||
↓ continue; // CleanupJob отдельно пометит inactive_since
|
||||
↓ }
|
||||
↓ $allocation = SupplierQuotaAllocator::allocate(
|
||||
↓ platform: $supplierProject->platform,
|
||||
↓ signalType: $supplierProject->signal_type,
|
||||
↓ liderraProjects: $activeProjects,
|
||||
↓ targetDate: Carbon::tomorrow('Europe/Moscow'),
|
||||
↓ );
|
||||
↓ $current = SupplierProjectDto::fromModel($supplierProject);
|
||||
↓ if ($allocation->equals($current)) {
|
||||
↓ continue; // no diff, save API call
|
||||
↓ }
|
||||
↓ DB::transaction(function() use ($supplierProject, $allocation) {
|
||||
↓ if ($supplierProject->supplier_external_id === null) {
|
||||
↓ $externalId = $portalClient->saveProject($allocation);
|
||||
↓ $supplierProject->update([
|
||||
↓ 'supplier_external_id' => $externalId,
|
||||
↓ 'current_limit' => $allocation->limit,
|
||||
↓ 'current_workdays' => $allocation->workdays,
|
||||
↓ 'current_regions' => $allocation->regions,
|
||||
↓ 'sync_status' => 'ok',
|
||||
↓ 'last_synced_at' => now(),
|
||||
↓ ]);
|
||||
↓ } else {
|
||||
↓ $portalClient->updateProject($supplierProject->supplier_external_id, $allocation);
|
||||
↓ $supplierProject->update([
|
||||
↓ 'current_limit' => $allocation->limit,
|
||||
↓ 'current_workdays' => $allocation->workdays,
|
||||
↓ 'current_regions' => $allocation->regions,
|
||||
↓ 'sync_status' => 'ok',
|
||||
↓ 'last_synced_at' => now(),
|
||||
↓ ]);
|
||||
↓ }
|
||||
↓ SupplierSyncLog::create([
|
||||
↓ 'supplier_project_id' => $supplierProject->id,
|
||||
↓ 'action' => $supplierProject->wasRecentlyCreated ? 'save' : 'update',
|
||||
↓ 'status' => 'ok',
|
||||
↓ ]);
|
||||
↓ });
|
||||
↓ $consecutiveTransientCount = 0;
|
||||
↓ } catch (SupplierAuthException $e) {
|
||||
↓ Mail::to(config('services.supplier.alert_email'))->queue(new SupplierCriticalAlertMail('auth_sticky', $e));
|
||||
↓ report($e);
|
||||
↓ throw $e; // фатально, останавливает весь job
|
||||
↓ } catch (SupplierTransientException $e) {
|
||||
↓ $consecutiveTransientCount++;
|
||||
↓ SupplierSyncLog::create([
|
||||
↓ 'supplier_project_id' => $supplierProject->id,
|
||||
↓ 'action' => 'update',
|
||||
↓ 'status' => 'failed',
|
||||
↓ 'error' => substr($e->getMessage(), 0, 500),
|
||||
↓ ]);
|
||||
↓ if ($consecutiveTransientCount >= 50) {
|
||||
↓ Mail::to(config('services.supplier.alert_email'))->queue(new SupplierCriticalAlertMail('mass_transient', $e));
|
||||
↓ report(new \RuntimeException('Supplier outage suspected: 50 consecutive transient failures'));
|
||||
↓ break;
|
||||
↓ }
|
||||
↓ continue;
|
||||
↓ } catch (SupplierClientException $e) {
|
||||
↓ SupplierSyncLog::create([
|
||||
↓ 'supplier_project_id' => $supplierProject->id,
|
||||
↓ 'action' => 'update',
|
||||
↓ 'status' => 'failed',
|
||||
↓ 'error' => substr($e->getMessage(), 0, 500),
|
||||
↓ ]);
|
||||
↓ report($e); // warning to Sentry
|
||||
↓ continue; // одна bad payload не валит остальных
|
||||
↓ }
|
||||
```
|
||||
|
||||
**Failure-isolation per supplier_project:** один сбой не валит партию. Согласуется с Plan 2 Task 6 `605c457` per-Project isolation.
|
||||
|
||||
**Mass-fail mitigation:** 50 подряд `SupplierTransientException` → abort + email + Sentry.
|
||||
|
||||
**Time budget:** 20:30–21:00 = 30 мин до deadline'а поставщика, 5-мин safety margin до 20:55.
|
||||
|
||||
### 4.4. Cleanup flow (Task 7, daily 02:00 МСК) — Phase A → B → C
|
||||
|
||||
**Критический порядок фаз** (исправление черновика — порядок важен для safety):
|
||||
|
||||
```
|
||||
CleanupInactiveSupplierProjectsJob::handle (под crm_supplier_worker)
|
||||
↓ Phase A — re-activate (СНАЧАЛА, чтобы Phase C не удалила недавно вернувшихся):
|
||||
↓ UPDATE supplier_projects SET inactive_since=NULL
|
||||
↓ WHERE inactive_since IS NOT NULL
|
||||
↓ AND id IN (
|
||||
↓ SELECT DISTINCT supplier_b1_project_id FROM projects WHERE status='active' AND supplier_b1_project_id IS NOT NULL
|
||||
↓ UNION SELECT DISTINCT supplier_b2_project_id FROM projects WHERE status='active' AND supplier_b2_project_id IS NOT NULL
|
||||
↓ UNION SELECT DISTINCT supplier_b3_project_id FROM projects WHERE status='active' AND supplier_b3_project_id IS NOT NULL
|
||||
↓ )
|
||||
↓
|
||||
↓ Phase B — mark new inactive:
|
||||
↓ UPDATE supplier_projects SET inactive_since=NOW()
|
||||
↓ WHERE inactive_since IS NULL
|
||||
↓ AND id NOT IN ( … тот же DISTINCT SELECT … )
|
||||
↓
|
||||
↓ Phase C — delete 180d-old:
|
||||
↓ foreach SupplierProject::where('inactive_since', '<', now()->subDays(180))->cursor():
|
||||
↓ try {
|
||||
↓ if ($sp->supplier_external_id) {
|
||||
↓ $portalClient->deleteProject($sp->supplier_external_id);
|
||||
↓ }
|
||||
↓ $sp->delete(); // soft-delete если SoftDeletes trait; иначе forceDelete
|
||||
↓ SupplierSyncLog::create([
|
||||
↓ 'supplier_project_id' => $sp->id,
|
||||
↓ 'action' => 'delete',
|
||||
↓ 'status' => 'ok',
|
||||
↓ ]);
|
||||
↓ } catch (SupplierClientException $e) {
|
||||
↓ // 404 от поставщика = уже удалён → продолжаем с локальным soft-delete
|
||||
↓ if ($e->httpStatus === 404) {
|
||||
↓ $sp->delete();
|
||||
↓ SupplierSyncLog::create([
|
||||
↓ 'supplier_project_id' => $sp->id,
|
||||
↓ 'action' => 'delete',
|
||||
↓ 'status' => 'ok',
|
||||
↓ 'note' => 'supplier returned 404 (already deleted)',
|
||||
↓ ]);
|
||||
↓ } else {
|
||||
↓ SupplierSyncLog::create([...status=failed]);
|
||||
↓ report($e);
|
||||
↓ continue;
|
||||
↓ }
|
||||
↓ } catch (SupplierTransientException $e) {
|
||||
↓ SupplierSyncLog::create([...status=failed]);
|
||||
↓ continue; // retry завтра
|
||||
↓ }
|
||||
```
|
||||
|
||||
**Safety property:** если Phase A не отработает по ошибке → Phase C не удалит активно используемые supplier_projects, потому что Phase C select-критерий — `inactive_since < NOW() - INTERVAL '180 days'`, а Phase B (которая ставит NOW()) выполняется ПОСЛЕ Phase A → новые inactive_since=NOW() даты сильно меньше 180-дневной отсечки.
|
||||
|
||||
**180 дней — paritет со spec §3.3.** Без grace-period. Защита от потенциального reverse'а на стороне поставщика — через `supplier_sync_log` audit (полная история «что/когда удалено / response поставщика»). При обнаружении ошибки восстановление через manual rt-project-save с тем же `unique_key` — UNIQUE INDEX [schema.sql:904-905](../../db/schema.sql#L904) защитит от дублей.
|
||||
|
||||
---
|
||||
|
||||
## 5. Error handling и retry strategy
|
||||
|
||||
### 5.1. Матрица ошибок
|
||||
|
||||
| Тип ошибки | Источник | Кто ловит | Retry | Эскалация |
|
||||
|---|---|---|---|---|
|
||||
| Network timeout / connection refused | Любой rt-* call | `SupplierPortalClient` → `SupplierTransientException` | Job-уровень: `$tries=3`, `backoff()=[60,300,900]` (1м/5м/15м) | После $tries → `failed_webhook_jobs` (Sync) ИЛИ `supplier_sync_log.status='failed'` (per-item) |
|
||||
| HTTP 401/403 (session expired) | Любой rt-* call | `SupplierPortalClient` | Inline: `dispatch_sync(RefreshSupplierSessionJob)` + retry 1 раз | Повторный 401/403 → `SupplierAuthException` (sticky), job fails, email + Sentry critical |
|
||||
| HTTP 5xx | Любой rt-* call | `SupplierPortalClient` → `SupplierTransientException` | Job-уровень (как network) | Same as network |
|
||||
| HTTP 4xx (не 401/403): 400/404/422 | rt-project-save/update/delete | `SupplierPortalClient` → `SupplierClientException` | НЕТ retry (наша ошибка payload'а) | `supplier_sync_log.status='failed'` + Sentry warn + continue к следующему |
|
||||
| Playwright subprocess timeout/crash | `PlaywrightBridge` | `RefreshSupplierSessionJob` → `SupplierAuthException` | Job-уровень: `$tries=3`, `backoff()=[120,600,1800]` (2м/10м/30м) | После $tries → email + Sentry critical |
|
||||
| Redis недоступен | `SupplierPortalClient` чтение cache | Laravel native exception | Laravel automatic с queue worker | После N retries → fallback: synchronous `RefreshSupplierSessionJob` минуя cache |
|
||||
| PG transient | DB calls в jobs | Laravel native retry | Auto retry на conn-level | Baseline noise, игнорируется |
|
||||
| Supplier overcommit | Sync видит что поставщик отверг limit | `SupplierPortalClient::updateProject` → 422 | НЕТ retry | Email + Sentry + `supplier_sync_log` |
|
||||
| 50 подряд `SupplierTransientException` в Sync | `SyncSupplierProjectsJob` aggregate | Job-уровень | Job aborts mid-loop | Email + Sentry critical |
|
||||
|
||||
### 5.2. Idempotency
|
||||
|
||||
| Операция | Идемпотентность |
|
||||
|---|---|
|
||||
| `RefreshSupplierSessionJob` | Естественно идемпотентна (write to Redis-ключ); защита от concurrent — `Cache::lock('supplier:session:refresh', 30)->block(35, ...)` |
|
||||
| `SyncSupplierProjectsJob` (whole) | `->onOneServer()` через Redis driver не запускается параллельно |
|
||||
| `SyncSupplierProjectsJob` per-item | save: `if supplier_external_id IS NULL → save, else update` + UNIQUE INDEX `(platform, unique_key)` [schema.sql:904](../../db/schema.sql#L904) защищает от race на нашей стороне; на стороне поставщика — дополнительный Redis lock per `supplier_projects.id` перед save для гарантии single-flight |
|
||||
| `CleanupInactiveSupplierProjectsJob` | `->onOneServer()`; Phase A/B — идемпотентные UPDATE'ы; Phase C delete защищён per-row try/catch на 404 |
|
||||
| `RouteSupplierLeadJob` | Уже идемпотентна — fix Plan 2.5 #3 `1ba1df8`, НЕ меняем |
|
||||
| `RetryFailedSupplierJobsCommand` | Idempotent через `last_retried_at + retry_attempts < 10` фильтр |
|
||||
|
||||
### 5.3. Безопасность данных при failure
|
||||
|
||||
- **Save sequence:** HTTP-call к поставщику получает `external_id` → UPDATE `supplier_projects.supplier_external_id` в той же transaction'е, с retry на UPDATE отдельно (3 attempts). При полном failure UPDATE — `supplier_sync_log.action='save_orphan'` для manual recovery (search by `(platform, unique_key)`).
|
||||
- **Delete sequence:** soft-delete в `supplier_projects` (locally) ПОСЛЕ успешного HTTP-call. Если HTTP fail — row остаётся с `inactive_since`, next-day Phase C retry. Если HTTP succeed но локальный delete failed — next-day Phase C видит row снова, повтор HTTP → поставщик 404 → catch'им как «уже удалён», локальный delete. Self-healing.
|
||||
|
||||
### 5.4. Алерт-каналы
|
||||
|
||||
| Severity | Event | Канал |
|
||||
|---|---|---|
|
||||
| `critical` | `SupplierAuthException` после retry | Sentry + email (`SupplierCriticalAlertMail`) |
|
||||
| `critical` | Playwright subprocess crashed 3 раза подряд | Sentry + email |
|
||||
| `critical` | 50 подряд transient в Sync (suspected outage) | Sentry + email |
|
||||
| `warning` | per-item failure в Sync (один supplier_project) | Sentry + `supplier_sync_log` |
|
||||
| `info` | rt-project-delete success | `supplier_sync_log` (audit для potential reverse) |
|
||||
| `info` | session refreshed | `supplier_sync_log` + Sentry breadcrumb |
|
||||
|
||||
**Email через Unisender Go SMTP relay** ([CLAUDE.md §2](../../CLAUDE.md)), target из `config('services.supplier.alert_email')` (env `SUPPLIER_ALERT_EMAIL`). Telegram — НЕ используется (избегаем supply-chain risk от внешнего сервиса; controlled email survivable).
|
||||
|
||||
### 5.5. Exception hierarchy
|
||||
|
||||
```
|
||||
\App\Exceptions\Supplier\SupplierException (abstract)
|
||||
├── SupplierAuthException // 401/403 sticky после refresh-retry
|
||||
├── SupplierTransientException // 5xx, network, timeout
|
||||
└── SupplierClientException // 4xx 400/404/422 (наша ошибка payload)
|
||||
```
|
||||
|
||||
3 класса вместо одного с predicate-methods: idiomatic Laravel/PHP pattern, IDE autocomplete + Larastan static-check ловит unhandled paths. При добавлении новых типов (например, `SupplierRateLimitException` для 429) — +1 файл, не +1 ветка в predicate.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing strategy
|
||||
|
||||
### 6.1. Многоуровневая пирамида
|
||||
|
||||
| Уровень | Объём | Где запускается | Покрывает |
|
||||
|---|---|---|---|
|
||||
| **Unit (Mockery)** | 60–70% тестов Plan 3 | Native Windows dev + Linux CI | Pure-function logic (QuotaAllocator), HTTP-clients через `Http::fake`, Job-orchestration через mocked services |
|
||||
| **Integration (real PG)** | 20–25% | Native Windows dev + Linux CI | Job → DB → connection switch (pgsql_supplier vs pgsql); RouteSupplierLeadJob под BYPASSRLS; CleanupJob Phase A/B/C SQL'и |
|
||||
| **E2E (mock supplier server)** | 5–10% | **Linux CI only** (skip на Windows) | Полный flow liderra_project create → SyncJob → mock-server получает корректный rt-project-save payload |
|
||||
| **Discovery fixtures** | Static JSON | Не запускаются | Артефакт Task 1 как baseline |
|
||||
|
||||
### 6.2. Per-Task testing
|
||||
|
||||
**Task 1 (Discovery):** 5 JSON-fixtures в `app/tests/Fixtures/SupplierPortal/`. Каждый fixture: `{request: {method, url, headers, body}, response: {status, headers, body}}`. Артефакт коммитится в репозиторий, не запускается.
|
||||
|
||||
**Task 3 (Switch supplier-flow):** `tests/Feature/Supplier/SupplierConnectionTest.php`:
|
||||
|
||||
- `it('uses pgsql_supplier connection by default in RouteSupplierLeadJob')` — assertion через reflection / Mockery
|
||||
- `it('inserts failed_webhook_jobs with tenant_id=null without error')` — **regression-test для BLOCKER #6**. Сетап: имитировать throw в `createDealCopyForProject`, assert что `failed()` callback пишет row с `tenant_id = NULL`.
|
||||
- `it('LeadRouter sees projects across tenants under BYPASSRLS')` — **regression-test WARN #2**. Создать 3 tenant'а × 2 projects, без SET LOCAL вызвать `matchEligibleProjects`, assert что видит все 6 projects.
|
||||
- `it('ResetDeliveredTodayCommand updates all tenants under BYPASSRLS')` — **regression-test WARN #3**.
|
||||
|
||||
**Task 4 (SupplierPortalClient):** `tests/Unit/Supplier/SupplierPortalClientTest.php` через `Http::fake()`:
|
||||
|
||||
- `it('attaches PHPSESSID and CSRF cookies from cache')`
|
||||
- `it('triggers RefreshSupplierSessionJob synchronously on 401')` + `it('retries once after refresh')` + `it('throws SupplierAuthException on sticky 401')`
|
||||
- `it('throws SupplierTransientException on 5xx')`
|
||||
- `it('throws SupplierClientException on 4xx not 401/403')`
|
||||
- `it('parses rt-projects-load response into SupplierProjectDto collection')`
|
||||
- Один тест per method: `saveProject` / `updateProject` / `deleteProject` / `listProjects` (используют fixtures Task 1)
|
||||
|
||||
**Task 5 (RefreshSupplierSessionJob + PlaywrightBridge):**
|
||||
|
||||
- Unit: `tests/Unit/Supplier/RefreshSupplierSessionJobTest.php`:
|
||||
- `it('writes session data to Redis with 6h TTL')` (mock PlaywrightBridge)
|
||||
- `it('throws SupplierAuthException if PlaywrightBridge returns null')`
|
||||
- `it('acquires Redis lock before refresh')` (prevent concurrent)
|
||||
- Unit для PlaywrightBridge: `tests/Unit/Supplier/PlaywrightBridgeTest.php`:
|
||||
- Mock `Symfony\Process` через DI
|
||||
- `it('passes credentials to subprocess via stdin not argv')` (avoid leak in ps output)
|
||||
- `it('parses stdout JSON')`, `it('throws on non-zero exit')`, `it('throws on timeout')`
|
||||
- Integration Linux CI only: `tests/Browser/SupplierPlaywrightBridgeTest.php`:
|
||||
- `it('actually launches chromium and returns cookies')` — `->skip(PHP_OS_FAMILY === 'Windows', ...)`
|
||||
- Mock supplier-server → real Node subprocess → assert cookies extracted
|
||||
|
||||
**Task 6 (SyncSupplierProjectsJob + SupplierQuotaAllocator):**
|
||||
|
||||
- Unit QuotaAllocator (pure function): `tests/Unit/Supplier/SupplierQuotaAllocatorTest.php`:
|
||||
- 9 тестов на distribution: B1+B2+B3 для site/call, B2+B3 для sms-with-keyword, B3-only для sms-without-keyword
|
||||
- Workdays union, regions union, regions_reverse=false
|
||||
- Edge cases: 0 active liderra (return null), 1 project с limit=1, 1000 projects с limit=10000
|
||||
- Integration SyncJob: `tests/Feature/Supplier/SyncSupplierProjectsJobTest.php`:
|
||||
- `it('creates supplier_project at supplier when supplier_external_id is null')` — Http::fake получает rt-project-save с правильным payload
|
||||
- `it('updates when diff detected')`
|
||||
- `it('skips when no diff')`
|
||||
- `it('isolates failure: one bad supplier_project does not stop others')` — критический тест per-item isolation
|
||||
- `it('aborts after 50 consecutive transient failures and alerts')` — mass-fail mitigation
|
||||
- `it('writes supplier_sync_log row for each action')`
|
||||
- `it('respects time budget 20:30-20:55')` — mock Carbon, assert exit при cutoff
|
||||
|
||||
**Task 7 (CleanupInactiveSupplierProjectsJob):** `tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php`:
|
||||
|
||||
- `it('phase A re-activates supplier_project when active liderra returns')`
|
||||
- `it('phase B marks inactive_since=NOW for newly orphaned supplier_project')`
|
||||
- `it('phase C deletes supplier_project after 180 days inactive')`
|
||||
- **`it('phase A runs before phase B before phase C')`** — критический ordering test (safety от accidental delete активного supplier_project)
|
||||
- `it('handles 404 from supplier on already-deleted supplier_project')`
|
||||
- `it('writes audit row to supplier_sync_log on each delete')`
|
||||
- Edge case: `it('does not delete supplier_project marked inactive < 180 days ago')`
|
||||
|
||||
**Task 8 (RetryFailedSupplierJobsCommand):** `tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php`:
|
||||
|
||||
- `it('dispatches RouteSupplierLeadJob for each row in failed_webhook_jobs')`
|
||||
- `it('skips rows with retry_attempts >= 10')`
|
||||
- `it('skips rows with last_retried_at within last hour')`
|
||||
- `it('increments retry_attempts and updates last_retried_at')`
|
||||
|
||||
**Task 9 (E2E flow, Linux CI only):** `tests/Browser/SupplierIntegrationE2ETest.php`:
|
||||
|
||||
- Mock supplier-server на временном порту через Symfony HttpFoundation
|
||||
- Создать liderra_project в БД, запустить SyncSupplierProjectsJob
|
||||
- Assert mock-server получил rt-project-save с корректным payload
|
||||
- Assert `supplier_projects.supplier_external_id` обновился
|
||||
- Skip на Windows
|
||||
- Опционально: проверить что после Task 3 connection switch не сломан Plan 2 E2E test `b6b5b0b`
|
||||
|
||||
### 6.3. Pre-commit lefthook расширение
|
||||
|
||||
Дополнения к [lefthook.yml](../../lefthook.yml):
|
||||
|
||||
- Job `playwright-fixtures-syntax`: `node app/playwright/check-fixtures.js` валидирует JSON-fixtures (на Linux); skip на Windows.
|
||||
- Job `pest-supplier-unit-fast`: запускать только `tests/Unit/Supplier/` + `tests/Feature/Supplier/` на staged commit (~5s). Полный `composer test` остаётся на push-hook.
|
||||
|
||||
### 6.4. CI matrix
|
||||
|
||||
- **GitHub Actions Linux job:** добавить `npx playwright install chromium --with-deps` в setup, запуск `composer test --testsuite=Browser` для full E2E
|
||||
- **Native Windows dev:** `composer test` пропускает Browser tests; всё остальное проходит
|
||||
|
||||
### 6.5. Метрики приёма Plan 3
|
||||
|
||||
Минимум для merge:
|
||||
|
||||
- **Pest:** +50–70 новых тестов; total после Plan 3 ≥ ~608/610 (Plan 2.6 baseline 558/556)
|
||||
- **Larastan:** 0 errors на новом коде; baseline пополняется только Pest-Mockery-PhpDoc edge cases
|
||||
- **Pint:** clean
|
||||
- **squawk:** 0 issues (Plan 3 = 0 SQL миграций → squawk не активируется)
|
||||
- **gitleaks-full-history:** 0 leaks. **Особое внимание:** `SUPPLIER_LOGIN`, `SUPPLIER_PASSWORD` НИКОГДА не в коде / fixtures / tests. Тесты используют dummy `SUPPLIER_LOGIN=test_login`.
|
||||
- **lychee:** 0 broken links в spec + plan files
|
||||
|
||||
---
|
||||
|
||||
## 7. Acceptance criteria
|
||||
|
||||
Plan 3 считается готовым к FF-merge в main когда:
|
||||
|
||||
1. **BLOCKER #6 закрыт:** failed_webhook_jobs.insert с tenant_id=NULL не падает silent под `crm_supplier_worker` connection. Regression-test green.
|
||||
2. **WARN #2 закрыт:** LeadRouter::matchEligibleProjects видит все tenant projects под BYPASSRLS. Regression-test green.
|
||||
3. **WARN #3 закрыт:** ResetDeliveredTodayCommand обновляет projects всех tenant'ов. Regression-test green.
|
||||
4. **Session refresh работает:** `php artisan supplier:session:refresh` успешно логинится в реальный crm.bp-gr.ru и записывает в Redis. Smoke check после deploy.
|
||||
5. **SyncSupplierProjectsJob smoke:** один тестовый liderra_project → 20:30 cron создаёт supplier-side проект через rt-project-save → `supplier_projects.supplier_external_id` обновляется.
|
||||
6. **Cleanup ordering:** Phase A → B → C проверен явным test'ом.
|
||||
7. **Mass-fail mitigation:** 50 transient → abort + email + Sentry.
|
||||
8. **Все per-Task тесты:** green на Linux CI, green на Windows dev (с E2E skip).
|
||||
9. **Code-review subagent:** один прогон с финальным «Ready for FF-merge» без BLOCKERов.
|
||||
10. **CV-gates (Comprehensive Verification):** 14 проверок (как в Plan 2 — CV.1–CV.14) green.
|
||||
|
||||
---
|
||||
|
||||
## 8. Не верифицировано в этом design'е
|
||||
|
||||
Согласно правилам экономии 0%, явный список limitations:
|
||||
|
||||
- **DOM-селекторы** для `refresh-session.js` (`input[name=login]`, `meta[name=csrf-token]`) — placeholder'ы. Точные селекторы будут известны после Task 1 discovery; могут потребовать корректировку Node-скрипта.
|
||||
- **Mock supplier server library** в Pest Browser tests — предположил Symfony HttpFoundation для in-process mock-server'а. Альтернативы (`hammerstone/closure-server`, `react/http`) не проверял; финальный выбор — в Task 9.
|
||||
- **Cache lock `Cache::lock('supplier:session:refresh', 30)->block(35, ...)`** для Redis driver — синтаксис Laravel 13 не проверил против актуальной `[config/cache.php](app/config/cache.php)` (memurai на dev). Возможна корректировка при implementation.
|
||||
- **`onOneServer()` driver:** предполагаю Redis (через memurai). Если `CACHE_STORE` env-default — `file` (Plan 2 deferred WARNING #5 упоминает отсутствие `cache_locks` таблицы), нужна явная коррекция env или добавление `cache_locks` таблицы. Финал — в Task 6/7.
|
||||
- **Larastan PhpDoc для `Symfony\Process` stub injection** — на Linux может работать иначе чем Windows. Не верифицировал.
|
||||
- **Telegram-канал для алертов** — отвергнут в пользу email; альтернатива не реализуется в Plan 3, можно добавить в Sprint 5+ как extra канал.
|
||||
- **Семантика rt-project-delete у поставщика (soft vs hard):** узнаем в Task 1 discovery. План предполагает наихудший случай (hard) и опирается на `supplier_sync_log` audit для potential reverse. При soft-семантике план не меняется — overhead минимален.
|
||||
- **Cookie `PHPSESSID` имя:** предположение, реальное имя session-cookie у поставщика — узнаем в Task 1. Может быть `JSESSIONID`, `_session`, etc.
|
||||
- **CSRF-токен механизм:** предположил `<meta name="csrf-token" content="...">`. Альтернативы: hidden input в каждой форме, custom header. Узнаем в Task 1.
|
||||
- **Время для `onOneServer` lock TTL:** взят дефолт Laravel (~30s); если cron жёстко занимает >30s — race возможна. Уточним при implementation.
|
||||
|
||||
---
|
||||
|
||||
## 9. Открытые вопросы для Discovery (Task 1)
|
||||
|
||||
Список конкретных вопросов, на которые Task 1 должен дать ответ:
|
||||
|
||||
1. **Login flow:** обычный HTML-form POST или JS-rendered (SPA)? Какие input-name (`login`/`username`/`email`)?
|
||||
2. **Cookie names:** `PHPSESSID`, `JSESSIONID`, custom? Какие attributes (HttpOnly, Secure, SameSite)?
|
||||
3. **CSRF mechanism:** `<meta>` tag в head? Hidden input в каждой форме? Custom header (`X-CSRF-Token`)?
|
||||
4. **rt-projects-load:** GET или POST? Pagination? Format JSON / form-urlencoded?
|
||||
5. **rt-project-save:** обязательные поля? Валидация на сервере? Response format (id + status / только status / redirect)?
|
||||
6. **rt-project-update:** включает все поля или только diff? Принимает PATCH или POST?
|
||||
7. **rt-project-delete:** идемпотентен (повторный delete → 200 / 404)? Soft или hard?
|
||||
8. **HTTP error semantics:** что возвращается на bad payload — 400 / 422? На auth fail — 401 / 403? На rate limit — 429?
|
||||
9. **Rate limits:** есть ли вообще? Сколько RPS поддерживает personal account?
|
||||
10. **Логин-страница URL:** прямой URL для login form?
|
||||
11. **DOM селекторы успешного логина:** какой signal что login passed (redirect URL? presence of element? cookie change?)?
|
||||
12. **Логаут timeout:** через сколько без активности cookie expires?
|
||||
|
||||
Эти ответы попадут в spec parent §4.4 после Task 2.
|
||||
|
||||
---
|
||||
|
||||
## 10. Следующий шаг
|
||||
|
||||
После approval этого design'а:
|
||||
|
||||
1. `superpowers:writing-plans` skill → создать [docs/superpowers/plans/2026-05-11-supplier-sync-plan3.md](../plans/2026-05-11-supplier-sync-plan3.md) с детальной декомпозицией каждого из 9 Tasks (failing test → implementation → green → commit).
|
||||
2. После писания plan'а — отдельный stop-gate user review.
|
||||
3. Executing-plans (либо subagent-driven для Tasks 4–7, либо inline для Task 3 как самой простой).
|
||||
4. После всех 9 Tasks → CV-gates → code-review subagent → FF-merge в main.
|
||||
|
||||
---
|
||||
|
||||
## История версий design'а
|
||||
|
||||
- **v1.0 от 2026-05-11** — первичный draft после brainstorming-сессии (5 секций обсуждено, 9 Tasks утверждены, 3 архитектурных решения: BLOCKER #6 = вариант C, headless browser = Playwright real, discovery = вариант А). Согласовано через delegated decision-making у заказчика «реши сам, руководствуясь максимумом эффекта и минимумом риска как в моменте так и в дальнейшем».
|
||||
@@ -1,736 +0,0 @@
|
||||
# Plan 4 (Billing + CSV Reconcile + Admin) Implementation Design
|
||||
|
||||
**Дата:** 2026-05-11
|
||||
**Статус:** черновик дизайна (brainstorming output)
|
||||
**Заказчик:** Дмитрий (владелец Лидерры)
|
||||
**Parent spec:** [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](./2026-05-10-supplier-integration-design.md) §5.2, §7
|
||||
**Inherits from:** Plan 1 (Foundation `001d781`) + Plan 2 (Webhook+Routing `d5aa972`) + Plan 2.5 (concurrency hotfix `c1ae195`+`1ba1df8`) + Plan 2.6 (cleanup `7899071`) + Plan 3 (Supplier Sync `734b0ab`).
|
||||
|
||||
## 1. Контекст, инварианты, что уже есть в коде
|
||||
|
||||
### 1.1. Цель Plan 4
|
||||
|
||||
Активировать ступенчатый биллинг клиентов Лидерры (`pricing_tiers` / `lead_charges`), закрыть TODO «Биллинг per Plan 4» в [RouteSupplierLeadJob.php:48](../../../app/app/Jobs/RouteSupplierLeadJob.php#L48), реализовать резервный CSV-канал приёма лидов и Admin UI для конфигурации.
|
||||
|
||||
### 1.2. Что уже есть в коде (после Plans 1+2+3)
|
||||
|
||||
| Сущность | Где | Состояние |
|
||||
|---|---|---|
|
||||
| `pricing_tiers` (7 ступеней, копейки, `effective_from`) | [db/schema.sql:962](../../../db/schema.sql#L962) | Таблица создана (v8.14), **никем не читается** |
|
||||
| `lead_charges` (append-only ledger, RLS, FK на partitioned deals) | [db/schema.sql:1001](../../../db/schema.sql#L1001) | Таблица создана (v8.15), **никто не пишет** |
|
||||
| `tenants.balance_rub` (DECIMAL 12,2 без CHECK), `tenants.balance_leads` (INT) | [db/schema.sql:629](../../../db/schema.sql#L629) | Существуют; `balance_leads` декрементится в RouteSupplierLeadJob |
|
||||
| `projects.delivered_in_month` (INTEGER) | [db/schema.sql:786](../../../db/schema.sql#L786) | Инкрементится в RouteJob, **нет cron'а сброса** |
|
||||
| `BalanceTransaction` (type=`lead_charge`, amount_leads=-1) | [RouteSupplierLeadJob:271](../../../app/app/Jobs/RouteSupplierLeadJob.php#L271) | Пишется при доставке |
|
||||
| `SupplierLeadCost` (per-deal закупочный snapshot, partitioned) | [db/schema.sql:2201](../../../db/schema.sql#L2201), [ProcessWebhookJob:216](../../../app/app/Jobs/ProcessWebhookJob.php#L216) | Пишется только в старом `ProcessWebhookJob`, в новом `RouteSupplierLeadJob` НЕ пишется (gap) |
|
||||
| `suppliers.cost_rub` (per-platform B1/B2/B3) | [db/schema.sql:352](../../../db/schema.sql#L352) | Seed B1/B2/B3 в [schema.sql:2505](../../../db/schema.sql#L2505) |
|
||||
| `SupplierPortalClient` + `RefreshSupplierSessionJob` + `PlaywrightBridge` | `app/app/Services/Supplier/*`, Plan 3 | Готово, переиспользуется для CSV |
|
||||
| `SupplierCriticalAlertMail` (email через Unisender Go) | `app/app/Mail/*`, Plan 3 | Готово, расширим алертами биллинга и drift'а |
|
||||
|
||||
### 1.3. Бизнес-инварианты Plan 4
|
||||
|
||||
1. **Dual balance:** при доставке лида сначала пытаемся списать 1 единицу `balance_leads` (prepaid pool); если 0 — списываем из `balance_rub` по текущей tier-цене.
|
||||
2. **Tier-lookup per-tenant:** ступень определяется накопительной суммой `tenants.delivered_in_month` за месяц (новая колонка — см. §2).
|
||||
3. **Атомарность:** charge + deal-INSERT + counter increment — одна транзакция. `lead_charges` пишется **всегда** при успешной доставке: `price_per_lead_kopecks=0` для prepaid, фактическая tier-цена для рублёвого списания. `charge_source ENUM('prepaid','rub')` для прозрачности.
|
||||
4. **Pause-on-insufficient:** если оба источника пусты — лид НЕ доставляется этому проекту, статус Лидерра-проекта → `is_active=false` + email уведомление с rate-limit 1/час/tenant.
|
||||
5. **Pricing tier change → effective 1-го числа след. месяца:** UI принимает изменения в любой день, но `effective_from` всегда auto-set = `DATE_TRUNC('month', NOW() + INTERVAL '1 month')`. Текущая активная ступень = `MAX(effective_from) WHERE effective_from <= CURRENT_DATE AND is_active=true`.
|
||||
6. **`SupplierLeadCost` для sharing-flow:** при каждой созданной deal-копии пишем `supplier_lead_costs(deal_id, supplier_id, cost_rub=suppliers.cost_rub)` — закрывает существующий gap (Plan 2/3 не писали).
|
||||
7. **CSV reconcile:** hourly. Сравнение по `supplier_leads.vid` за окно 25h. Drift > 5% → email админу. Recovered лиды идут через тот же `RouteSupplierLeadJob` с `recovered_from_csv_at` маркером.
|
||||
8. **Admin UI:** SaaS-level `/admin/pricing-tiers` (CRUD 7 ступеней) + `/admin/supplier-prices` (B1/B2/B3 cost_rub editor) + tenant-level «Списания» tab в существующем `BillingView`.
|
||||
|
||||
### 1.4. Out of scope Plan 4
|
||||
|
||||
1. Balance top-up UI для admin/tenant — Plan 4.5 или Plan 5.
|
||||
2. Auto-resume project при пополнении баланса — Plan 4.5+.
|
||||
3. Подписочный биллинг / payments / счета-фактуры — Plan 5+.
|
||||
4. Per-tenant pricing override — out of MVP (spec §1.2).
|
||||
5. Replay сделок при изменении tier'а задним числом — нет (`effective_from` + append-only `lead_charges`).
|
||||
6. `Schedule::onOneServer()` для новых cron'ов — требует `cache_locks` таблицу, gated на Б-1 / Managed PG в Yandex Cloud.
|
||||
7. SaaS-admin auth middleware — gated на Б-1 + SSO заказчика; на MVP все `/api/admin/*` без middleware (паритет с Plans 1/2/3).
|
||||
8. Discovery CSV-схемы через credentials поставщика (паритет с Plan 3 Tasks 1+2 BLOCKED).
|
||||
9. Polling балансов из платёжного шлюза.
|
||||
|
||||
## 2. Schema delta v8.18 → v8.19
|
||||
|
||||
### 2.1. Изменения в существующих таблицах
|
||||
|
||||
#### `tenants` — +1 колонка `delivered_in_month`
|
||||
|
||||
```sql
|
||||
ALTER TABLE tenants ADD COLUMN delivered_in_month INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (delivered_in_month >= 0);
|
||||
|
||||
COMMENT ON COLUMN tenants.delivered_in_month IS
|
||||
'Накопительный счётчик доставленных лидов в текущем календарном месяце (Europe/Moscow). '
|
||||
'Используется PricingTierResolver для определения текущей ступени pricing_tiers '
|
||||
'на горячем пути RouteSupplierLeadJob. Сбрасывается в 0 cron-командой '
|
||||
'ResetMonthlyCountersCommand 1-го числа в 00:00 МСК (Plan 4).';
|
||||
```
|
||||
|
||||
Обоснование: SUM-by-projects на каждом charge — N+1 + `lockForUpdate`-race; денормализованный счётчик читается O(1) и обновляется в той же транзакции, что `lead_charges` INSERT.
|
||||
|
||||
#### `tenants.balance_rub` — CHECK НЕ добавляем
|
||||
|
||||
Намеренно: проверка `< price` происходит в `LedgerService::canCharge()`, в БД оставляем свободу для chargeback writedown (Ю-3, может уйти в плюс) и админских корректировок.
|
||||
|
||||
#### `lead_charges` — +1 колонка `charge_source` + 1 CHECK
|
||||
|
||||
```sql
|
||||
ALTER TABLE lead_charges
|
||||
ADD COLUMN charge_source VARCHAR(8) NOT NULL DEFAULT 'rub'
|
||||
CHECK (charge_source IN ('prepaid','rub'));
|
||||
|
||||
ALTER TABLE lead_charges
|
||||
ADD CONSTRAINT chk_lead_charges_prepaid_zero_price
|
||||
CHECK (charge_source = 'rub' OR price_per_lead_kopecks = 0);
|
||||
|
||||
COMMENT ON COLUMN lead_charges.charge_source IS
|
||||
'Источник списания: prepaid (balance_leads -1, price_per_lead_kopecks=0) '
|
||||
'или rub (balance_rub минус price). Plan 4.';
|
||||
```
|
||||
|
||||
`DEFAULT 'rub'` безопасен: до Plan 4 строк в таблице нет.
|
||||
|
||||
#### `supplier_leads` — +1 колонка `recovered_from_csv_at`
|
||||
|
||||
```sql
|
||||
ALTER TABLE supplier_leads
|
||||
ADD COLUMN recovered_from_csv_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX supplier_leads_recovered_from_csv_partial
|
||||
ON supplier_leads(recovered_from_csv_at)
|
||||
WHERE recovered_from_csv_at IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN supplier_leads.recovered_from_csv_at IS
|
||||
'NULL для лидов, пришедших через webhook (основной канал). Заполняется CsvReconcileJob '
|
||||
'при восстановлении лида, который webhook пропустил. Plan 4.';
|
||||
```
|
||||
|
||||
### 2.2. Новая таблица: `supplier_csv_reconcile_log`
|
||||
|
||||
```sql
|
||||
CREATE TABLE supplier_csv_reconcile_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
finished_at TIMESTAMPTZ,
|
||||
window_start TIMESTAMPTZ NOT NULL,
|
||||
window_end TIMESTAMPTZ NOT NULL,
|
||||
total_csv_rows INTEGER,
|
||||
matched_count INTEGER,
|
||||
recovered_count INTEGER,
|
||||
drift_ratio NUMERIC(5,4),
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'running'
|
||||
CHECK (status IN ('running','ok','drift_alert','failed')),
|
||||
error_message TEXT,
|
||||
alert_email_sent_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX supplier_csv_reconcile_log_started_at_index
|
||||
ON supplier_csv_reconcile_log(started_at DESC);
|
||||
CREATE INDEX supplier_csv_reconcile_log_status_index
|
||||
ON supplier_csv_reconcile_log(status)
|
||||
WHERE status IN ('drift_alert','failed');
|
||||
```
|
||||
|
||||
SaaS-level (не tenant-scoped), без RLS. Аналог `supplier_sync_log`. **GRANT-policy:** REVOKE/GRANT для `crm_supplier_worker` (BYPASSRLS) **не идут в `schema.sql`** — `schema.sql` остаётся DDL-only под dev (postgres superuser). GRANT SELECT,INSERT,UPDATE на `supplier_csv_reconcile_log` для `crm_supplier_worker` добавляется в [db/02_grants.sql](../../../db/02_grants.sql) (паттерн Plan 2.6 #iv `7899071`). На dev таблица доступна суперпользователю по умолчанию.
|
||||
|
||||
### 2.3. Seed-данные (отдельно от schema.sql)
|
||||
|
||||
7 ступеней `pricing_tiers` + системные настройки. **НЕ идут в schema.sql** (тот — DDL only), а в:
|
||||
|
||||
- `database/seeders/PricingTierSeeder.php` — 7 строк с `effective_from='1970-01-01'`.
|
||||
- `system_settings` row seed в schema.sql §INSERTS — TBD ключ `supplier_lead_price_default_kopecks` (если потребуется fallback).
|
||||
|
||||
**Дефолтные tier-цены** (`[?]` для заказчика — открытый вопрос #1):
|
||||
|
||||
| tier_no | leads_in_tier | price_per_lead (руб) | price_per_lead_kopecks |
|
||||
|---|---|---|---|
|
||||
| 1 | 100 | 500.00 | 50000 |
|
||||
| 2 | 200 | 450.00 | 45000 |
|
||||
| 3 | 400 | 400.00 | 40000 |
|
||||
| 4 | 800 | 350.00 | 35000 |
|
||||
| 5 | 1500 | 300.00 | 30000 |
|
||||
| 6 | 3000 | 270.00 | 27000 |
|
||||
| 7 | NULL | 250.00 | 25000 |
|
||||
|
||||
### 2.4. Метрики после Plan 4
|
||||
|
||||
| Метрика | До (v8.18) | После (v8.19) | Δ |
|
||||
|---|---|---|---|
|
||||
| Базовые таблицы | 61 | 62 (+`supplier_csv_reconcile_log`) | +1 |
|
||||
| Партиции | 12 | 12 | 0 |
|
||||
| Индексы | 114 | 117 | +3 |
|
||||
| RLS-политики | 39 | 39 | 0 |
|
||||
| CHECK constraints | (без явного подсчёта) | +2 (`tenants.delivered_in_month >= 0`, `chk_lead_charges_prepaid_zero_price`) | +2 |
|
||||
| Добавлено колонок | — | `tenants.delivered_in_month`, `lead_charges.charge_source`, `supplier_leads.recovered_from_csv_at` | +3 |
|
||||
|
||||
### 2.5. Patch order в schema.sql
|
||||
|
||||
1. `tenants.delivered_in_month` — после строки 644 (`desired_daily_numbers`), в секции «Биллинг».
|
||||
2. `lead_charges.charge_source` + CHECK — внутри `CREATE TABLE lead_charges` после строки 1008.
|
||||
3. `supplier_csv_reconcile_log` — новая секция после `supplier_sync_log`.
|
||||
4. `supplier_leads.recovered_from_csv_at` — внутри `CREATE TABLE supplier_leads` (v8.18).
|
||||
5. Bump version header v8.18 → v8.19 + entry в `db/CHANGELOG_schema.md`.
|
||||
|
||||
## 3. Billing flow в `RouteSupplierLeadJob`
|
||||
|
||||
### 3.1. Новые сервисы
|
||||
|
||||
**`App\Services\Billing\PricingTierResolver`** — pure resolver:
|
||||
|
||||
```php
|
||||
final class PricingTierResolver
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, PricingTier> $tiers активные ступени, отсортированные по tier_no
|
||||
* @return PricingTier ступень, в которую попадает N-й лид (1-based)
|
||||
*/
|
||||
public function resolveForCount(Collection $tiers, int $deliveredInMonth): PricingTier;
|
||||
}
|
||||
```
|
||||
|
||||
Логика: tier 1 покрывает 1..`leads_in_tier`; tier 2 — следующие `leads_in_tier`; ... tier 7 с `leads_in_tier=NULL` ловит всё свыше. Текущая активная сетка = `MAX(effective_from) WHERE effective_from <= CURRENT_DATE AND is_active=true` через `PricingTierRepository::activeAt(Carbon $date)`.
|
||||
|
||||
**`App\Services\Billing\LedgerService`** — командный сервис, инжектится в job:
|
||||
|
||||
```php
|
||||
final class LedgerService
|
||||
{
|
||||
public function __construct(
|
||||
private PricingTierResolver $resolver,
|
||||
private PricingTierRepository $tiers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Выполняется ВНУТРИ открытой DB-транзакции под lockForUpdate(Tenant).
|
||||
* Возвращает ChargeResult с source ('prepaid'|'rub'); throws InsufficientBalanceException.
|
||||
*/
|
||||
public function chargeForDelivery(Tenant $lockedTenant, Deal $deal): ChargeResult;
|
||||
}
|
||||
```
|
||||
|
||||
**`App\Exceptions\Billing\InsufficientBalanceException`** — несёт `priceKopecks`, `balanceRub`, `balanceLeads` для логирования.
|
||||
|
||||
### 3.2. Точка интеграции — diff в `createDealCopyForProject`
|
||||
|
||||
Замена строк [RouteSupplierLeadJob.php:265-279](../../../app/app/Jobs/RouteSupplierLeadJob.php#L265-L279):
|
||||
|
||||
**Было:**
|
||||
|
||||
```php
|
||||
$tenant->decrement('balance_leads');
|
||||
$tenant->refresh();
|
||||
$project->increment('delivered_today');
|
||||
$project->increment('delivered_in_month');
|
||||
|
||||
BalanceTransaction::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
|
||||
'amount_leads' => -1,
|
||||
'balance_leads_after' => (int) $tenant->balance_leads,
|
||||
'related_type' => Deal::class,
|
||||
'related_id' => $deal->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
```
|
||||
|
||||
**Станет:**
|
||||
|
||||
```php
|
||||
try {
|
||||
$chargeResult = $ledger->chargeForDelivery($tenant, $deal);
|
||||
} catch (InsufficientBalanceException $e) {
|
||||
Log::warning('billing.insufficient_balance.deal_rolled_back', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'project_id' => $project->id,
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'price_kopecks' => $e->priceKopecks,
|
||||
'balance_rub' => $e->balanceRub,
|
||||
'balance_leads' => $e->balanceLeads,
|
||||
]);
|
||||
throw $e; // вылетает из DB::transaction → rollback Deal/Tenant lock/ActivityLog
|
||||
}
|
||||
|
||||
$project->increment('delivered_today');
|
||||
$project->increment('delivered_in_month');
|
||||
```
|
||||
|
||||
### 3.3. Поток внутри `LedgerService::chargeForDelivery`
|
||||
|
||||
```
|
||||
1. Считать активные tiers через PricingTierRepository::activeAt(today).
|
||||
2. Resolve currentTier = PricingTierResolver::resolveForCount(tiers,
|
||||
$lockedTenant->delivered_in_month + 1).
|
||||
3. priceKopecks = currentTier->price_per_lead_kopecks.
|
||||
4. Decide chargeSource (все денежные сравнения через bcmath — НЕ через PHP float):
|
||||
- if $lockedTenant->balance_leads >= 1: source='prepaid'
|
||||
- else if bccomp(bcmul((string) $lockedTenant->balance_rub, '100', 0),
|
||||
(string) $priceKopecks, 0) >= 0: source='rub'
|
||||
- else: throw InsufficientBalanceException(...)
|
||||
5. Apply:
|
||||
if source='prepaid':
|
||||
- $lockedTenant->decrement('balance_leads', 1)
|
||||
- $lockedTenant->increment('delivered_in_month', 1)
|
||||
- $lockedTenant->refresh()
|
||||
- INSERT lead_charges (charge_source='prepaid', tier_no=current,
|
||||
price_per_lead_kopecks=0, charged_at=now)
|
||||
- INSERT balance_transactions (type='lead_charge', amount_leads=-1,
|
||||
balance_leads_after, related=Deal)
|
||||
if source='rub':
|
||||
- amount_rub = bcdiv($priceKopecks, '100', 2) (string-math, не float)
|
||||
- $lockedTenant->decrement('balance_rub', $amount_rub)
|
||||
- $lockedTenant->increment('delivered_in_month', 1)
|
||||
- $lockedTenant->refresh()
|
||||
- INSERT lead_charges (charge_source='rub', tier_no=current,
|
||||
price_per_lead_kopecks=$priceKopecks, charged_at=now)
|
||||
- INSERT balance_transactions (type='lead_charge', amount_rub=-$amount_rub,
|
||||
balance_rub_after, related=Deal)
|
||||
6. INSERT supplier_lead_costs (deal_id, received_at, supplier_id, cost_rub)
|
||||
— закрывает gap (Plan 2/3 не писали в sharing-flow). supplier_id резолвится через
|
||||
$lead->supplierProject->supplier_id (FK supplier_projects → suppliers); если null
|
||||
(stub-проект без resolved supplier) — fallback в suppliers WHERE code = strtolower(platform).
|
||||
cost_rub = $supplier->cost_rub (snapshot на момент INSERT'а).
|
||||
7. return new ChargeResult(source, currentTier, priceKopecks)
|
||||
```
|
||||
|
||||
**Атомарность:** все INSERT'ы — внутри одного `DB::transaction` родительского closure. Композитный DEFERRABLE FK `lead_charges → deals(id, received_at)` проверяется на commit.
|
||||
|
||||
**Денежная арифметика:** `price_per_lead_kopecks` (INTEGER) → `balance_rub` (DECIMAL 12,2) через `bcdiv($priceKopecks, '100', 2)`, не PHP float. `Tenant::decrement('balance_rub', $amount)` использует SQL UPDATE (Eloquent), PG корректно держит в DECIMAL.
|
||||
|
||||
### 3.4. Concurrency и порядок locks
|
||||
|
||||
Текущий код держит lock'и в порядке (после Plan 2.5 fix #2):
|
||||
|
||||
1. `Tenant::lockForUpdate` ([RouteSupplierLeadJob.php:188-191](../../../app/app/Jobs/RouteSupplierLeadJob.php#L188-L191))
|
||||
2. `Project::lockForUpdate` ([RouteSupplierLeadJob.php:199-202](../../../app/app/Jobs/RouteSupplierLeadJob.php#L199-L202))
|
||||
|
||||
Plan 4 не меняет порядок. `LedgerService::chargeForDelivery` принимает **уже locked** `$tenant` — не делает повторный SELECT.
|
||||
|
||||
## 4. Monthly reset + balance-insufficient → auto-pause
|
||||
|
||||
### 4.1. `ResetMonthlyCountersCommand` (cron 1-го числа в 00:00 МСК)
|
||||
|
||||
Расширенный аналог `ResetDeliveredTodayCommand`:
|
||||
|
||||
```php
|
||||
namespace App\Console\Commands;
|
||||
|
||||
final class ResetMonthlyCountersCommand extends Command
|
||||
{
|
||||
protected $signature = 'projects:reset-monthly';
|
||||
protected $description = 'Сброс tenants.delivered_in_month + projects.delivered_in_month = 0 (1-го числа в 00:00 МСК)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
DB::connection('pgsql_supplier')->transaction(function () {
|
||||
$tenants = DB::connection('pgsql_supplier')
|
||||
->update('UPDATE tenants SET delivered_in_month = 0 WHERE delivered_in_month <> 0');
|
||||
$projects = DB::connection('pgsql_supplier')
|
||||
->update('UPDATE projects SET delivered_in_month = 0 WHERE delivered_in_month <> 0');
|
||||
$this->info("Monthly reset: {$tenants} tenants, {$projects} projects.");
|
||||
});
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Schedule entry в [routes/console.php](../../../app/routes/console.php) — после `reset-delivered-today`:
|
||||
|
||||
```php
|
||||
Schedule::command('projects:reset-monthly')
|
||||
->monthlyOn(1, '00:00')
|
||||
->timezone('Europe/Moscow');
|
||||
```
|
||||
|
||||
Идемпотентность: `WHERE delivered_in_month <> 0` → повторный запуск даёт 0 affected.
|
||||
|
||||
### 4.2. Auto-pause при insufficient balance
|
||||
|
||||
`InsufficientBalanceException` ловится в `createDealCopyForProject` **после** rollback'а транзакции:
|
||||
|
||||
```php
|
||||
try {
|
||||
return DB::transaction(function () use (...) { /* существующий код + LedgerService */ });
|
||||
} catch (InsufficientBalanceException $e) {
|
||||
$this->handleInsufficientBalance($lead, $project, $e);
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
private function handleInsufficientBalance(
|
||||
SupplierLead $lead,
|
||||
Project $project,
|
||||
InsufficientBalanceException $e,
|
||||
): void {
|
||||
// UPDATE через pgsql_supplier (BYPASSRLS-роль crm_supplier_worker) — паттерн
|
||||
// ResetDeliveredTodayCommand. Не используем SET LOCAL app.current_tenant_id,
|
||||
// т.к. queue worker может работать под non-tenant context'ом.
|
||||
DB::connection('pgsql_supplier')
|
||||
->update('UPDATE projects SET is_active = false WHERE id = ?', [$project->id]);
|
||||
|
||||
$cacheKey = "billing:zero_balance_alert:{$project->tenant_id}";
|
||||
if (Cache::add($cacheKey, true, now()->addHour())) {
|
||||
app(NotificationService::class)->notifyZeroBalance($project->tenant, $project);
|
||||
}
|
||||
|
||||
Log::warning('billing.project_paused_insufficient_balance', [
|
||||
'tenant_id' => $project->tenant_id,
|
||||
'project_id' => $project->id,
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'price_kopecks' => $e->priceKopecks,
|
||||
'balance_rub' => $e->balanceRub,
|
||||
'balance_leads' => $e->balanceLeads,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3. Семантика паузы
|
||||
|
||||
- **Что пауза делает:** `projects.is_active = false`. `LeadRouter::matchEligibleProjects` уже фильтрует `WHERE is_active = true` (Plan 1/2 паттерн — подтверждается тестом).
|
||||
- **Что НЕ делает:** не отменяет связь с supplier_projects, не сбрасывает счётчики, не трогает `SyncSupplierProjectsJob` (но союз active-tenant'ов пересчитается на след. SyncJob run).
|
||||
- **Re-activation:** через UI пополнения баланса (Plan 4.5+) или manual toggle. Auto-resume — out of Plan 4.
|
||||
|
||||
### 4.4. Notification — `notifyZeroBalance`
|
||||
|
||||
`NotificationService::notifyZeroBalance(Tenant $tenant, Project $project)`. Матрица `notification_preferences` ([schema.sql:716](../../../db/schema.sql#L716)) `zero_balance` дефолтно через email. Email через Unisender Go.
|
||||
|
||||
Mailable `App\Mail\ZeroBalancePausedMail` + blade `resources/views/emails/zero_balance_paused.blade.php`:
|
||||
|
||||
- Subject: «Проект "{name}" приостановлен — недостаточно средств»
|
||||
- Body (русский): имя проекта, текущий баланс (rub и leads), требуемая ступень + цена, ссылка `/billing/topup`.
|
||||
|
||||
### 4.5. Rate-limit алертов 1/час/tenant
|
||||
|
||||
Через `Cache::add($key, true, now()->addHour())` — atomic Redis SETNX. Открытый вопрос #2: возможна корректировка.
|
||||
|
||||
## 5. CSV reconcile архитектура
|
||||
|
||||
### 5.1. Расширение `SupplierPortalClient`
|
||||
|
||||
Один новый публичный метод в [SupplierPortalClient.php:65](../../../app/app/Services/Supplier/SupplierPortalClient.php#L65):
|
||||
|
||||
```php
|
||||
public function downloadLeadsCsv(CarbonInterface $from, CarbonInterface $to): string
|
||||
{
|
||||
$response = $this->request('GET', '/admin/report/index', [
|
||||
'type' => 49,
|
||||
'from' => $from->format('Y-m-d H:i:s'),
|
||||
'to' => $to->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return $response->body();
|
||||
}
|
||||
```
|
||||
|
||||
`request()` ([line 70](../../../app/app/Services/Supplier/SupplierPortalClient.php#L70)) уже поддерживает GET + query-string + 401 retry через `RefreshSupplierSessionJob` + 5xx → `SupplierTransientException` + 4xx → `SupplierClientException`.
|
||||
|
||||
### 5.2. `SupplierCsvParser`
|
||||
|
||||
Pure парсер, streaming через generator:
|
||||
|
||||
```php
|
||||
namespace App\Services\Supplier;
|
||||
|
||||
final class SupplierCsvParser
|
||||
{
|
||||
/**
|
||||
* Ожидаемые столбцы (placeholder — открытый вопрос #4):
|
||||
* vid;project;tag;phone;phones;time
|
||||
*
|
||||
* @return iterable<int, array{vid: string, project: string, phone: string, time: int}>
|
||||
*/
|
||||
public function parse(string $rawCsv): iterable;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3. `CsvReconcileJob`
|
||||
|
||||
```php
|
||||
namespace App\Jobs\Supplier;
|
||||
|
||||
final class CsvReconcileJob implements ShouldQueue
|
||||
{
|
||||
public int $tries = 1;
|
||||
public int $timeout = 300;
|
||||
|
||||
public function handle(
|
||||
SupplierPortalClient $portal,
|
||||
SupplierCsvParser $parser,
|
||||
Mailer $mailer,
|
||||
): void;
|
||||
}
|
||||
```
|
||||
|
||||
**Алгоритм:**
|
||||
|
||||
```
|
||||
1. Cache::lock('supplier:csv_reconcile', 600) — защита от overlap'а.
|
||||
2. INSERT supplier_csv_reconcile_log (status='running', started_at=now,
|
||||
window_start=now-25h, window_end=now).
|
||||
3. csv = $portal->downloadLeadsCsv(window_start, window_end).
|
||||
4. rows = $parser->parse(csv) → собрать ['vid' => row, ...].
|
||||
5. existing = SELECT vid FROM supplier_leads WHERE received_at BETWEEN window_start AND window_end
|
||||
(через pgsql_supplier BYPASSRLS).
|
||||
6. missing = array_diff_key(csvRows, existing).
|
||||
7. drift_ratio = count(missing) / max(count(rows), 1).
|
||||
8. Для каждой missing row:
|
||||
- INSERT supplier_leads (vid, raw_payload=json_encode(row), received_at=row.time,
|
||||
recovered_from_csv_at=now, supplier_project_id=null).
|
||||
- dispatch(new RouteSupplierLeadJob($newLead->id)).
|
||||
- recovered_count++.
|
||||
9. UPDATE supplier_csv_reconcile_log:
|
||||
total_csv_rows, matched_count, recovered_count, drift_ratio, finished_at, status.
|
||||
10. if drift_ratio > 0.05:
|
||||
- status='drift_alert', $mailer->send(CsvDriftAlertMail), alert_email_sent_at=now.
|
||||
11. На исключение: status='failed', error_message, throw.
|
||||
```
|
||||
|
||||
Окно — 25h (запас 1h над hourly cron'ом). Дубли через `supplier_leads.vid UNIQUE` — INSERT упадёт `unique_violation`, ловим, считаем как matched.
|
||||
|
||||
### 5.4. Schedule entry
|
||||
|
||||
После Plan 3 entries в [routes/console.php](../../../app/routes/console.php):
|
||||
|
||||
```php
|
||||
Schedule::job(new CsvReconcileJob)->hourly();
|
||||
```
|
||||
|
||||
Без `withoutOverlapping()` (нет `cache_locks`). Защита от overlap — внутренний `Cache::lock` (шаг 1).
|
||||
|
||||
### 5.5. Recovery flow
|
||||
|
||||
`RouteSupplierLeadJob` уже идемпотентен (Plan 2.5 fix #3 `processed_at` guard). `recovered_from_csv_at` маркер нужен для:
|
||||
|
||||
1. Отчётности «webhook vs CSV» (spec §5.2).
|
||||
2. ActivityLog tag: `'source' => $lead->recovered_from_csv_at !== null ? 'supplier_csv_recovery' : 'supplier_webhook'`.
|
||||
|
||||
### 5.6. `CsvDriftAlertMail`
|
||||
|
||||
Mailable + `resources/views/emails/csv_drift_alert.blade.php`:
|
||||
|
||||
- Subject: «Лидерра ↔ Поставщик: расхождение CSV > 5% за {window}»
|
||||
- Body (русский): drift %, total_csv_rows, missing_count, recovered_count, window, ссылка на `supplier_csv_reconcile_log.id`.
|
||||
- Адресат: `config('services.supplier.alert_email')` — **не верифицировал** существование ключа в config из Plan 3; при impl грепнем и при необходимости добавим.
|
||||
|
||||
## 6. Admin UI экраны
|
||||
|
||||
### 6.1. `/admin/pricing-tiers` (SaaS-admin)
|
||||
|
||||
**Frontend:** `resources/js/views/admin/AdminPricingTiersView.vue`. Маршрут в `resources/js/router/index.ts`:
|
||||
|
||||
```ts
|
||||
{ path: '/admin/pricing-tiers',
|
||||
name: 'admin-pricing-tiers',
|
||||
component: () => import('@/views/admin/AdminPricingTiersView.vue'),
|
||||
meta: { layout: 'app', requiresAdmin: true } },
|
||||
```
|
||||
|
||||
**UI:** Vue 3 + Vuetify 3, Forest-палитра, Inter + JetBrains Mono для цифр (`font-feature-settings:'tnum'`).
|
||||
|
||||
- `<v-card>` «Текущая активная сетка (с {effective_from})»: `<v-data-table>` 7 строк (tier_no, leads_in_tier — `NULL → «всё свыше»`, price отображается через computed «{руб},{коп}»).
|
||||
- `<v-card>` «Запланированные изменения»: будущие pricing_tiers (`effective_from > today`) + «отменить запланированное».
|
||||
- `<v-btn>` «Редактировать сетку» → `<v-dialog>` с 7-строчным редактором: `<v-text-field type="number">` для `leads_in_tier` (последний disabled = «NULL»), `<v-text-field>` для `price_rub` с 2 десятичными.
|
||||
- `effective_from`: read-only, auto = «1-е след. месяца».
|
||||
- POST `/api/admin/pricing-tiers` (создаёт 7 новых строк с одинаковым `effective_from`).
|
||||
|
||||
**Backend:** `App\Http\Controllers\Api\AdminPricingTiersController`:
|
||||
|
||||
- `GET /api/admin/pricing-tiers` — active + scheduled.
|
||||
- `POST /api/admin/pricing-tiers` — 7 новых rows с `effective_from = DATE_TRUNC('month', NOW() + INTERVAL '1 month')`. Validation: ровно 7 tiers, `tier_no` 1..7 unique, `leads_in_tier > 0` для 1..6, `leads_in_tier IS NULL` для 7, `price_per_lead_kopecks >= 0`. Single transaction.
|
||||
- `DELETE /api/admin/pricing-tiers/scheduled/{effective_from}` — отмена будущей сетки.
|
||||
|
||||
Маршрут в [routes/web.php:99](../../../app/routes/web.php#L99):
|
||||
|
||||
```php
|
||||
Route::prefix('/api/admin/pricing-tiers')->group(function () {
|
||||
Route::get('/', 'App\Http\Controllers\Api\AdminPricingTiersController@index');
|
||||
Route::post('/', 'App\Http\Controllers\Api\AdminPricingTiersController@store');
|
||||
Route::delete('/scheduled/{effective_from}',
|
||||
'App\Http\Controllers\Api\AdminPricingTiersController@deleteScheduled')
|
||||
->where('effective_from', '\d{4}-\d{2}-\d{2}');
|
||||
});
|
||||
```
|
||||
|
||||
Без auth middleware (паритет с другими `/api/admin/*`). Audit trail — `saas_admin_audit_log`.
|
||||
|
||||
### 6.2. `/admin/supplier-prices` (SaaS-admin)
|
||||
|
||||
**Frontend:** `resources/js/views/admin/AdminSupplierPricesView.vue` + маршрут `/admin/supplier-prices`.
|
||||
|
||||
**UI:** `<v-card>` + `<v-data-table>` 3 строки (B1/B2/B3): code, name, cost_rub (editable inline `<v-text-field type="number" step="0.01">`), quality_score (editable inline), is_active toggle. Кнопка «Сохранить».
|
||||
|
||||
**Backend:** `App\Http\Controllers\Api\AdminSuppliersController`:
|
||||
|
||||
- `GET /api/admin/suppliers` — список 3 строк.
|
||||
- `PATCH /api/admin/suppliers/{id}` — обновление `cost_rub` / `quality_score` / `is_active`. Audit log.
|
||||
|
||||
```php
|
||||
Route::get('/api/admin/suppliers', 'App\Http\Controllers\Api\AdminSuppliersController@index');
|
||||
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
|
||||
->where('id', '[0-9]+');
|
||||
```
|
||||
|
||||
### 6.3. Tenant-side: «Списания» tab в `BillingView`
|
||||
|
||||
Не отдельный route — **новый tab** в существующем `BillingView`. Tabs: «Баланс» + «Списания» (новый) + «Тариф» (read-only показ pricing_tiers — открытый вопрос #3: всё или только текущая ступень).
|
||||
|
||||
**UI** `resources/js/views/billing/ChargesTab.vue`:
|
||||
|
||||
- Фильтры: `<v-select>` период (текущий месяц / прошлый / 90 дней / диапазон), `<v-select>` charge_source.
|
||||
- `<v-data-table>`: charged_at, deal_id (clickable → `/deals/{id}`), tier_no, charge_source (chip), price_rub, balance_rub_after.
|
||||
- Footer: «Всего за период: {N} лидов, {sum} ₽» + кнопка «Скачать CSV».
|
||||
- Pagination server-side.
|
||||
|
||||
**Backend:** `App\Http\Controllers\Api\TenantChargesController` под `auth:sanctum + tenant`:
|
||||
|
||||
```php
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing/charges')->group(function () {
|
||||
Route::get('/', 'App\Http\Controllers\Api\TenantChargesController@index');
|
||||
Route::post('/export', 'App\Http\Controllers\Api\TenantChargesController@export');
|
||||
});
|
||||
```
|
||||
|
||||
- `GET /api/billing/charges` — paginated lead_charges WHERE tenant_id = current (RLS защищает через `SetTenantContext`). JOIN `pricing_tiers` для leads_in_tier.
|
||||
- `POST /api/billing/charges/export` — CSV через OpenSpout + `StreamedResponse` (паттерн из DealExport).
|
||||
|
||||
### 6.4. Nav-tree
|
||||
|
||||
В [AppLayout.vue](../../../app/resources/js/layouts/AppLayout.vue) — добавить пункты под admin-секцию (если `user.is_saas_admin`): «Тарифы» (`/admin/pricing-tiers`), «Поставщики» (`/admin/supplier-prices`).
|
||||
|
||||
**Не верифицировал** различение saas-admin vs tenant-user в nav-tree (связано с Б-1 / SSO). Грепнем при impl.
|
||||
|
||||
### 6.5. Stories для Histoire
|
||||
|
||||
- `AdminPricingTiersView.story.vue` — 4 variants: пустая сетка / активная / scheduled / dialog open.
|
||||
- `AdminSupplierPricesView.story.vue` — 2 variants: default / editing row.
|
||||
- `billing/ChargesTab.story.vue` — 3 variants: пустой / mixed prepaid+rub / только rub.
|
||||
|
||||
### 6.6. A11y и стиль
|
||||
|
||||
- Vuetify-компоненты покрывают keyboard nav + ARIA.
|
||||
- Числа — JetBrains Mono с `font-feature-settings:'tnum'`.
|
||||
- Pa11y проверка после impl — 0 violations.
|
||||
|
||||
## 7. Тестовая стратегия и Acceptance Criteria
|
||||
|
||||
### 7.1. Сводка тестов
|
||||
|
||||
| Категория | Слой | Кол-во |
|
||||
|---|---|---|
|
||||
| Pure unit на `PricingTierResolver` | tests/Unit/Billing | 7 |
|
||||
| Integration `LedgerService::chargeForDelivery` | tests/Feature/Billing | 6 |
|
||||
| E2E `RouteSupplierLeadJob` с биллингом | tests/Feature/Supplier | 4 |
|
||||
| `ResetMonthlyCountersCommand` | tests/Feature/Console | 4 |
|
||||
| Auto-pause flow | tests/Feature/Supplier | 5 |
|
||||
| `SupplierCsvParser` | tests/Unit/Supplier | 5 |
|
||||
| `SupplierPortalClient::downloadLeadsCsv` | tests/Unit/Supplier | 3 |
|
||||
| `CsvReconcileJob` integration | tests/Feature/Supplier | 6 |
|
||||
| E2E CSV happy-path mock-server | tests/Browser (Linux CI only) | 1 |
|
||||
| `AdminPricingTiersController` | tests/Feature/Admin | 8 |
|
||||
| `AdminSuppliersController` | tests/Feature/Admin | 4 |
|
||||
| `TenantChargesController` | tests/Feature/Billing | 6 |
|
||||
| Frontend Vitest на 3 Vue компонента | resources/js/.../spec.ts | 12 |
|
||||
| **Итого новых тестов Plan 4** | | **71** |
|
||||
|
||||
После Plan 4 baseline: ориентировочно **688 Pest + 405 Vitest** (фактическое число подтвердится при impl).
|
||||
|
||||
### 7.2. Pre-commit (lefthook) gates
|
||||
|
||||
Без изменений в `lefthook.yml`: Pint / Larastan / squawk / pgFormatter / cspell / gitleaks / markdownlint — встраивается в существующие jobs. Larastan baseline вероятно расширится на ~5-10 entries.
|
||||
|
||||
### 7.3. Acceptance criteria
|
||||
|
||||
| AC | Описание | Verification |
|
||||
|---|---|---|
|
||||
| **AC-1** | Schema v8.18 → v8.19: +1 таблица, +3 колонки, +3 индекса, +2 CHECK. | `migrate:fresh` зелёный; `db/CHANGELOG_schema.md`. |
|
||||
| **AC-2** | `RouteSupplierLeadJob` пишет `lead_charges` + `supplier_lead_costs` в одной транзакции с Deal. | 4 E2E теста (см. §7.1). |
|
||||
| **AC-3** | Dual-balance: balance_leads-first, balance_rub-second; tier через `delivered_in_month + 1`; bcmath. | 6 LedgerService тестов (см. §7.1). |
|
||||
| **AC-4** | `ResetMonthlyCountersCommand` + `monthlyOn(1, '00:00')` + Europe/Moscow. | 4 Console-теста + `schedule:list`. |
|
||||
| **AC-5** | Insufficient balance → exception → rollback → `is_active=false` + email (1/час/tenant). | 5 auto-pause тестов. |
|
||||
| **AC-6** | `CsvReconcileJob` hourly, окно 25h, drift > 5% → email, лог в `supplier_csv_reconcile_log`. | 6 integration + 1 E2E. |
|
||||
| **AC-7** | Admin UI: 7-tier editor, 3-row supplier prices, ChargesTab + CSV export. | 18 backend + 12 frontend + Histoire +9 variants + Pa11y 0. |
|
||||
| **AC-8** | Retry-idempotency: повторный run job'а не пишет дубль `lead_charges`. | 1 retry-idempotency тест. |
|
||||
| **AC-9** | Атомарные коммиты: 1 commit = 1 logical change. | git log review. |
|
||||
| **AC-10** | 0 schema-orphan FK / 0 duplicate CREATE TABLE / RLS-метрики 39. | self-review per Pravila §4.6. |
|
||||
|
||||
### 7.4. Verification gate перед merge (CV-pattern)
|
||||
|
||||
1. `composer pint` — clean diff.
|
||||
2. `composer stan` — 0 errors above baseline.
|
||||
3. `composer test` — все Pest зелёные.
|
||||
4. `npm run lint:vue` — clean.
|
||||
5. `npm run type-check` — 0 errors.
|
||||
6. `npm run test:vue` — все Vitest зелёные.
|
||||
7. `npm run story` build — все variants собираются.
|
||||
8. `php artisan migrate:fresh --database=liderra_testing` + RLS smoke + model smoke.
|
||||
9. `npm run a11y` — 0 violations.
|
||||
10. `gitleaks detect --no-banner` full history — 0 leaks.
|
||||
11. `npm run links` (lychee) — 0 broken.
|
||||
12. code-reviewer subagent → no BLOCKER.
|
||||
13. Manual smoke tinker `RouteSupplierLeadJob`.
|
||||
14. Manual UI smoke: pricing-tiers editor.
|
||||
|
||||
### 7.5. Risks / mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| `delivered_in_month` race 2 concurrent deliveries | `lockForUpdate(Tenant)` уже держится; tier-lookup внутри lock → safe. |
|
||||
| PHP float drift при price/100 | `bcdiv($kopecks, '100', 2)` — string-math; `decimal:2` cast. |
|
||||
| CSV-схема расходится с реальной (Tasks 1-2 BLOCKED) | Schema parser placeholder; Http::fake тесты; impl Task 0 = manual curl после credentials. |
|
||||
| Reset cron не запустился вовремя | Idempotent UPDATE; manual `php artisan projects:reset-monthly`. |
|
||||
| Redis crash → Cache::add false → ноль алертов | Log::warning всегда; observability-alert out of Plan 4. |
|
||||
| Pricing-tier editor: tier 7 c `leads_in_tier ≠ NULL` | Validation на бэке: ровно одна row с NULL = tier 7. |
|
||||
| `charge_source='prepaid'` + `price ≠ 0` | CHECK `chk_lead_charges_prepaid_zero_price`. |
|
||||
|
||||
### 7.6. Открытые вопросы
|
||||
|
||||
| # | Вопрос | Кто решает | Дефолт |
|
||||
|---|---|---|---|
|
||||
| 1 | Дефолтные tier-цены (500/450/400/350/300/270/250 руб) | Заказчик | placeholder, ждём согласования перед seed'ом |
|
||||
| 2 | Email rate-limit 1/час/tenant — норма? | Заказчик | 1/час |
|
||||
| 3 | Tenant видит ВСЕ ступени или только свою? | Заказчик | transparent (все) |
|
||||
| 4 | CSV-схема (точные столбцы из `/admin/report/index?type=49`) | Discovery после credentials | placeholder webhook-payload |
|
||||
| 5 | CSV окно (25h) — норма? | Заказчик/опыт | 25h |
|
||||
| 6 | Drift threshold 5% — норма? | Заказчик/опыт | 5% |
|
||||
| 7 | Pricing-tier-change при повышении цены | Заказчик | единая логика с понижением (effective 1-е след. месяца) |
|
||||
|
||||
Эти 7 — попадают в Открытые_вопросы реестра как новые `Биз-*` после approval'а спецификации.
|
||||
|
||||
### 7.7. Декомпозиция на impl-задачи (preview)
|
||||
|
||||
Детальный impl-plan будет написан через skill `superpowers:writing-plans` после approval'а spec'а. Preview:
|
||||
|
||||
1. **Task 1:** Schema delta v8.18 → v8.19.
|
||||
2. **Task 2:** `PricingTierResolver` (pure unit, TDD).
|
||||
3. **Task 3:** `LedgerService::chargeForDelivery` (integration, TDD).
|
||||
4. **Task 4:** Integration `LedgerService` в `RouteSupplierLeadJob`.
|
||||
5. **Task 5:** `ResetMonthlyCountersCommand` + Schedule entry.
|
||||
6. **Task 6:** Auto-pause flow + `ZeroBalancePausedMail` + rate-limit.
|
||||
7. **Task 7:** `SupplierPortalClient::downloadLeadsCsv` + `SupplierCsvParser`.
|
||||
8. **Task 8:** `CsvReconcileJob` + `supplier_csv_reconcile_log` + `CsvDriftAlertMail`.
|
||||
9. **Task 9:** `AdminPricingTiersController` backend + Vue view + Histoire.
|
||||
10. **Task 10:** `AdminSuppliersController` backend + Vue view + Histoire.
|
||||
11. **Task 11:** `TenantChargesController` + ChargesTab + CSV export + Histoire.
|
||||
12. **Task 12:** Verification gate (full CV) + code-review subagent.
|
||||
|
||||
12 Tasks. TDD: red → green → refactor; атомарный коммит на Task.
|
||||
|
||||
## 8. Ограничения и неверифицированное
|
||||
|
||||
В соответствии с экономия-0% дисциплиной фиксирую явно, что **не верифицировано** на момент написания spec'а:
|
||||
|
||||
- `LeadRouter::matchEligibleProjects` действительно фильтрует `WHERE is_active = true` — будет проверено грепом и feature-тестом при impl.
|
||||
- `config('services.supplier.alert_email')` ключ уже существует из Plan 3 — будет проверено грепом при impl.
|
||||
- `AppLayout.vue` различает saas-admin vs tenant-user nav-tree — будет проверено грепом при impl.
|
||||
- Точное число Pest baseline после Plan 4 (688) — округлённое; финальное число — после запуска `composer test`.
|
||||
- Vuetify `<v-data-table>` cells применяют `font-feature-settings:'tnum'` без кастомного slot'а — будет проверено визуально при impl.
|
||||
- OpenSpout `StreamedResponse` паттерн в проекте — упомянут в memory `feedback_environment.md`, но конкретный пример код в DealExport не перечитывал; найдём при impl.
|
||||
|
||||
## 9. Источник истины и cross-refs
|
||||
|
||||
- Spec Plan 4 (этот файл): docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md
|
||||
- Parent spec: [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](./2026-05-10-supplier-integration-design.md) §5.2, §7
|
||||
- Schema: [db/schema.sql](../../../db/schema.sql) (v8.18; будет v8.19 после impl)
|
||||
- CLAUDE.md §6 (текущая фаза) — будет обновлён после merge Plan 4
|
||||
- Pravila §4.5 (3 варианта) — overridden brainstorming-skill согласно §11.1 + §12
|
||||
@@ -1,132 +0,0 @@
|
||||
# Редизайн страницы «Сделки» — дизайн
|
||||
|
||||
**Дата:** 2026-05-17
|
||||
**Статус:** дизайн утверждён заказчиком; спека на ревью.
|
||||
**Макет:** `web/v8/deals-final.html` (вариант A, финал). Сравнение вариантов — `web/v8/deals-variants.html`.
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
Текущая страница «Сделки» (`resources/js/views/DealsView.vue`) построена как обычная CRM: ручное создание сделки, корзина, mock-данные, колонки «Менеджер»/«Стоимость». Это неверно: лиды поступают в портал **только** от поставщика crm.bp по заказанным в проектах источникам (B1/B2/B3 × тип сигнала). Сделку нельзя создать вручную и нельзя удалить — у неё есть только статус.
|
||||
|
||||
Конкретные ошибки текущей реализации:
|
||||
|
||||
- Кнопка «Новая сделка» + `NewDealDialog` — [DealsView.vue:542-551](../../../app/resources/js/views/DealsView.vue#L542-L551), [:741](../../../app/resources/js/views/DealsView.vue#L741).
|
||||
- Режим «Корзина» (soft-delete) — [DealsView.vue:524-532](../../../app/resources/js/views/DealsView.vue#L524-L532).
|
||||
- Mock-данные — `mockDeals.ts` ([DealsView.vue:18](../../../app/resources/js/views/DealsView.vue#L18)).
|
||||
- Колонки «Менеджер»/«Стоимость» и две дублирующие фильтр-панели — [DealsTable.vue:57-64](../../../app/resources/js/components/deals/DealsTable.vue#L57-L64), [DealsView.vue:559-687](../../../app/resources/js/views/DealsView.vue#L559-L687).
|
||||
|
||||
## 2. Утверждённый дизайн
|
||||
|
||||
Страница — **реестр лидов, поставленных crm.bp**. Ручного создания и корзины нет.
|
||||
|
||||
**Шапка:** заголовок «Сделки» + статистика + кнопка «Обновить».
|
||||
|
||||
**Панель экспорта:** диапазон дат поставки (`от` — `до`) + кнопки «Экспорт в Excel» и «Экспорт в CSV». Диапазон фильтрует таблицу и задаёт окно экспорта.
|
||||
|
||||
**Фильтры:** поиск по телефону, Статус, Проект, Город.
|
||||
|
||||
**Выбор числа строк:** 10 / 20 / 50 — между фильтрами и таблицей.
|
||||
|
||||
**Таблица** (колонки): чекбокс · Телефон · Источник · Город · Статус · Напоминание · Комментарий · Поставлен.
|
||||
|
||||
- **Телефон** — только номер (ФИО убрано).
|
||||
- **Источник** — название проекта + тип сигнала (Звонки/Сайт/СМС); B1/B2/B3 не показываются.
|
||||
- **Город** — см. §4 (открытый вопрос источника).
|
||||
- **Статус** — одна из 5 (см. §3).
|
||||
- Колонка «Ответственный» убрана; «Отгружен» переименован в «Поставлен».
|
||||
|
||||
**Панель сделки (master-detail):** клик по строке открывает панель с деталями сделки **справа от списка**. Список при этом **сжимается влево**, освобождая место — панель **не перекрывает** таблицу (в отличие от текущего overlay-дровера). Повторный клик / кнопка ✕ — закрыть. Содержимое панели: телефон, статус, источник (проект+тип), город, дата поставки, напоминание, комментарий, история сделки.
|
||||
|
||||
**Футер:** пагинация.
|
||||
|
||||
**Сайдбар** (отдельная правка, уже внесена): из nav убраны ссылки «Импорт данных» и «Отчёты» — `AppSidebar.vue`, тест `AppLayout.spec.ts` обновлён.
|
||||
|
||||
## 3. Воронка статусов: 14 → 5
|
||||
|
||||
Заказчик пересматривает воронку: вместо 14 статусов — **5**:
|
||||
|
||||
1. **Новая сделка** — присваивается при поступлении лида от crm.bp.
|
||||
2. **Просмотрено**
|
||||
3. **В работе**
|
||||
4. **Сделка** — успешно закрыта.
|
||||
5. **Не реализовано** — закрыта без результата.
|
||||
|
||||
Это самый крупный затрагиваемый блок (work-stream B, см. §6). Каскад:
|
||||
|
||||
- Схема: `lead_statuses` / воронка (`db/schema.sql` р-н строки 2076) — изменение справочника статусов; миграция с записью в `db/CHANGELOG_schema.md`.
|
||||
- `composables/leadStatuses.ts` + `stores/leadStatuses` — новый набор.
|
||||
- `mockDeals.ts` `DEALS_TABS` — срезы по статусам.
|
||||
- Страница **Канбан** — колонки строятся из статусов → 5 колонок вместо 14.
|
||||
- Фильтр по статусу + массовая смена статуса (`DealsBulkBar`, `DealBulkActionController@transition`, `dealsApi.transitionDeals`).
|
||||
- `StatusRuToSlugMapper` (Sprint 4, импорт исторических лидов) — маппинг русских статусов поставщика на slug'и пересматривается под 5.
|
||||
|
||||
**Предлагаемый маппинг старых 14 → новых 5** (на подтверждение в фазе плана):
|
||||
|
||||
| Старый | Новый |
|
||||
|---|---|
|
||||
| Новый | Новая сделка |
|
||||
| Просмотрено | Просмотрено |
|
||||
| Проработан, База, Недозвон, Переговоры, Ожидаем оплаты, Партнёрка, Тест-драйв, Горячий, На замену, Конечный недозвон | В работе |
|
||||
| Оплачено | Сделка |
|
||||
| Закрыто и не реализовано | Не реализовано |
|
||||
|
||||
Слуги новых статусов и точный маппинг — фиксируются в плане после явного подтверждения заказчиком.
|
||||
|
||||
## 4. Открытый вопрос — источник данных для колонки «Город»
|
||||
|
||||
crm.bp **не передаёт** город/регион ни по одному каналу ингеста:
|
||||
|
||||
- вебхук `SupplierWebhookController@receive` валидирует только `vid/project/phone/time/tag/phones`;
|
||||
- CSV-импорт (`Services/Import/*`) гео-полей не читает.
|
||||
|
||||
Поставщик доработку делать не будет. Из телефона регион не вывести (`PhonePrefixService` для мобильных 9XX → «неизвестно»). В схеме `deals.region_code` + `deals.city` есть (Биз-23), но заполнять нечем.
|
||||
|
||||
**Решение:** заказчик определит источник заполнения «Города» отдельно. До этого колонка в реализацию не берётся (рендерится пустой/`—`). Вопрос зафиксирован: memory `project_deals_page_redesign.md`, напоминание 18.05.
|
||||
|
||||
## 5. Затрагиваемые компоненты
|
||||
|
||||
**Frontend:**
|
||||
|
||||
- `views/DealsView.vue` — убрать `NewDealDialog`/«Новая сделка», trash-режим, дублирующую `.ld-filterbar`; перевести на реальный API; добавить панель экспорта по датам и выбор числа строк.
|
||||
- `components/deals/DealsTable.vue` — новый набор колонок.
|
||||
- `components/deals/DealsFilters.vue` — фильтры Статус/Проект/Город; убрать «Менеджер».
|
||||
- `components/deals/DealsBulkBar.vue` — массовая смена статуса на 5 значений.
|
||||
- `composables/mockDeals.ts` — снять mock-зависимость (реальные данные из API).
|
||||
- `composables/leadStatuses.ts` / `stores/leadStatuses` — 5 статусов.
|
||||
- `api/deals.ts` — список с фильтром по диапазону дат поставки, пагинацией `per_page`, полем города.
|
||||
- `components/deals/DealDetailDrawer.vue` — переделать из overlay-дровера в боковую панель master-detail: список сжимается, панель встаёт рядом (flex split-pane), не перекрывает таблицу. Содержимое карточки переиспользуется.
|
||||
|
||||
**Backend:**
|
||||
|
||||
- `DealController@index` — пагинация `per_page` (10/20/50), фильтр `received_at BETWEEN`, фильтр по городу/статусу/проекту.
|
||||
- `DealExportController` (`POST /api/deals/export`) — экспорт по диапазону дат, XLSX + CSV.
|
||||
- `db/schema.sql` — справочник статусов воронки 14→5 (+ `db/CHANGELOG_schema.md`).
|
||||
- `RouteSupplierLeadJob` — присваивает статус «Новая сделка» новым лидам (сейчас `'new'`).
|
||||
- `StatusRuToSlugMapper` — маппинг под 5 статусов.
|
||||
|
||||
## 6. Декомпозиция и порядок
|
||||
|
||||
Два work-stream'а:
|
||||
|
||||
- **A — редизайн страницы** (frontend + `DealController`/`DealExportController`): колонки, фильтры, экспорт, пагинация, удаление ручного создания/корзины. Не зависит от B при условии, что A временно работает на текущем наборе статусов.
|
||||
- **B — воронка 14→5** (схема + миграция + Канбан + маппинг импорта). Schema-change — гейт на явное подтверждение маппинга §3.
|
||||
|
||||
Рекомендация для плана: A и B — отдельными фазами; B стартует после подтверждения маппинга. Колонка «Город» — вне реализации до решения §4.
|
||||
|
||||
## 7. Вне scope
|
||||
|
||||
- Заполнение колонки «Город» данными — до решения по источнику (§4).
|
||||
- Inline-редактирование напоминания/комментария в строке (было в варианте B макета) — вариант A оставляет их read-only.
|
||||
- Содержимое карточки сделки (поля, история) не пересматривается — переиспользуется как есть; меняется только подача: overlay-дровер → боковая панель master-detail (§2, §5).
|
||||
|
||||
## 8. Тестирование
|
||||
|
||||
- TDD на каждый блок: Vitest (DealsView/DealsTable/DealsFilters/DealsBulkBar), Pest (`DealController@index` пагинация/фильтры, `DealExportController` диапазон дат, миграция статусов — db-тесты).
|
||||
- После изменения схемы статусов — db-тесты + smoke.
|
||||
- Регрессия Vitest + Pest перед коммитом.
|
||||
|
||||
## 9. Открытые вопросы для фазы плана
|
||||
|
||||
1. Источник данных для «Города» (§4) — ждём заказчика.
|
||||
2. Точные slug'и 5 статусов и маппинг старых 14 (§3) — подтвердить.
|
||||
3. ~~Судьба маршрутов `/import` и `/reports`~~ — **РЕШЕНО (2026-05-17): вариант А** — маршруты и вью остаются доступными по прямому URL; из nav-сайдбара ссылки убраны (уже сделано).
|
||||
@@ -1,217 +0,0 @@
|
||||
# Резервный CSV-канал импорта лидов (Путь 2) — дизайн
|
||||
|
||||
**Дата:** 18.05.2026
|
||||
**Статус:** утверждён заказчиком
|
||||
**Эпик:** интеграция с поставщиком crm.bp-gr.ru — резервный канал
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Webhook (Путь 3) — основной живой канал поступления лидов от поставщика crm.bp-gr.ru.
|
||||
Доказан вживую 18.05.2026 (112 реальных лидов). Но живой провод может оборваться —
|
||||
сбой сети, смена webhook-URL на стороне поставщика, падение нашего портала.
|
||||
|
||||
CSV-канал (Путь 2) — **страховка**: периодическая сверка отчёта поставщика «Запрос
|
||||
номеров» с тем, что реально приняли через webhook. Лиды, которых нет в нашей базе, —
|
||||
подбираются (recovery). Если webhook жив, сверка стабильно находит 0 пропущенных.
|
||||
|
||||
CSV-канал НЕ заменяет webhook и не работает параллельным потоком — он только
|
||||
**добирает пропущенное** и **сигнализирует**, что webhook теряет лиды.
|
||||
|
||||
## 2. Источник данных
|
||||
|
||||
Отчёт поставщика **«Запрос номеров»** (`/admin/report/index`, self-service dropdown).
|
||||
|
||||
- Формат: **CSV**, разделитель `;`, заголовок `Name;Tag;Phone` — 3 колонки.
|
||||
- `Name` — имя проекта поставщика, формат `B<N>_<rest>` (как в webhook `raw_payload['project']`).
|
||||
- `Tag` — тег проекта (клиент/рекламодатель).
|
||||
- `Phone` — номер телефона лида.
|
||||
- **Нет** `vid` (id лида), **нет** времени отгрузки.
|
||||
- Генерация **асинхронная**: заказать отчёт за диапазон дат → отчёт строится (секунды) →
|
||||
появляется в «Списке отчётов» со статусом «Обработан» → скачать `/admin/report/getfile?id=N`.
|
||||
|
||||
Разведано 18.05.2026 (см. memory `reference_supplier_crm.md` §«Отчёты / выгрузки»).
|
||||
Альтернатива «Выгрузка данных» (XLSX, 10 колонок с id и временем) — НЕ в self-service
|
||||
dropdown, генерируется иначе; отвергнута как источник — «Запрос номеров» легче и доступен.
|
||||
|
||||
## 3. Поток данных
|
||||
|
||||
Каждые 30 минут (Laravel scheduler) **и** по кнопке «Сверить сейчас»:
|
||||
|
||||
```
|
||||
1. Cache::lock('supplier:csv_reconcile', 600s) — overlap-защита (skip если занят).
|
||||
2. INSERT supplier_csv_reconcile_log (status='running').
|
||||
3. SupplierPortalClient.requestNumbersReport(from, to) → report_id.
|
||||
from..to = окно (вчера 00:00 .. сегодня 23:59 — 2 календарных дня).
|
||||
4. SupplierPortalClient.waitReportReady(report_id) — polling статуса «Обработан».
|
||||
5. SupplierPortalClient.downloadReport(report_id) → raw CSV.
|
||||
6. SupplierCsvParser.parse(csv) → строки {project, tag, phone}.
|
||||
7. Дедуп: для каждой CSV-строки ключ (phone, project). SELECT существующих
|
||||
supplier_leads за окно → set ключей. Строки вне set → missing.
|
||||
8. Для каждой missing:
|
||||
- INSERT supplier_leads (vid=NULL, source='csv_recovery',
|
||||
recovered_from_csv_at=now(), received_at=now(), raw_payload=строка).
|
||||
- dispatch RouteSupplierLeadJob(lead_id).
|
||||
9. drift_ratio = missing / total_csv_rows.
|
||||
10. UPDATE supplier_csv_reconcile_log: total/matched/recovered/drift/status.
|
||||
11. drift > 5% → CsvDriftAlertMail + плашка + колокольчик.
|
||||
12. На любом исключении — status='failed', error_message, throw (cron повторит через 30 мин).
|
||||
```
|
||||
|
||||
## 4. Компоненты
|
||||
|
||||
### 4.1. `SupplierCsvParser` — переписать (rework)
|
||||
|
||||
Текущая версия ([app/app/Services/Supplier/SupplierCsvParser.php](../../../app/app/Services/Supplier/SupplierCsvParser.php))
|
||||
написана под неверную догадку: 6 колонок `vid;project;tag;phone;phones;time`.
|
||||
|
||||
Новая версия:
|
||||
|
||||
- Заголовок `Name;Tag;Phone` — 3 колонки.
|
||||
- BOM (UTF-8 `EF BB BF`) + CRLF→LF — сохранить.
|
||||
- Streaming-generator — сохранить.
|
||||
- Malformed-строки (< 3 колонок) — skip + `Log::warning` — сохранить.
|
||||
- Возвращает `iterable<array{project: string, tag: string, phone: string}>` — без `vid`/`time`.
|
||||
|
||||
### 4.2. `CsvReconcileJob` — переписать (rework)
|
||||
|
||||
Текущая версия ([app/app/Jobs/Supplier/CsvReconcileJob.php](../../../app/app/Jobs/Supplier/CsvReconcileJob.php))
|
||||
строит дедуп на `vid` (`$csvByVid`, `array_diff_key`), вызывает синхронный
|
||||
`downloadLeadsCsv` — оба невозможны на реальном CSV.
|
||||
|
||||
Новая версия:
|
||||
|
||||
- Async-флоу заказа отчёта (шаги 3–5 §3) вместо `downloadLeadsCsv`.
|
||||
- Дедуп по `(phone, project)` вместо `vid` (§5).
|
||||
- Окно — `WINDOW_DAYS = 2` календарных дня (вчера + сегодня).
|
||||
- `Cache::lock` overlap-защита — сохранить.
|
||||
- `supplier_csv_reconcile_log` запись — сохранить (status CHECK `running/ok/drift_alert/failed`
|
||||
уже существует, не меняется).
|
||||
- drift-порог `0.05` — сохранить.
|
||||
- vid у создаваемых `supplier_leads` — `NULL` (см. §6).
|
||||
|
||||
### 4.3. `SupplierPortalClient` — расширить
|
||||
|
||||
Добавить 3 метода (auth/retry семантика — от приватного `request()`, как у существующих):
|
||||
|
||||
- `requestNumbersReport(CarbonInterface $from, CarbonInterface $to): int`
|
||||
— POST-запрос генерации отчёта «Запрос номеров» за диапазон → возвращает `report_id`.
|
||||
- `waitReportReady(int $reportId): void`
|
||||
— polling статуса отчёта до «Обработан»; таймаут (напр. 60s) → `SupplierTransientException`.
|
||||
- `downloadReport(int $reportId): string`
|
||||
— GET `/admin/report/getfile?id=N` → raw CSV-body.
|
||||
|
||||
Метод `downloadLeadsCsv` — удалить (единственный потребитель — старый `CsvReconcileJob`;
|
||||
проверить grep при реализации).
|
||||
|
||||
> **Discovery-оговорка:** точные имена endpoint'ов и поля payload отчёта «Запрос
|
||||
> номеров» — placeholder (как было с `rt-*` endpoints в §4.4 spec'а 2026-05-10).
|
||||
> Уточняются на реальном портале при реализации; при расхождении — fixup-commit.
|
||||
|
||||
### 4.4. UI — страница «Интеграция с поставщиком» (админ-часть)
|
||||
|
||||
Новый экран в админ-части портала:
|
||||
|
||||
- **Здоровье канала:** дата/статус последней сверки, текущий `drift %`, индикатор
|
||||
«webhook live / webhook down» (down — если последняя сверка дала drift > 5%).
|
||||
- **Кнопка «Сверить сейчас»** → `POST` → dispatch `CsvReconcileJob` вручную (вне расписания).
|
||||
- **История сверок** — таблица из `supplier_csv_reconcile_log` (последние N записей:
|
||||
время, окно, total/matched/recovered, drift, статус).
|
||||
|
||||
### 4.5. Расписание
|
||||
|
||||
`CsvReconcileJob` — в Laravel scheduler `everyThirtyMinutes()`.
|
||||
Ручной запуск кнопкой — всегда доступен независимо от расписания.
|
||||
|
||||
## 5. Дедуп
|
||||
|
||||
CSV «Запрос номеров» не содержит `vid` и времени → дедуп по `vid` невозможен.
|
||||
|
||||
**Ключ дедупа: `(phone, project)`** за окно сверки.
|
||||
|
||||
- `project` — поле `B<N>_<rest>` из CSV-строки (= `raw_payload['project']` у webhook-лидов).
|
||||
- Существующие `supplier_leads` за окно (по `received_at`) → set ключей `phone|project`.
|
||||
- CSV-строка, чьего ключа нет в set → missing → recovery.
|
||||
|
||||
**Известное ограничение:** если за окно поступило две *разных* заявки с одним телефоном
|
||||
на один проект — CSV-канал подберёт только одну (вторую сочтёт уже принятой). Это
|
||||
приемлемо, потому что:
|
||||
|
||||
1. CSV-канал — резервный, его задача — не потерять лид, а не точный аудит.
|
||||
2. Даже если бы CSV создал второй `supplier_lead` — `RouteSupplierLeadJob` через
|
||||
`DuplicateDetector::findMaster` (phone + окно 24ч) пометил бы его дублем
|
||||
**без списания баланса**. Деньги защищены на уровне routing, не дедупа CSV.
|
||||
|
||||
## 6. Миграция: `supplier_leads.vid` → nullable
|
||||
|
||||
Сейчас `supplier_leads.vid` — `bigint NOT NULL` с `idx_supplier_leads_vid_unique`
|
||||
(UNIQUE-индекс). CSV «Запрос номеров» vid не содержит.
|
||||
|
||||
**Миграция:** `ALTER TABLE supplier_leads ALTER COLUMN vid DROP NOT NULL`.
|
||||
|
||||
- Webhook-лиды — без изменений (настоящий `vid` от поставщика).
|
||||
- CSV-recovered лиды — `vid = NULL`.
|
||||
- UNIQUE-индекс `idx_supplier_leads_vid_unique` **остаётся**: в PostgreSQL несколько
|
||||
строк с `vid = NULL` не нарушают UNIQUE (NULL ≠ NULL) — много CSV-лидов с NULL сосуществуют.
|
||||
- `RouteSupplierLeadJob` пишет `Deal.source_crm_id = lead.vid` → для CSV-лида `NULL`.
|
||||
`Deal.source_crm_id` уже nullable (webhook-only поле). Дедуп сделок — `DuplicateDetector`
|
||||
по phone+окну, не по `source_crm_id` — не ломается.
|
||||
|
||||
Запись в `db/CHANGELOG_schema.md` обязательна (правило §4.2). RLS на `supplier_leads`
|
||||
не меняется (миграция трогает только nullability колонки).
|
||||
|
||||
## 7. Drift-детект и уведомления
|
||||
|
||||
`drift_ratio = missing_count / total_csv_rows`.
|
||||
|
||||
- `drift ≤ 5%` → status `ok` (webhook здоров; recovery подобрал мелочь — норма).
|
||||
- `drift > 5%` → status `drift_alert` → webhook систематически теряет лиды:
|
||||
- `CsvDriftAlertMail` ([app/app/Mail/CsvDriftAlertMail.php](../../../app/app/Mail/CsvDriftAlertMail.php) — уже существует).
|
||||
- Плашка в портале + колокольчик (in-app notification).
|
||||
- **Письмо фактически отправляется только после настройки почты (Б-1)** — до этого
|
||||
Mailable формируется, но доставка зависит от mail-конфига; плашка+колокольчик работают сразу.
|
||||
|
||||
## 8. Error handling
|
||||
|
||||
| Сбой | Поведение |
|
||||
|---|---|
|
||||
| Playwright-логин упал / сессия не обновилась | `SupplierAuthException` → `reconcile_log` status=`failed`, throw, cron повторит |
|
||||
| Портал поставщика 5xx | `SupplierTransientException` → status=`failed`, throw |
|
||||
| Отчёт не дошёл до «Обработан» за таймаут | `SupplierTransientException` → status=`failed` |
|
||||
| Параллельный запуск (cron + кнопка) | `Cache::lock` занят → skip с `Log::info`, без записи в log |
|
||||
| CSV-строка с непарсимым `project` | skip + `Log::warning`, остальные обрабатываются |
|
||||
| `vid`-коллизия (CSV-лид совпал с webhook-vid) | невозможна — CSV-лиды имеют `vid=NULL` |
|
||||
|
||||
`$tries = 1` у `CsvReconcileJob` — повтор обеспечивает cron каждые 30 мин, не retry-механизм.
|
||||
|
||||
## 9. Тестирование
|
||||
|
||||
- **`SupplierCsvParser`** (Pest unit): 3-колоночный заголовок, BOM, CRLF, malformed-строки skip,
|
||||
пустой CSV, корректный generator-выход.
|
||||
- **`CsvReconcileJob`** (Pest feature): дедуп phone+project (missing подобран, существующий
|
||||
пропущен), drift расчёт + порог 5%, missing → `RouteSupplierLeadJob` dispatched,
|
||||
overlap-lock skip, fail-path (status=`failed` на исключении), drift_alert → Mailable.
|
||||
- **`SupplierPortalClient`** новые методы — HTTP-факт мокается (`Http::fake`), как у существующих.
|
||||
- **Миграция vid** (Pest feature/db): `supplier_leads` принимает `vid=NULL`; несколько
|
||||
NULL-строк сосуществуют под UNIQUE-индексом; webhook-путь с настоящим vid не сломан.
|
||||
- **UI** (Vitest): страница «Интеграция с поставщиком» рендерит здоровье/историю;
|
||||
кнопка «Сверить сейчас» вызывает endpoint.
|
||||
- Регрессия: существующие `RouteSupplierLeadJobTest`, `SupplierWebhookTest`,
|
||||
`CsvReconcileJobTest` (последний переписывается под новый дедуп).
|
||||
|
||||
## 10. Что НЕ входит (YAGNI)
|
||||
|
||||
- Параллельный CSV-поток как равноправный канал — нет, только recovery.
|
||||
- Импорт исторических лидов — отдельный флоу (Sprint 4, `HistoricalImportService`), не трогаем.
|
||||
- Региона/города из CSV — у поставщика гео-данных нет (см. открытый вопрос «Город»).
|
||||
- Ретрай-механизм внутри job — повтор обеспечивает 30-мин cron.
|
||||
|
||||
## 11. Журнал решений
|
||||
|
||||
| # | Решение | Обоснование |
|
||||
|---|---|---|
|
||||
| 1 | Источник — отчёт «Запрос номеров» (CSV 3 колонки) | Лёгкий, в self-service dropdown; «Выгрузка данных» XLSX — не self-service |
|
||||
| 2 | Частота авто-сверки — 30 минут | Баланс свежести подхвата и нагрузки на портал поставщика (подтв. заказчиком 18.05) |
|
||||
| 3 | Окно — 2 календарных дня (вчера+сегодня) | CSV без времени; покрывает скользящие 24ч независимо от гранулярности диапазона портала |
|
||||
| 4 | Дедуп по (phone, project) | CSV не содержит vid; деньги защищены DuplicateDetector в RouteJob |
|
||||
| 5 | `vid` → nullable (миграция) | CSV не даёт vid; NULL-ы не конфликтуют под UNIQUE-индексом (PG semantics) |
|
||||
| 6 | Письмо drift-alert — после Б-1 | Почта не настроена; плашка+колокольчик работают сразу |
|
||||
@@ -1,223 +0,0 @@
|
||||
# Резервный канал миграции проектов Лидерра → поставщик — дизайн
|
||||
|
||||
**Дата:** 19.05.2026
|
||||
**Статус:** утверждён заказчиком (4 секции согласованы поэтапно)
|
||||
**Эпик:** интеграция с поставщиком crm.bp-gr.ru — резерв канала проектов
|
||||
**Связано:** [2026-05-18-supplier-csv-reconcile-channel-design.md](2026-05-18-supplier-csv-reconcile-channel-design.md) (зеркальная архитектура: тот резервирует ВХОДЯЩЕЕ направление — лиды, этот резервирует ИСХОДЯЩЕЕ — миграцию проектов)
|
||||
|
||||
## 1. Цель
|
||||
|
||||
`SyncSupplierProjectJob` (singular, on-edit) и `SyncSupplierProjectsJob` (plural, ночной крон) сегодня мигрируют проект Лидерра → поставщик crm.bp-gr.ru только через AJAX `rt-project-save/update/delete` ([SupplierPortalClient.php:103-121](../../../app/app/Services/Supplier/SupplierPortalClient.php#L103-L121)). Этот канал — единственный, и он:
|
||||
|
||||
- В коде помечен как **placeholder, на реальном портале не верифицирован** ([SupplierPortalClient.php:24-28](../../../app/app/Services/Supplier/SupplierPortalClient.php#L24-L28)).
|
||||
- Реверс-инжиниринг внутреннего AJAX «Мои проекты» — может сломаться при правках портала без предупреждения (это не публичный контракт).
|
||||
- Зависит от эмуляции сессии через `PlaywrightBridge` ([RefreshSupplierSessionJob.php:43](../../../app/app/Jobs/Supplier/RefreshSupplierSessionJob.php#L43)) — отдельная точка отказа (логин-форма, креды).
|
||||
|
||||
Цель: **полный резерв канала миграции проектов**. Не точечная защита от одного сбоя — полная избыточность, по образцу резерва входящих лидов (webhook + CSV-сверка).
|
||||
|
||||
## 2. Граница и пределы резерва
|
||||
|
||||
Разведка портала 19.05.2026 (Playwright read-only по согласию заказчика, снапшоты `rt-projects-snapshot.yml`, `rt-add-project-form.yml`, `user-api-page.yml`) установила:
|
||||
|
||||
- **У поставщика нет API управления проектами.** Раздел «API» меню — это интеграции *доставки* лидов (Битрикс / amoCRM / Google-таблица / Unisender + webhook). `/admin/user/api` — только настройка webhook'а («Апи ссылка», протокол, статус) + docs формата входящих лидов.
|
||||
- **Единственная дверь к проектам** — UI «Мои проекты» (`/admin/visit/rt`), за ним внутренний AJAX `rt-project-*`. Наш HTTP-канал бьёт именно туда.
|
||||
- **Внеканальной приёмной стороны не существует** — у поставщика нет менеджера/поддержки, принимающей запросы по почте/мессенджеру (подтверждено заказчиком).
|
||||
|
||||
**Следствие — потолок резерва.** Резервный канал может быть только *вторым механизмом в ту же дверь*. Он переживёт слом контракта `rt-project-*`, поломку нашей сессионной авторизации, частичные сбои портала. Полный даун хоста crm.bp-gr.ru роняет оба пути одновременно — это принятый предел, не недоработка дизайна.
|
||||
|
||||
## 3. Архитектура — три яруса
|
||||
|
||||
| Ярус | Механизм | Скорость | Когда работает |
|
||||
|---|---|---|---|
|
||||
| 1 | **AJAX** `rt-project-*` (primary) | быстро | штатно |
|
||||
| 2 | **Браузерная автоматизация** формы «Мои проекты» через `PlaywrightBridge` | медленно (Chromium boot + загрузки страниц) | контракт/auth яруса 1 сломан |
|
||||
| 3 | **Operator worklist** — оператор Лидерры вносит руками в crm.bp-gr.ru | сколько займёт у человека | оба автоматических яруса упали ИЛИ хост недоступен |
|
||||
|
||||
`FailoverProjectChannel` — декоратор-оркестратор: пробует ярус 1 → классифицирует исход → ярус 2 / прыжок на 3. Это зеркало входящего дизайна: webhook (push, машинный) + CSV-сверка (другой механизм, тот же портал).
|
||||
|
||||
## 4. Компоненты
|
||||
|
||||
### 4.1. `SupplierProjectChannel` — интерфейс
|
||||
|
||||
Новый интерфейс `App\Services\Supplier\Channel\SupplierProjectChannel`:
|
||||
|
||||
```php
|
||||
interface SupplierProjectChannel
|
||||
{
|
||||
public function createProject(SupplierProjectDto $dto): int; // external_id
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void;
|
||||
public function listProjects(): array; // для дедуп-сверки
|
||||
}
|
||||
```
|
||||
|
||||
Контракт нейтрален к транспорту. Реализации — три (см. ниже).
|
||||
|
||||
### 4.2. `AjaxProjectChannel` — ярус 1, тонкий адаптер
|
||||
|
||||
Имплементирует интерфейс через существующий `SupplierPortalClient` (его `saveProject`/`updateProject`/`listProjects` + `request()`/`loadSession()` остаются HTTP-плумбингом — [SupplierPortalClient.php:103-121](../../../app/app/Services/Supplier/SupplierPortalClient.php#L103-L121)). Тонкий wrapper, чтобы не ломать существующий код CSV-канала, который тоже использует `SupplierPortalClient`.
|
||||
|
||||
### 4.3. `FormProjectChannel` — ярус 2, браузерная автоматизация
|
||||
|
||||
Новый класс `App\Services\Supplier\Channel\FormProjectChannel`. Зависит от `PlaywrightBridge` ([RefreshSupplierSessionJob.php:43](../../../app/app/Jobs/Supplier/RefreshSupplierSessionJob.php#L43)) — тот уже водит headless Chromium для логина, инфра переиспользуется.
|
||||
|
||||
Новый Node-скрипт `app/playwright/manage-project.js` (рядом с `refresh-session.js`):
|
||||
|
||||
- Принимает аргументы `{operation, externalId|null, dto}`.
|
||||
- Логин (как `refresh-session.js`, селекторы Yii2 `#loginform-username`/`#loginform-password`), переход на `/admin/visit/rt`, ожидание загрузки таблицы.
|
||||
- Для `create`: клик «Добавить проект» → форма из 11 полей (снапшот разведки `rt-add-project-form.yml`) → заполнить (`textbox` Тег/Название, `checkbox` B1/B2/B3, `combobox` Источники сбора, `tree`/`switch` Регион include/exclude, `textarea` Список сайтов или вкладка «Файл» при необходимости, `spinbutton` Лимит в день, `checkbox` дни Пн-Вс) → «Сохранить» → дождаться появления новой строки в таблице → прочитать её `id` → вернуть.
|
||||
- Для `update`: найти проект в таблице по `externalId` → открыть форму редактирования → изменить нужные поля → «Сохранить».
|
||||
- Для `listProjects`: прочитать таблицу через snapshot/evaluate → массив `{id, platform, signal_type, unique_key, limit, workdays, regions, ...}`.
|
||||
|
||||
PHP-сторона — `FormProjectChannel` вызывает bridge с JSON-полезной нагрузкой и парсит ответ.
|
||||
|
||||
### 4.4. `FailoverProjectChannel` — декоратор
|
||||
|
||||
Новый класс `App\Services\Supplier\Channel\FailoverProjectChannel`. Конструктор: `AjaxProjectChannel`, `FormProjectChannel`, `ManualSyncQueueRepository`, `Mailer`.
|
||||
|
||||
`createProject(dto)`:
|
||||
|
||||
1. **Локальная проверка существования** (`SupplierProject::where(platform, signal_type, unique_key)`) — логика `ensureSupplierProject` ([SupplierPortalClient.php:54-91](../../../app/app/Services/Supplier/SupplierPortalClient.php#L54-L91)). Если есть — вернуть id.
|
||||
2. **Портальная сверка** через `listProjects()` (кэш на прогон job) по тому же ключу — если на портале уже есть (полу-успех яруса 1 в прошлом запуске, или другой Лидерра-проект на тот же канал), «усыновить»: создать локальную строку с external_id, вернуть id.
|
||||
3. Иначе — попытка яруса 1. Классификация исключения:
|
||||
- Успех → запись в `supplier_sync_log`, вернуть external_id.
|
||||
- `SupplierTransientException` (5xx, [SupplierPortalClient.php:291](../../../app/app/Services/Supplier/SupplierPortalClient.php#L291); сеть, [.php:263](../../../app/app/Services/Supplier/SupplierPortalClient.php#L263)) — `FailoverProjectChannel` сам ретраит транзиент (N попыток с backoff; точные значения пинуем в плане); после исчерпания → **хост недоступен** → прыжок на ярус 3 (ярус 2 по тому же хосту тоже не зайдёт), reason `portal_unreachable`. Существующий job-уровневый `$tries=3` ([SyncSupplierProjectJob.php:35](../../../app/app/Jobs/SyncSupplierProjectJob.php#L35)) — на catastrophic-сценарии вне канала (например, `FailoverProjectChannel` бросил неожиданное исключение).
|
||||
- `SupplierClientException` (4xx, [.php:299](../../../app/app/Services/Supplier/SupplierPortalClient.php#L299)) или неожиданная форма ответа (`saveProject` без `id` и т.п.) → **слом контракта** → ярус 2.
|
||||
- `SupplierAuthException` ([.php:270](../../../app/app/Services/Supplier/SupplierPortalClient.php#L270)) → ярус 2 (его свежий интерактивный логин может пройти, если протух только cookie).
|
||||
4. Ярус 2 — `FormProjectChannel.createProject(dto)`. На успех → запись в `supplier_projects` + `supplier_sync_log`, алёрт `failover_to_form` (инфо). На любой сбой (логин не прошёл, селекторы формы не найдены, save error-тост, таймаут) → запись в `supplier_manual_sync_queue` (status `pending`) + алёрт `manual_required`.
|
||||
|
||||
`updateProject(externalId, dto)`: аналогично без шагов 1–2 (проект уже привязан).
|
||||
|
||||
`listProjects()`: ярус 1; на client-exc → ярус 2 (browser-чтение таблицы).
|
||||
|
||||
### 4.5. Очередь яруса 3 — `supplier_manual_sync_queue`
|
||||
|
||||
Новая SaaS-level таблица (как `supplier_csv_reconcile_log` — без `tenant_id`, без RLS; доступ только SaaS-admin через admin-controller):
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `id` | bigserial PK | |
|
||||
| `project_id` | bigint NOT NULL, FK `projects` | Лидерра-проект |
|
||||
| `platform` | varchar CHECK `B1`/`B2`/`B3` | |
|
||||
| `operation` | varchar CHECK `create`/`update` | |
|
||||
| `external_id` | varchar NULL | для update — `supplier_external_id` |
|
||||
| `payload_snapshot` | jsonb NOT NULL | снимок DTO (signal_type, unique_key, limit, workdays, regions, regions_reverse, ...) — оператор видит точные значения для ввода на портале |
|
||||
| `failure_reason` | varchar NOT NULL | `portal_unreachable` / `contract_break` / `auth_failure` / `form_selector_break` / `form_save_error` / ... |
|
||||
| `status` | varchar CHECK `pending`/`resolved`/`cancelled` | |
|
||||
| `resolved_by_user_id` | bigint NULL FK `users` | оператор, отметивший выполненным |
|
||||
| `created_at`, `resolved_at` | timestamp | |
|
||||
|
||||
Миграция + обновление `db/schema.sql` + запись в `db/CHANGELOG_schema.md` обязательны (правило §4.2 Pravila).
|
||||
|
||||
### 4.6. Admin UI — worklist (расширение существующего)
|
||||
|
||||
Расширяем существующий админ-экран «Интеграция с поставщиком» (`AdminSupplierIntegrationController` + `AdminSupplierIntegrationView.vue`, созданы для CSV-канала, [spec 2026-05-18 §4.4](2026-05-18-supplier-csv-reconcile-channel-design.md)). Точное расположение контроллера — `app/app/Http/Controllers/Api/Admin/` (по конвенции; **не верифицировал именно файл в этой сессии**, запиную при планировании).
|
||||
|
||||
Новые endpoint'ы:
|
||||
|
||||
- `GET /api/admin/supplier-integration/manual-queue` — список pending записей с полным `payload_snapshot` (чтобы оператор видел, что вносить).
|
||||
- `POST /api/admin/supplier-integration/manual-queue/{id}/resolve` — оператор отмечает done. Backend:
|
||||
1. Дёргает `listProjects()` (ярус 1; fallback на ярус 2 если ярус 1 не работает).
|
||||
2. Ищет проект по `(platform, signal_type, unique_key)` из снапшота.
|
||||
3. Если нашёл — создаёт/обновляет локальный `supplier_projects`, ставит FK на `projects.supplier_b{1,2,3}_project_id`, помечает строку очереди `resolved`.
|
||||
4. Если не нашёл — возвращает 409 «проект не найден на портале, проверьте, что вы действительно его создали» (оператор перепроверяет).
|
||||
|
||||
`AdminSupplierIntegrationView.vue` — +секция «Ручная очередь»:
|
||||
|
||||
- Таблица: project name, platform, operation, payload (читаемое представление), reason, created_at.
|
||||
- Кнопка «Отметить выполнено» (с подтверждением).
|
||||
- Счётчик pending в шапке экрана + колокольчик-уведомление при появлении новых записей.
|
||||
|
||||
### 4.7. Расписание
|
||||
|
||||
- `SyncSupplierProjectsJob` крон: 20:30 МСК → **18:00 МСК**.
|
||||
- `RefreshSupplierSessionJob` ежедневный триггер: 20:15 → **17:45** МСК (15 мин до sync, [RefreshSupplierSessionJob.php:24](../../../app/app/Jobs/Supplier/RefreshSupplierSessionJob.php#L24)). Hourly-trigger остаётся.
|
||||
- `TIME_BUDGET_CUTOFF = '20:55'` ([SyncSupplierProjectsJob.php:60](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L60)) — **остаётся** (страховка от правок после портального дедлайна 21:00; при 18:00-старте обычно недостижим, остаётся safety rail).
|
||||
|
||||
Рацио 18:00: эскалация на ярус 2 (медленный) или ярус 3 (ручной оператор) — в рабочее время, не поздно вечером. Запас ~3 часа до 21:00.
|
||||
|
||||
Точный файл scheduler-записи (`routes/console.php` либо `app/Console/Kernel.php` — Laravel 13) — **не верифицировал**, запиную при планировании.
|
||||
|
||||
## 5. Поток данных
|
||||
|
||||
**On project create/edit** ([ProjectService.php:212-234](../../../app/app/Services/Project/ProjectService.php#L212-L234)): `ProjectService::create/update` → dispatch `SyncSupplierProjectJob(projectId)` → для каждой нужной платформы ([SyncSupplierProjectJob.php:70-81](../../../app/app/Jobs/SyncSupplierProjectJob.php#L70-L81)) → `FailoverProjectChannel.createProject(dto)` → ярусы. FK `projects.supplier_b{1,2,3}_project_id` ставится только при успехе яруса 1 или 2. При прыжке на ярус 3 — FK пока null, операция в очереди; FK ставится при resolve.
|
||||
|
||||
**Ночной 18:00** ([SyncSupplierProjectsJob.php](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php)): итерация по supplier_projects → расчёт allocation через `SupplierQuotaAllocator` ([.php:134-141](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L134-L141)) → если изменилось ([.php:147-149](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L147-L149)) → ветвление: `external_id IS NULL` → `FailoverProjectChannel.createProject` ([.php:158](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L158)), иначе → `updateProject` ([.php:168](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L168)) — обе через ярусы. Окно 18:00–20:55.
|
||||
|
||||
**Закрытие яруса 3:** оператор внёс правки на портале → жмёт «Отметить выполнено» → backend reconcile через `listProjects()` → дописывает `supplier_projects` + FK, помечает строку очереди `resolved`.
|
||||
|
||||
## 6. Обработка ошибок и эскалация
|
||||
|
||||
Сжатая матрица (детали в §4.4):
|
||||
|
||||
| Ярус 1 исход | Куда |
|
||||
|---|---|
|
||||
| Успех | done |
|
||||
| Транзиент исчерпан (5xx/сеть) | сразу ярус 3 (`portal_unreachable`) — ярус 2 пропускаем |
|
||||
| 4xx или непарсимый ответ | ярус 2 |
|
||||
| Sticky 401/403 | ярус 2 |
|
||||
| Ярус 2 любой сбой | ярус 3 (`form_selector_break`/`auth_failure`/...) |
|
||||
|
||||
Алёрты: реюз `SupplierCriticalAlertMail` ([SyncSupplierProjectsJob.php:88](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L88)). Новые типы:
|
||||
|
||||
- `failover_to_form` — инфо: AJAX сломан, ярус 2 несёт нагрузку (команде сигнал: чинить primary).
|
||||
- `manual_required` — ярус 3: оператор обязан вмешаться.
|
||||
|
||||
Дополнительно — отражение в админ-экране (плашка + колокольчик).
|
||||
|
||||
`$tries` у job'ов — пересмотр при планировании; повтор обеспечивает крон, ретрай-механизм внутри job'а не нужен.
|
||||
|
||||
## 7. Защита от дублей (идемпотентность)
|
||||
|
||||
**Опасность:** ярус 1 `createProject` полу-успешен — портал создал проект, но наш парсинг ответа упал → мы думаем «не вышло» → ярус 2 повторяет create → **дубль на портале**.
|
||||
|
||||
**Гард** в `FailoverProjectChannel.createProject` (шаги 1–2 §4.4): локальная проверка + портальная сверка через `listProjects()` (кэш на прогон job) до любой попытки create. Так create идемпотентен сквозь все ярусы и через ручное закрытие яруса 3.
|
||||
|
||||
Update — идемпотентен по своей природе (повтор `updateProject(externalId, dto)` с тем же DTO даёт то же состояние).
|
||||
|
||||
## 8. Временное окно поставщика
|
||||
|
||||
Портал на `/admin/visit/rt` показывает alert: «правки до 21:00 МСК; в 22:00–00:00 создание/редактирование запрещено» (зафиксировано снапшотом `rt-projects-snapshot.yml`). Заказчик уточнил 19.05.2026: это **UX-предупреждение пользователю** (для управления его ожиданиями), не технический забор; функционально портал работает 24/7. Соответственно:
|
||||
|
||||
- Канал и ярусы **не гейтятся** по времени.
|
||||
- 18:00-крон — рационален «эскалация в рабочее время», не «успеть до 21:00».
|
||||
|
||||
**Защитная ветка** (на случай, если 22:00–00:00 всё-таки технический reject): `FailoverProjectChannel` классифицирует window-связанный отказ (специфичный 4xx/строка ошибки) как `WindowDeferred` — **не сбой, не эскалация**; операция переносится на следующий ретрай/тик. Если окно полностью мягкое (опыт заказчика подтвердит), ветка не сработает ни разу и стоит ноль.
|
||||
|
||||
**Не верифицировал:** технический ли это reject в 22:00–00:00 на `rt-project-save`. Час 22:00–00:00 живьём не протестировать в день разведки (09:37 МСК). Если из эксплуатации станет ясно, что отказ невозможен — защитную ветку удалить.
|
||||
|
||||
## 9. Тестирование
|
||||
|
||||
- **`FailoverProjectChannel` — приоритет №1**, мозг фичи. Чистое юнит-тестирование с тест-даблами ярусов 1 и 2 (поддельные каналы, бросающие сконфигурированные исключения). Покрыть матрицу эскалации (§4.4 / §6) целиком: успех — никаких эскалаций; transient-exhausted → прыжок на ярус 3 (ярус 2 НЕ зван); client-exc → ярус 2 → успех / провал → очередь; auth-exc → ярус 2; идемпотентность — портальный матч `listProjects` → create не зван, FK усыновлён; `WindowDeferred` → ни очереди, ни эскалации. Детерминированно, быстро.
|
||||
- **`AjaxProjectChannel`** — Pest feature через `Http::fake()` (паттерн `CsvReconcileJobTest`): create/update/list, 4xx → `SupplierClientException`, 5xx → transient, 401/403 → auth. *Ограничение:* `Http::fake` проверяет НАШ код против предполагаемого контракта, не реальный портал; контракт верифицируется отдельным live-шагом (Задача 1 плана, см. §11 ниже).
|
||||
- **`FormProjectChannel` (PHP)**: `PlaywrightBridge` мокается на PHP-границе (он DI-инжектируемый, [RefreshSupplierSessionJob.php:43](../../../app/app/Jobs/Supplier/RefreshSupplierSessionJob.php#L43)) — тест проверяет правильность вызова bridge + обработку возвращаемых значений.
|
||||
- **Node-скрипт `manage-project.js`**: прогон против сохранённого статического HTML-фикстура формы «Мои проекты» — снапшот разведки `rt-add-project-form.yml` используется как seed-фикстура. Покрывает логику заполнения полей без живого портала.
|
||||
- **Миграция `supplier_manual_sync_queue`**: Pest feature/db — миграция применяется, `db/schema.sql` + `CHANGELOG_schema.md` синхронны, db-smoke (CHECK-constraint'ы, FK на `projects`).
|
||||
- **Джобы** `SyncSupplierProjectJob`/`SyncSupplierProjectsJob` — существующие тесты обновляются под инъекцию `FailoverProjectChannel`. +Тест расписания (18:00 крон, 17:45 session-refresh).
|
||||
- **`AdminSupplierIntegrationController`** — Pest feature: getJson worklist, postJson resolve + reconcile-эффект.
|
||||
- **`AdminSupplierIntegrationView.vue`** — Vitest: новая секция worklist рендерит pending; кнопка «выполнено» дёргает endpoint; счётчик/колокольчик при появлении новой записи.
|
||||
- **End-to-end `FormProjectChannel` против боевого crm.bp-gr.ru** — только **ручной smoke с живой сессией** (как webhook live-test / discovery T3). CI реальный портал не трогает: нет кредов, внешняя зависимость, создавал бы настоящие проекты. Live-smoke — отдельная gated-задача плана (read-mostly + один контролируемый create+откат через `deleteProject`).
|
||||
|
||||
Larastan baseline возможно потребует bump (новые классы) — зелёный larastan — обязательное условие коммита.
|
||||
|
||||
## 10. Что НЕ входит (YAGNI)
|
||||
|
||||
- Бэк-портирование `rt-project-delete` через ярусы — текущий код delete не зовёт нигде вне расширения channel'а; вероятно понадобится, но за рамками этого spec'а.
|
||||
- Pull-листинг проектов с портала как фоновый sync (метод `listProjects` уже есть) — на сегодня не нужен; используется только для дедуп-сверки в моменте create.
|
||||
- Out-of-band канал (e-mail / FTP / менеджер) — у поставщика приёма вне портала нет (см. §2).
|
||||
- Замена AJAX на browser как primary — отвергнуто: один механизм не даёт «полного резерва».
|
||||
- Параллельный browser-канал «для верификации каждого AJAX-вызова» — слишком дорого; cross-проверка делается на этапе discovery (Задача 1 плана) однократно.
|
||||
|
||||
## 11. Журнал решений
|
||||
|
||||
| # | Решение | Обоснование |
|
||||
|---|---|---|
|
||||
| 1 | Три яруса A+C (AJAX → авто-браузер → ручной worklist) | Максимальная избыточность в пределах единственной двери поставщика; зеркало webhook↔CSV |
|
||||
| 2 | Ярус 1 (AJAX) остаётся primary | Быстро; ярус 2 медленный (Chromium boot + загрузки страниц) — нежелателен по умолчанию |
|
||||
| 3 | Transient-exhausted → прыжок прямо на ярус 3 (ярус 2 пропускаем) | Если хост недоступен, браузер по тому же хосту тоже не зайдёт — пустая трата времени и контекста |
|
||||
| 4 | Ярус 3 ручной с точным worklist (не алёрт-без-данных) | Без точного снимка payload оператор не знает, что вносить — ошибки и расхождения |
|
||||
| 5 | Крон 18:00 МСК (был 20:30) | Эскалация на ярус 2/3 в рабочее время, не поздно вечером (запас ~3 часа до 21:00) |
|
||||
| 6 | Временное окно — не гейт (заказчик 19.05.2026) | UX-предупреждение, а не технический забор; защитная ветка `WindowDeferred` на случай, если 22:00–00:00 окажется hard-reject |
|
||||
| 7 | Идемпотентность через портальный `listProjects()` до create | Защита от дубля при полу-успехе яруса 1 / повторе ярусом 2 |
|
||||
| 8 | Очередь яруса 3 — отдельная SaaS-таблица (не расширение `supplier_sync_log`) | `supplier_sync_log` — append-only log; ярус 3 — resolvable queue (pending/resolved), отдельная семантика |
|
||||
| 9 | Discovery яруса 1 (верификация `rt-project-*` на боевом портале) — первая задача плана | AJAX endpoints placeholder, не верифицированы ([SupplierPortalClient.php:24-28](../../../app/app/Services/Supplier/SupplierPortalClient.php#L24-L28)); fixup-commit при расхождении; `FormProjectChannel` (ярус 2) служит независимым оракулом сверки |
|
||||
| 10 | Расширение существующего `AdminSupplierIntegrationView` (не новый экран) | Эта же админ-секция уже покрывает CSV-сверку; «здоровье интеграции с поставщиком» — один экран, две сущности (CSV + manual queue) |
|
||||
@@ -1,147 +0,0 @@
|
||||
# Дизайн: разовый импорт активных проектов поставщика в тенант info@lkomega.ru
|
||||
|
||||
**Дата:** 2026-05-22
|
||||
**Статус:** утверждён заказчиком (brainstorming), готов к плану
|
||||
**Ветка:** `feat/supplier-import-lkomega` (worktree от origin/main `4c80a58`)
|
||||
**Среда выполнения:** боевой пилот liderra.ru = `111.88.246.137` (там тенант info@lkomega.ru и живая supplier-сессия)
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Заказчик ведёт проекты вручную на портале поставщика crm.bp-gr.ru (логин `lkomega.ru`). Нужно один раз завести их как проекты в Лидерре под тенантом **info@lkomega.ru** («Компания 1»), **полностью по правилам Лидерры**: три площадки B1/B2/B3 одного источника = один проект Лидерры; лимит лидов и прочие настройки переносятся корректно.
|
||||
|
||||
Проекты **уже существуют** на портале и собирают лиды. Поэтому «перенести» = **усыновить** существующие записи портала (связать с ними проекты Лидерры), **не создавая дублей** на портале и **не меняя** его настройки.
|
||||
|
||||
## 2. Решения заказчика (brainstorming)
|
||||
|
||||
| Вопрос | Решение |
|
||||
|---|---|
|
||||
| Охват | Все активные проекты (`status` = включён; у всех `lim` > 0) |
|
||||
| Сторона поставщика | **Не трогать** портал — только усыновить (никаких save/update/delete на портал) |
|
||||
| Лимит в Лидерре | **Сумма** `lim` активных площадок группы (B1+B2+B3) |
|
||||
| Способ | Артизан-команда с режимом «примерки» (dry-run по умолчанию) |
|
||||
|
||||
## 3. Исходные данные (recon read-only 2026-05-22)
|
||||
|
||||
`SupplierPortalClient::listProjects()` на пилоте: **472** проекта аккаунта lkomega.
|
||||
|
||||
- По типу: `calls`=322, `hosts`=135, `sms`=15.
|
||||
- По источнику (`src`): `rt`=152, `bl`=160, `mt`=159, `dop2`=1.
|
||||
- По статусу: активных (`status=true`)=375, выключенных=97. У всех `lim`>0.
|
||||
- Группировка активных по `(content, type, tag)` → **128 групп**: 120 троек B1/B2/B3, 6 пар, 2 одиночных.
|
||||
|
||||
Форма строки портала (ключевые поля): `id` (строка, внешний id), `tag`, `src` (rt/bl/mt/…), `type` (calls/hosts/sms), `content` (идентификатор — телефон/домен), `name` (`B<n>_<content>`), `lim` (строка, лимит на площадку), `workdays` (строки `["1".."7"]`, 1=Пн..7=Вс ISO), `regions` (строка кодов ГИБДД, через запятую; пусто = вся РФ), `regions_reverse` (bool), `status` (bool).
|
||||
|
||||
NB: на портале лимиты активных проектов **уже поделены** на B1/B2/B3 (re-split форсом, ПИЛОТ.md `029b19a`) — значит сумма площадок = корректный целевой total для Лидерры.
|
||||
|
||||
## 4. Маппинг портал → Лидерра
|
||||
|
||||
| Портал | Лидерра |
|
||||
|---|---|
|
||||
| `src` rt / bl / mt | platform B1 / B2 / B3 |
|
||||
| `src` = `dop2` и любые иные | **пропуск** + строка в отчёт (вне модели B1/B2/B3) |
|
||||
| `type` calls / hosts / sms | `signal_type` call / site / sms |
|
||||
| `content` (для site/call) | `signal_identifier` |
|
||||
| группа = (`content`, `type`, `tag`) | один `Project` Лидерры |
|
||||
| Σ `lim` активных площадок группы | `daily_limit_target` |
|
||||
| `regions` (коды ГИБДД, union по площадкам группы) | `Project.regions` INT[] (коды Лидерры, обратная карта `SupplierRegions`); пусто = вся РФ → `[]` |
|
||||
| `regions_reverse=false` (include) | поддерживаем; `regions_reverse=true` (exclude) → **пропуск группы** + отчёт (модель Лидерры импорта — include) |
|
||||
| union `workdays` строк | `delivery_days_mask` (бит 0=Пн..6=Вс; bit=`1<<(d-1)`) |
|
||||
| `tag` | `Project.tag` (как есть); `Project.name` = производное от `tag` (+ суффикс идентификатора при коллизии имён) |
|
||||
| `status=true` | `is_active=true` |
|
||||
|
||||
**Обратная карта регионов:** существующий `SupplierRegions::mapToSupplier()` — Лидерра→ГИБДД (биекция 79 субъектов). Импорту нужна инверсия `mapFromSupplier()` (ГИБДД→Лидерра); непереводимый код → лог-warning + пропуск кода (регион не добавляется).
|
||||
|
||||
**SMS (15 строк, особый случай):** модель Лидерры для sms: `sms_senders` + `sms_keyword`, площадки B2 (sender+keyword) / B3 (sender), `unique_key` по `SupplierProjectGrouping::buildUniqueKey`. На портале `content` sms-строки кодирует sender(+keyword). План: best-effort разбор `content` → `sms_senders[0]`/`sms_keyword`; группы sms, которые не разбираются однозначно, **выводятся в отчёт и пропускаются** для ручного решения (объём мал).
|
||||
|
||||
## 5. Группировка и идемпотентность
|
||||
|
||||
- **Группа** строится только из **активных** строк (`status=true`), у которых `src` ∈ {rt,bl,mt}. Группы с одной/двумя площадками — валидны (создаём проект с теми платформами, что есть).
|
||||
- `unique_key` для `supplier_projects` вычисляется через `SupplierProjectGrouping::buildUniqueKey($project, $platform)` (консистентность с ночным джобом: будущие синки матчатся).
|
||||
- **Идемпотентность Project:** если под тенантом info@lkomega.ru уже есть `Project` с тем же (`signal_type`, `signal_identifier`) [для sms — (`signal_type`, `sms_senders[0]`, `sms_keyword`)] → **пропуск** (в отчёт «уже существует»), не дубль.
|
||||
- **Идемпотентность supplier_projects:** строка матчится по `supplier_external_id` (id портала) либо по unique-индексу `(platform, unique_key, subject_code=null)`. Есть → переиспользуем; нет → `forceCreate` с `sync_status='ok'`, `last_synced_at=now()`.
|
||||
- Повторный запуск команды безопасен: создаёт только недостающее.
|
||||
|
||||
## 6. Архитектура / компоненты
|
||||
|
||||
1. **`App\Services\Supplier\SupplierProjectImporter`** — чистая логика, без побочных эффектов записи:
|
||||
- `buildPlan(): ImportPlan` — читает `listProjects()`, фильтрует активные, группирует, реверс-маппит, считает суммы лимитов, помечает пропуски (dop2 / regions_reverse / нераспознанный sms / уже существующие). Возвращает структуру плана (список запланированных проектов + список пропусков). Зависимость `SupplierPortalClient` инжектится → тестируется на моках.
|
||||
2. **`App\Console\Commands\ImportSupplierProjectsCommand`** (`supplier:import-projects {--tenant=} {--commit}`):
|
||||
- Резолвит тенант по email (`User::where('email', …)->tenant_id`).
|
||||
- Печатает план таблицей (имя, тип, регионы, лимит, площадки + external_id) + счётчики + список пропусков.
|
||||
- Без `--commit` (по умолчанию) — только печать (dry-run), 0 записей.
|
||||
- С `--commit` — пишет в транзакции (см. §7).
|
||||
3. **`SupplierRegions::mapFromSupplier(array<int> $gibddCodes): array<int>`** — инверсия существующей карты.
|
||||
|
||||
Граница: importer НЕ знает про вывод в консоль; команда НЕ знает про парсинг портала. План — простая DTO-структура.
|
||||
|
||||
## 7. Путь записи (только при `--commit`)
|
||||
|
||||
Зеркалит create-ветку `SyncSupplierProjectsJob::syncGroup`, но **без HTTP на портал** — `supplier_external_id` берётся из уже прочитанного `listProjects` (`id` строки).
|
||||
|
||||
Соединение: **`pgsql_supplier`** (BYPASSRLS, роль `crm_supplier_worker`) для всех записей — это паттерн supplier-джобов; `Project` пишется с **явным `tenant_id`** (BYPASSRLS обходит RLS, поэтому tenant_id задаётся в коде, не из GUC). `supplier_projects` и `project_supplier_links` — SaaS-level (без RLS).
|
||||
|
||||
На каждую группу в транзакции:
|
||||
|
||||
1. `Project::on('pgsql_supplier')->create([tenant_id, name, tag, signal_type, signal_identifier|sms_*, regions, delivery_days_mask, daily_limit_target=Σ, is_active=true, region_mode='include'])`.
|
||||
2. На каждую активную площадку: upsert `supplier_projects` (`platform`, `signal_type`, `unique_key`, `subject_code=null`, `supplier_external_id`=id портала, `current_limit`=`lim` площадки, `current_workdays`, `current_regions`, `sync_status='ok'`, `last_synced_at=now()`).
|
||||
3. `project_supplier_links` insertOrIgnore (`project_id`, `supplier_project_id`, `platform`, `subject_code=null`).
|
||||
4. `SupplierSyncLog` action='create' (audit).
|
||||
|
||||
Легаси-FK `supplier_b{1,2,3}_project_id` **не заполняем** — текущий `LeadRouter` ходит через pivot `project_supplier_links` (Plan 2 redesign); консистентно с актуальным джобом.
|
||||
|
||||
## 8. Безопасность
|
||||
|
||||
- **dry-run по умолчанию** — реальная запись только с явным `--commit`.
|
||||
- Перед `--commit` на пилоте — показ плана заказчику и его «ок».
|
||||
- Запись в **одной транзакции** на пилоте; при ошибке — откат.
|
||||
- Портал не трогаем (никаких save/update/delete) — нулевой риск дублей и переплаты.
|
||||
- Телефоны/ПДн в выводе/логах команды маскируются (152-ФЗ): идентификаторы и имена с цифровыми хвостами усекаются в отчёте.
|
||||
|
||||
## 9. Тестирование (TDD)
|
||||
|
||||
`SupplierProjectImporterTest` на моках `SupplierPortalClient`:
|
||||
|
||||
- группировка троек B1/B2/B3 в один план-проект;
|
||||
- сумма лимитов площадок → `daily_limit_target`;
|
||||
- обратная карта регионов (ГИБДД→Лидерра), union, пусто=вся РФ;
|
||||
- фильтр статуса (выключенные не попадают);
|
||||
- пропуск `dop2` / `regions_reverse=true` / нераспознанного sms — с записью в отчёт;
|
||||
- идемпотентность (существующий Project → skip; существующий supplier_project → reuse).
|
||||
|
||||
`ImportSupplierProjectsCommandTest` — smoke: dry-run ничего не пишет; `--commit` создаёт Project+supplier_projects+pivot (на тестовой БД, мок listProjects).
|
||||
|
||||
`SupplierRegions::mapFromSupplier` — unit: биекция-инверсия, непереводимый код.
|
||||
|
||||
## 10. Выполнение (порядок)
|
||||
|
||||
1. Команда + сервис + тесты в worktree `feat/supplier-import-lkomega` (от origin/main).
|
||||
2. Зелёная регрессия (Pest целевой + relevant supplier suite, Pint, Larastan).
|
||||
3. Деплой на пилот (scp файлов; команда — не воркер, restart очереди не нужен).
|
||||
4. **Dry-run на пилоте** → показываю план заказчику → его «ок».
|
||||
5. Реальный прогон `--commit` на пилоте.
|
||||
6. Пост-проверка: число созданных Project под тенантом, выборочная сверка лимитов/регионов, отсутствие записей на портале (портал не дёргался).
|
||||
7. Push ветки → main; merge по решению заказчика.
|
||||
|
||||
## 11. Уточнить при планировании (не блокеры)
|
||||
|
||||
- **Ключ группировки.** Ночной `SyncSupplierProjectsJob` группирует по `(signal_type, identifier)` **без тега**; recon считал по `(content, type, tag)` → 128. Если у одного `(content,type)` несколько тегов — счёт групп изменится, и будущий ночной синк слил бы их в одну. **Рекомендация:** группировать как ночной джоб — по `(signal_type, identifier)` без тега (консистентно с live-синком); при нескольких тегах на одном идентификаторе — взять один (первый/наиболее частый) + отчёт. Финальный счёт уточнить на реальных данных при планировании.
|
||||
- **Семантика `tag`.** На портале у Дмитрия `tag` = кампания (напр. «Сфера Займов https://…»). Ночной синк Лидерры, наоборот, **пишет в портальный `tag` имя региона** (или «РФ»). Для импорта `Project.tag` = **сохранить кампанию из портала** (это данные заказчика); портал не трогаем, поэтому подмены тега не происходит. NB: если позже сделать resync этого проекта — штатный синк перезапишет портальный `tag` на регион (известный побочный эффект штатного поведения, вне scope импорта).
|
||||
- Точные fillable/типы `SupplierProject` (платформенный CHECK uppercase B1/B2/B3; колонка `supplier_external_id` строка).
|
||||
- Имя `Project.name`: формат из `tag` (тег бывает с URL — обрезать/нормализовать).
|
||||
- SMS-разбор `content` → sender/keyword (15 строк) — формат подтвердить на реальных sms-строках портала (read-only).
|
||||
- Резолв тенанта: `User` vs отдельная `Tenant` запись по email.
|
||||
|
||||
## 12. Вне scope (YAGNI)
|
||||
|
||||
- Импорт исторических лидов/сделок (отдельный CSV-эпик, уже частично сделан).
|
||||
- Импорт выключенных проектов (`status=false`).
|
||||
- Изменение чего-либо на портале crm.bp-gr.ru.
|
||||
- UI для импорта (разовая операция — команда).
|
||||
- Двусторонняя синхронизация (это разовый импорт; дальше работает штатный sync).
|
||||
|
||||
## 13. Риски
|
||||
|
||||
- **Несовпадение группировки портал↔Лидерра для sms** — митигируется пропуском нераспознанных + отчётом (объём 15).
|
||||
- **Регионы exclude (`regions_reverse=true`)** — пропуск + отчёт (импорт only-include).
|
||||
- **Параллельная сессия / §15** — worktree изолирован; коммиты явными путями; pre-flight sync перед нормативкой (нормативка тут не правится).
|
||||
- **Расхождение кода ветки vs пилота** — строим от origin/main = код пилота; перед `--commit` проверяем, что версия команды на пилоте = собранная.
|
||||
@@ -1,98 +0,0 @@
|
||||
# Дизайн: правка рублёвого баланса тенанта из админки
|
||||
|
||||
**Дата:** 2026-05-23
|
||||
**Статус:** утверждён заказчиком (через brainstorming Q&A)
|
||||
**Контекст:** после Биллинга v2 Spec A (единый ₽-баланс) заказчику нужен постоянный инструмент корректировки `tenants.balance_rub` из админки — вместо ручных правок через psql. Триггер — необходимость выставить адекватные балансы тестовым/демо-тенантам на проде после выкатки Spec A (где `balance_leads` стал vestigial, конвертация даёт артефакты вроде ½ млрд ₽).
|
||||
|
||||
## Решения (зафиксированы в brainstorming)
|
||||
|
||||
1. **Семантика — «установить точную сумму».** Админ вводит целевой `balance_rub`; сервер считает знаковую разницу `target − current` и записывает её в ledger. (Не дельта-ввод, не двойной режим — YAGNI.)
|
||||
2. **Поле — только `balance_rub`.** `balance_leads` после Spec A не используется и удаляется в Phase B → редактировать его смысла нет.
|
||||
3. **Доступ — из двух мест:** карточка тенанта (`AdminTenantDetailView`) И инлайн в таблице списка (`AdminTenantsView` → `TenantsTable`). Общий диалог-компонент.
|
||||
|
||||
## Архитектура
|
||||
|
||||
### Backend
|
||||
|
||||
Новый метод `AdminTenantsController::updateBalance(Request $request, int $id): JsonResponse`.
|
||||
|
||||
**Маршрут:** `PATCH /api/admin/tenants/{id}/balance` под `middleware('saas-admin')` (тот же гейт, что у hole #4 `pd-subject-requests` и `AdminPricingTiers`). Сейчас `AdminTenantsController` MVP-без-auth; новый мутирующий эндпоинт ставится под saas-admin гейт сразу (мутация денег — не lookup).
|
||||
|
||||
**Валидация:**
|
||||
|
||||
- `balance_rub` — `required`, `string`, `regex:/^-?\d+(\.\d{1,2})?$/`. Отрицательное допустимо (баланс легитимно уходит в минус при задолженности; `chargeback_unrecovered_rub` / overdue-логика это поддерживает).
|
||||
- `reason` — `nullable`, `string`, `max:500`.
|
||||
|
||||
**Логика (внутри `DB::transaction`):**
|
||||
|
||||
1. Через SaaS-connection `DB::connection('pgsql_supplier')` (BYPASSRLS-роль `crm_supplier_worker`) — `AdminTenantsController` не tenant-aware, RLS-контекст не ставится (паттерн hole #7 + `AdminBillingController::refund`).
|
||||
2. `lockForUpdate` на строке `tenants` (защита от lost-update при конкурентных topup/charge/adjust).
|
||||
3. 404 если тенант не найден / `deleted_at` не null.
|
||||
4. `delta = bcsub(target, current, 2)`. Если `bccomp(delta, '0', 2) === 0` → HTTP 422 «Баланс не изменился».
|
||||
5. `UPDATE tenants SET balance_rub = target WHERE id = ?` (raw через ту же connection; модель Eloquent decimal:2 тоже допустима, паттерн `BillingTopupService`).
|
||||
6. `INSERT balance_transactions`:
|
||||
- `type = 'manual_adjustment'` (валидное значение CHECK).
|
||||
- `amount_rub = delta` (знаковая строка — отрицательная при уменьшении).
|
||||
- `amount_leads = null`, `balance_leads_after = null` (Spec A — лиды не трогаем).
|
||||
- `balance_rub_after = target`.
|
||||
- `description = reason ?? 'Ручная корректировка баланса (админ)'`.
|
||||
- `admin_user_id = <actor>` (nullable — saas-admin SSO ⏸ Б-1; на MVP `null`).
|
||||
- `created_at = now()`.
|
||||
- Hash-chain BEFORE INSERT триггер `audit_chain_hash` подпишет `log_hash` автоматически; `audit_block_mutation` гарантирует append-only.
|
||||
7. `INSERT saas_admin_audit_log`: `action = 'tenant.balance_adjusted'`, `payload_before = {balance_rub: current}`, `payload_after = {balance_rub: target, delta, transaction_id}`. Паттерн `AdminBillingController::refund` (строки 130-131).
|
||||
|
||||
**Ответ:** `{ balance_rub: target, delta, transaction_id }` (200).
|
||||
|
||||
**Деньги:** только bcmath (`bcsub`/`bccomp`), без PHP float. `lockForUpdate` + append-only ledger.
|
||||
|
||||
### Frontend
|
||||
|
||||
**Общий компонент** `app/resources/js/components/admin/TenantBalanceDialog.vue`:
|
||||
|
||||
- Props: `tenantId: number`, `tenantName: string`, `currentBalanceRub: string`, `modelValue: boolean` (v-model open).
|
||||
- Поля: «Новый баланс ₽» (числовой ввод, маска decimal 2), «Причина» (textarea, опц.).
|
||||
- Живой предпросмотр: «было `{current}` ₽ → станет `{new}` ₽ (`{±delta}` ₽)». Считается на клиенте через простую арифметику строк (для отображения; источник истины — сервер).
|
||||
- Кнопки: «Сохранить» (disabled если поле пустое / не изменилось / невалидно), «Отмена».
|
||||
- Submit → `PATCH /api/admin/tenants/{id}/balance` → `emit('saved', { balance_rub, transaction_id })` → закрытие.
|
||||
- Ошибка валидации/сервера → показ в диалоге (не закрывать).
|
||||
|
||||
**Точка 1 — карточка тенанта** `AdminTenantDetailView.vue`: кнопка «Изменить баланс» рядом с отображением `balance_rub`. По `saved` → перезагрузить detail (`balance_rub` + `balance_history`, новая строка manual_adjustment видна).
|
||||
|
||||
**Точка 2 — список** `AdminTenantsView.vue` / `TenantsTable.vue`: действие в строке (иконка-кнопка «карандаш» или пункт меню «Изменить баланс»). Открывает тот же диалог. По `saved` → обновить `balance_rub` строки в таблице (точечный патч локального состояния или перезапрос списка).
|
||||
|
||||
**API-клиент** `app/resources/js/api/admin.ts` (или где живут admin-вызовы): функция `updateTenantBalance(id, { balance_rub, reason })`.
|
||||
|
||||
### Тесты
|
||||
|
||||
**Pest feature** `tests/Feature/Admin/AdminTenantBalanceUpdateTest.php`:
|
||||
|
||||
- Установка нового баланса → `tenants.balance_rub` обновлён, `balance_transactions(type='manual_adjustment')` с правильной знаковой разницей + `balance_rub_after`, `saas_admin_audit_log` строка.
|
||||
- Уменьшение баланса (отрицательная дельта) → корректная знаковая amount_rub.
|
||||
- Установка того же значения (delta=0) → 422.
|
||||
- Невалидный формат (`10.123` / буквы) → 422.
|
||||
- Отрицательный целевой баланс → принимается.
|
||||
- 404 на несуществующий/удалённый тенант.
|
||||
|
||||
**Vitest** `tests/Frontend/TenantBalanceDialog.spec.ts`:
|
||||
|
||||
- Предпросмотр считает дельту корректно.
|
||||
- «Сохранить» disabled при пустом/неизменённом вводе.
|
||||
- Submit вызывает API с правильными аргументами.
|
||||
|
||||
## Изоляция и границы
|
||||
|
||||
- Эндпоинт — в `AdminTenantsController` (домен «Тенанты»), не в `AdminBillingController` (там tenant-аккаунт-операции refund/changeTariff). Граница: balance-adjust — административная корректировка, логически принадлежит карточке тенанта.
|
||||
- Диалог — отдельный переиспользуемый компонент, не дублируется между detail и list.
|
||||
- Никаких изменений в `LedgerService` / `BalanceToLeadsConverter` / биллинг-flow — это независимая admin-операция.
|
||||
|
||||
## Вне scope (YAGNI)
|
||||
|
||||
- Редактирование `balance_leads` (vestigial, удаляется Phase B).
|
||||
- Дельта-режим ввода / двойной режим.
|
||||
- Массовая правка балансов нескольких тенантов разом.
|
||||
- Лимиты-пороги на сумму (кроме формата decimal) — админ доверенный.
|
||||
- Реальный actor_admin_user_id — saas-admin SSO ⏸ Б-1 (поле nullable, заполнится позже).
|
||||
|
||||
## Развёртывание
|
||||
|
||||
Feature-ветка `feat/admin-tenant-balance-edit` (worktree). После реализации + регрессии + ревью — выкатка на боевой `liderra.ru` тем же копир-паттерном, что Биллинг v2 Phase A (scp файлов + frontend build + кэши). DDL не требуется (новых таблиц/колонок нет). После выкатки заказчик выставляет реальные балансы тестовым тенантам через UI.
|
||||
@@ -1,633 +0,0 @@
|
||||
# Спек A — Биллинг v2: единый ₽-баланс + унификация tariff_plans
|
||||
|
||||
**Дата:** 2026-05-23
|
||||
**Статус:** Design (awaiting user review)
|
||||
**Автор:** Claude Opus 4.7 (под руководством заказчика)
|
||||
**Брейнсторм:** сессия 23.05.2026
|
||||
**Триггер:** «баланс только в рублях и перевести его в лиды в соответствии с тарифом, клиент видит и то и то» + аудит раздела «Биллинг» с 19 находками.
|
||||
|
||||
**Часть серии из 3 спеков:**
|
||||
|
||||
- **Спек A (этот)** — балансовая модель + аудит UI.
|
||||
- Спек B — дубли (`DuplicateDetector` ↔ кросс-месячные кейсы).
|
||||
- Спек C — preflight баланса + остановка всех проектов + пересчёт заказа поставщику + VTB-эквайринг.
|
||||
|
||||
---
|
||||
|
||||
## §1. Контекст и проблема
|
||||
|
||||
### §1.1 Текущая модель
|
||||
|
||||
Сейчас у тенанта **два баланса** ([db/schema.sql](../../../db/schema.sql) таблица `tenants`):
|
||||
|
||||
- `balance_leads` (INTEGER) — предоплаченные лиды поштучно.
|
||||
- `balance_rub` (DECIMAL) — рублёвый баланс.
|
||||
|
||||
При доставке лида ([LedgerService::chargeForDelivery](../../../app/app/Services/Billing/LedgerService.php)):
|
||||
|
||||
1. Подбирается ступень из `pricing_tiers` (7 ступеней объёмного тарифа).
|
||||
2. Если `balance_leads >= 1` → списываем 1 лид, цена `lead_charges.price_per_lead_kopecks=0`, `charge_source='prepaid'`.
|
||||
3. Иначе — списываем рубли по цене ступени, `charge_source='rub'`.
|
||||
|
||||
Параллельно в `tariff_plans` есть колонки `price_per_lead`, `price_monthly`, `included_leads`, `trial_bonus_leads`, `billing_model` — второе понятие «цены за лид» и «включённых лидов», которое не используется в горячем пути (`LedgerService` смотрит только `pricing_tiers`), но висит в схеме и читается из API.
|
||||
|
||||
### §1.2 Проблемы
|
||||
|
||||
1. Клиенту трудно понять «сколько лидов у меня хватит» — два кошелька с разными правилами трат.
|
||||
2. Концепция «предоплаченных лидов» (`balance_leads`) дублирует ту же ценность, что и `balance_rub`, но в другой валюте.
|
||||
3. `tariff_plans.price_per_lead` ↔ `pricing_tiers.price_per_lead_kopecks` — конфликт источников истины.
|
||||
4. UI раздела «Биллинг» содержит 19 формальных находок (см. §7).
|
||||
5. Концепция «включённых лидов» (`included_leads`) при подписочной модели (`billing_model='monthly'`/`'hybrid'`) — мёртвый код.
|
||||
|
||||
### §1.3 Триггер
|
||||
|
||||
Заказчик 23.05.2026: «**баланс только в рублях и перевести его в лиды в соответствии с тарифом, клиент видит и то и то**». Дальше через брейнсторм согласован Approach 3 — «Чистый разрез + унификация tariff_plans».
|
||||
|
||||
---
|
||||
|
||||
## §2. Решение
|
||||
|
||||
**Подход:** единый ₽-баланс, лиды — деривативом через pure-сервис, `tariff_plans` ужимается до «название и фичи».
|
||||
|
||||
### §2.1 Ключевые тезисы
|
||||
|
||||
1. **Единый ₽-баланс.** Колонка `tenants.balance_leads` удаляется. Существующие ненулевые остатки конвертируются в `balance_rub` по цене ступени 1 (консервативно, в пользу клиента) одноразовой artisan-командой.
|
||||
2. **Лиды — деривативом** через pure-сервис `BalanceToLeadsConverter`. Точный расчёт по ступеням: сколько лидов клиент реально получит при текущем балансе, учитывая уже доставленные за месяц и пересечения ступеней.
|
||||
3. **`tariff_plans` — только название и фичи.** Колонки `price_per_lead`, `price_monthly`, `included_leads`, `trial_bonus_leads`, `billing_model` удаляются. Все цены — только из `pricing_tiers`.
|
||||
4. **Никаких возвратов** (`refund`). Соответствующий таб/фильтр удаляются. (Если бизнес-нужда подтвердится — отдельный спек.)
|
||||
5. **Все P0/P1/P2 находки реестра** (§7) закрываются в рамках этого спека.
|
||||
|
||||
### §2.2 Что НЕ делаем (явно — out of scope)
|
||||
|
||||
- VTB-эквайринг и реальная оплата → **спек C**.
|
||||
- Auto-stop всех проектов клиента при нехватке баланса + пересчёт заказа у поставщика → **спек C**.
|
||||
- Дубли (`DuplicateDetector` 24h окно, кросс-месячные кейсы) → **спек B**.
|
||||
- Сверка с поставщиком CSV (`CsvReconcileJob`) — не трогаем.
|
||||
- `SupplierQuotaAllocator::computeOrder` — не трогаем.
|
||||
- Возвраты (`refund`) — не реализуем.
|
||||
|
||||
---
|
||||
|
||||
## §3. Архитектура
|
||||
|
||||
### §3.1 Карта изменений
|
||||
|
||||
| Слой | Что |
|
||||
|---|---|
|
||||
| **БД** | `tenants` (DROP `balance_leads`), `tariff_plans` (DROP 5 колонок), `balance_transactions` (новый `type='migration'`), `lead_charges` (без изменений в схеме) |
|
||||
| **Бэк-сервисы** | `LedgerService` (упрощается), `BillingTopupService` (без изменений), **новый** `BalanceToLeadsConverter` (pure) |
|
||||
| **Бэк-контроллеры** | `BillingController` (wallet + transactions), `TenantChargesController` (export), `AdminPricingTiersController` (bcmul fix) |
|
||||
| **Бэк-команды** | **новая** `BillingMigrateLeadsToRubCommand` (artisan, идемпотентная) |
|
||||
| **Фронт-страница** | `BillingView`, `views/billing/ChargesTab` |
|
||||
| **Фронт-компоненты** | `BalanceCard`, `TransactionsTable`, `InvoicesTable`, `TopupDialog` (минимально) |
|
||||
| **Новый UI** | `TierPricesPanel` (7-ступенчатая таблица с подсветкой текущей, сворачиваемая) |
|
||||
| **Seeders** | `DemoSeeder`, `TenantSeeder` (если ссылаются на удаляемые поля) |
|
||||
| **Тесты** | Pest +3 новых файла, ~6 обновляемых; Vitest +1, ~4 обновляемых; Histoire +1, ~2 обновляемых |
|
||||
|
||||
### §3.2 Изменения схемы БД
|
||||
|
||||
#### §3.2.1 Phase 1 — data migration (artisan-команда)
|
||||
|
||||
Команда `php artisan billing:migrate-leads-to-rub`:
|
||||
|
||||
```
|
||||
ДЛЯ КАЖДОГО tenant С balance_leads > 0:
|
||||
В транзакции с lockForUpdate(tenant):
|
||||
1. Если balance_leads <= 0 → no-op (идемпотентность).
|
||||
2. migrated_kopecks := balance_leads × pricing_tiers[tier_no=1, активная на сегодня].price_per_lead_kopecks
|
||||
migrated_rub := bcdiv(migrated_kopecks, '100', 2)
|
||||
3. new_balance_rub := bcadd(balance_rub, migrated_rub, 2)
|
||||
4. UPDATE tenants SET balance_rub = new_balance_rub, balance_leads = 0 WHERE id = tenant.id
|
||||
5. INSERT balance_transactions(
|
||||
type = 'migration',
|
||||
amount_leads = -balance_leads,
|
||||
amount_rub = '+' || migrated_rub,
|
||||
balance_leads_after = 0,
|
||||
balance_rub_after = new_balance_rub,
|
||||
description = 'Конвертация предоплаченных лидов в ₽ (миграция модели биллинга)',
|
||||
created_at = now()
|
||||
)
|
||||
```
|
||||
|
||||
Свойства:
|
||||
|
||||
- **Идемпотентна:** повторный запуск — no-op (проверка `balance_leads > 0`).
|
||||
- **Аудит:** одна `balance_transactions(type='migration')` на тенанта — единственный пейпер-трейл.
|
||||
- **Защита:** lockForUpdate против параллельных списаний/пополнений.
|
||||
|
||||
#### §3.2.2 Phase 2 — schema cleanup (отдельный коммит, **после кодовой части в проде**)
|
||||
|
||||
Миграция Laravel:
|
||||
|
||||
```sql
|
||||
ALTER TABLE tenants DROP COLUMN balance_leads;
|
||||
|
||||
ALTER TABLE tariff_plans
|
||||
DROP COLUMN price_per_lead,
|
||||
DROP COLUMN price_monthly,
|
||||
DROP COLUMN included_leads,
|
||||
DROP COLUMN trial_bonus_leads,
|
||||
DROP COLUMN billing_model;
|
||||
|
||||
-- balance_transactions.amount_leads — остаётся nullable INT навсегда (история).
|
||||
-- lead_charges.charge_source + chk_lead_charges_prepaid_zero_price — остаются (история).
|
||||
-- pricing_tiers — без изменений.
|
||||
-- balance_transactions hash-chain триггеры — не трогаем.
|
||||
```
|
||||
|
||||
После Phase 2: `tariff_plans` содержит только `id, code, name, description, features (jsonb), limits (jsonb), is_active, is_public, sort_order, created_at, updated_at`. Превращается из «тарифного плана» в «пакет фич/лимитов».
|
||||
|
||||
#### §3.2.3 Новые константы
|
||||
|
||||
- `BalanceTransaction::TYPE_MIGRATION = 'migration'` (добавляем).
|
||||
- `BalanceTransaction::TYPE_REFUND` — **не вводим** (возвратов нет в этом спеке).
|
||||
|
||||
### §3.3 Изменения бэка
|
||||
|
||||
#### §3.3.1 Новый pure-сервис `BalanceToLeadsConverter`
|
||||
|
||||
Файл: `app/app/Services/Billing/BalanceToLeadsConverter.php`.
|
||||
|
||||
Сигнатура:
|
||||
|
||||
```php
|
||||
final class BalanceToLeadsConverter
|
||||
{
|
||||
/**
|
||||
* @param string $balanceRub DECIMAL-строка («5000.00»), bcmath
|
||||
* @param int $deliveredInMonth tenants.delivered_in_month
|
||||
* @param Collection<int, PricingTier> $activeTiers
|
||||
* @return array{
|
||||
* leads: int,
|
||||
* breakdown: list<array{tier_no:int, leads:int, price_rub:string}>,
|
||||
* current_tier: array{no:int, price_rub:string, leads_left_in_tier:int}|null,
|
||||
* next_tier: array{no:int, price_rub:string, leads_in_tier:int}|null
|
||||
* }
|
||||
*/
|
||||
public function convert(string $balanceRub, int $deliveredInMonth, Collection $activeTiers): array;
|
||||
}
|
||||
```
|
||||
|
||||
Алгоритм (псевдокод):
|
||||
|
||||
```
|
||||
balance_kopecks := bcmul(balanceRub, '100', 0) # string-int
|
||||
sorted := tiers.sortBy('tier_no').values()
|
||||
total_leads := 0
|
||||
breakdown := []
|
||||
cumulative := 0 # сколько лидов покрыто пройденными ступенями (для определения «вы здесь»)
|
||||
|
||||
current_tier := null
|
||||
next_tier := null
|
||||
|
||||
ДЛЯ tier В sorted:
|
||||
tier_start := cumulative + 1
|
||||
tier_cap := (tier.leads_in_tier === null) ? INF : tier.leads_in_tier
|
||||
tier_end := cumulative + tier_cap
|
||||
|
||||
# сколько слотов в этой ступени ещё не «съедено» уже доставленными
|
||||
slots_left_in_tier := max(0, tier_end - max(tier_start - 1, deliveredInMonth))
|
||||
|
||||
# «текущая ступень» — первая, где (deliveredInMonth + 1) попадает
|
||||
ЕСЛИ current_tier IS null AND deliveredInMonth < tier_end:
|
||||
current_tier := { no: tier.tier_no, price_rub: tier.price_rub, leads_left_in_tier: slots_left_in_tier }
|
||||
|
||||
ЕСЛИ slots_left_in_tier <= 0:
|
||||
cumulative := tier_end
|
||||
ПРОДОЛЖИТЬ
|
||||
|
||||
price_kopecks := tier.price_per_lead_kopecks
|
||||
ЕСЛИ price_kopecks <= 0:
|
||||
# бесплатная ступень (теоретически — пока не используется)
|
||||
total_leads += slots_left_in_tier
|
||||
breakdown.append({ tier_no, leads: slots_left_in_tier, price_rub: '0.00' })
|
||||
cumulative := tier_end
|
||||
ПРОДОЛЖИТЬ
|
||||
|
||||
# сколько лидов в этой ступени можем себе позволить
|
||||
affordable_in_tier := (int) bcdiv(balance_kopecks, price_kopecks, 0)
|
||||
take := min(slots_left_in_tier, affordable_in_tier)
|
||||
|
||||
ЕСЛИ take > 0:
|
||||
total_leads += take
|
||||
breakdown.append({ tier_no, leads: take, price_rub: format(price_kopecks) })
|
||||
balance_kopecks := bcsub(balance_kopecks, bcmul(price_kopecks, take, 0), 0)
|
||||
|
||||
ЕСЛИ take < slots_left_in_tier:
|
||||
# баланс кончился в этой ступени — следующей нет смысла
|
||||
# next_tier остаётся null (нет смысла показывать)
|
||||
ВЫЙТИ
|
||||
|
||||
cumulative := tier_end
|
||||
ЕСЛИ tier.leads_in_tier === null: ВЫЙТИ # «всё свыше»
|
||||
|
||||
# next_tier — следующая после current_tier
|
||||
next_idx := sorted.findIndex(t => t.tier_no > current_tier.no)
|
||||
ЕСЛИ next_idx !== -1:
|
||||
next_tier := { no: sorted[next_idx].tier_no, price_rub, leads_in_tier: sorted[next_idx].leads_in_tier }
|
||||
|
||||
ВЕРНУТЬ { leads: total_leads, breakdown, current_tier, next_tier }
|
||||
```
|
||||
|
||||
Деньги — bcmath, без PHP float. Pure (без БД-обращений). Тестируется изолированно.
|
||||
|
||||
#### §3.3.2 `LedgerService::chargeForDelivery` (упрощённый)
|
||||
|
||||
Удаляется dual-balance ветвление. Метод ужимается до:
|
||||
|
||||
```php
|
||||
public function chargeForDelivery(Tenant $lockedTenant, Deal $deal, ?SupplierLead $lead = null): ChargeResult
|
||||
{
|
||||
$activeTiers = $this->tiers->activeAt(Carbon::now('Europe/Moscow'));
|
||||
$tier = $this->resolver->resolveForCount($activeTiers, ($lockedTenant->delivered_in_month ?? 0) + 1);
|
||||
$priceKopecks = (int) $tier->price_per_lead_kopecks;
|
||||
|
||||
// bcmath check: balance_rub × 100 >= priceKopecks
|
||||
$balanceKopecks = bcmul((string) $lockedTenant->balance_rub, '100', 0);
|
||||
if (bccomp($balanceKopecks, (string) $priceKopecks, 0) < 0) {
|
||||
throw new InsufficientBalanceException(
|
||||
priceKopecks: $priceKopecks,
|
||||
balanceRub: (string) $lockedTenant->balance_rub,
|
||||
);
|
||||
}
|
||||
|
||||
$amountRub = bcdiv((string) $priceKopecks, '100', 2);
|
||||
$newBalanceRub = bcsub((string) $lockedTenant->balance_rub, $amountRub, 2);
|
||||
DB::table('tenants')->where('id', $lockedTenant->id)->update(['balance_rub' => $newBalanceRub]);
|
||||
$lockedTenant->increment('delivered_in_month', 1);
|
||||
$lockedTenant->refresh();
|
||||
|
||||
LeadCharge::create([
|
||||
'tenant_id' => $lockedTenant->id,
|
||||
'deal_id' => $deal->id,
|
||||
'deal_received_at' => $deal->received_at,
|
||||
'tier_no' => $tier->tier_no,
|
||||
'price_per_lead_kopecks' => $priceKopecks,
|
||||
'charge_source' => 'rub', // всегда
|
||||
'charged_at' => now(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
BalanceTransaction::create([
|
||||
'tenant_id' => $lockedTenant->id,
|
||||
'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
|
||||
'amount_leads' => null, // история - больше не пишем
|
||||
'amount_rub' => '-' . $amountRub,
|
||||
'balance_leads_after' => null,
|
||||
'balance_rub_after' => (string) $lockedTenant->balance_rub,
|
||||
'related_type' => Deal::class,
|
||||
'related_id' => $deal->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// supplier_lead_costs - без изменений
|
||||
if ($lead !== null) {
|
||||
$supplierId = $this->resolveSupplierId($lead);
|
||||
if ($supplierId !== null) {
|
||||
$supplier = Supplier::findOrFail($supplierId);
|
||||
DB::table('supplier_lead_costs')->insert([
|
||||
'deal_id' => $deal->id,
|
||||
'received_at' => $deal->received_at,
|
||||
'supplier_id' => $supplierId,
|
||||
'cost_rub' => $supplier->cost_rub,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return new ChargeResult('rub', $tier, $priceKopecks);
|
||||
}
|
||||
```
|
||||
|
||||
Удаляется:
|
||||
|
||||
- Приватный метод `decideSource()`.
|
||||
- Поле `ChargeResult::$source` (или всегда `'rub'`).
|
||||
- Параметр `InsufficientBalanceException::$balanceLeads`.
|
||||
|
||||
#### §3.3.3 `BillingController::wallet`
|
||||
|
||||
Новая структура ответа:
|
||||
|
||||
```json
|
||||
{
|
||||
"balance_rub": "5000.00",
|
||||
"affordable_leads": 46,
|
||||
"current_tier": { "no": 1, "price_rub": "120.00", "leads_left_in_tier": 20 },
|
||||
"next_tier": { "no": 2, "price_rub": "100.00", "leads_in_tier": 100 },
|
||||
"delivered_in_month": 30,
|
||||
"runway_days": 12,
|
||||
"tiers_preview": [
|
||||
{ "tier_no": 1, "leads_in_tier": 50, "price_rub": "120.00" },
|
||||
{ "tier_no": 2, "leads_in_tier": 100, "price_rub": "100.00" },
|
||||
...
|
||||
{ "tier_no": 7, "leads_in_tier": null, "price_rub": "60.00" }
|
||||
],
|
||||
"tariff": { "code": "...", "name": "...", "features": [...] }
|
||||
}
|
||||
```
|
||||
|
||||
`runway_days` пересчитывается как `affordable_leads / средний_лидов_в_день_за_30дн`. Если средняя = 0 → `null`. Если `affordable_leads = 0` → `0`. Одна формула для всего экрана.
|
||||
|
||||
`tariff` — без `price_monthly`, `billing_model`, `included_leads` (поля удалены).
|
||||
|
||||
#### §3.3.4 `BillingController::transactions`
|
||||
|
||||
Удалить фильтр `refund` из validation:
|
||||
|
||||
```diff
|
||||
- if (is_string($type) && in_array($type, ['topup', 'lead_charge', 'refund'], true)) {
|
||||
+ if (is_string($type) && in_array($type, ['topup', 'lead_charge', 'migration'], true)) {
|
||||
```
|
||||
|
||||
#### §3.3.5 `AdminPricingTiersController::store`
|
||||
|
||||
```diff
|
||||
- 'tiers.*.price_rub' => ['required', 'numeric', 'min:0'],
|
||||
+ 'tiers.*.price_rub' => ['required', 'string', 'regex:/^\d+(\.\d{1,2})?$/'],
|
||||
|
||||
- 'price_per_lead_kopecks' => (int) round(((float) $tier['price_rub']) * 100),
|
||||
+ 'price_per_lead_kopecks' => (int) bcmul((string) $tier['price_rub'], '100', 0),
|
||||
```
|
||||
|
||||
#### §3.3.6 `TenantChargesController::export`
|
||||
|
||||
Заполняем колонку `balance_rub_after` через JOIN к `balance_transactions`:
|
||||
|
||||
```sql
|
||||
JOIN balance_transactions bt ON bt.related_type = 'App\Models\Deal'
|
||||
AND bt.related_id = lead_charges.deal_id
|
||||
AND bt.tenant_id = lead_charges.tenant_id
|
||||
```
|
||||
|
||||
#### §3.3.7 Seeders cleanup
|
||||
|
||||
Перед миграцией `grep -r 'balance_leads\|trial_bonus_leads\|included_leads\|billing_model\|price_per_lead\|price_monthly' app/database/seeders/` — заменить все ссылки. Бонусные лиды при подключении тарифа выдаются как ₽ через `BillingTopupService::topup($tenantId, $startBonusRub, null)` с описанием «Стартовый бонус».
|
||||
|
||||
### §3.4 Изменения фронта
|
||||
|
||||
#### §3.4.1 Типы (`app/resources/js/api/billing.ts`)
|
||||
|
||||
```typescript
|
||||
export interface Wallet {
|
||||
balance_rub: string
|
||||
affordable_leads: number
|
||||
current_tier: { no: number; price_rub: string; leads_left_in_tier: number }
|
||||
next_tier: { no: number; price_rub: string; leads_in_tier: number } | null
|
||||
delivered_in_month: number
|
||||
runway_days: number | null
|
||||
tiers_preview: Array<{ tier_no: number; leads_in_tier: number | null; price_rub: string }>
|
||||
tariff: { code: string; name: string; features: string[] } | null
|
||||
}
|
||||
|
||||
export interface BillingTransaction {
|
||||
id: number
|
||||
code: string
|
||||
type: 'topup' | 'lead_charge' | 'migration' // 'refund' удалён
|
||||
description: string | null
|
||||
amount_rub: string
|
||||
amount_leads: number | null // история, может быть null
|
||||
balance_rub_after: string
|
||||
display_amount_rub: string // новое: всегда ₽-эквивалент (для исторических prepaid)
|
||||
created_at: string
|
||||
}
|
||||
```
|
||||
|
||||
#### §3.4.2 `BillingView.vue`
|
||||
|
||||
- Шапка `page-stats`: удалить «N лидов запас». Остаётся «`X` кошелёк · хватит на `Y` дн.» (если `runway_days` не null).
|
||||
- Под `BalanceCard` — новый блок `TierPricesPanel` (см. §3.4.6), перед `TransactionsTable`.
|
||||
|
||||
#### §3.4.3 `BalanceCard.vue`
|
||||
|
||||
3 карточки:
|
||||
|
||||
| # | Заголовок | Контент |
|
||||
|---|---|---|
|
||||
| 1 | «Кошелёк ₽» (тёмная) | `balanceRub ₽` + мелким «мин. пополнение 100 ₽» (удалить «округление вниз ₽→лиды») + кнопка «Пополнить» + disabled «Автопополнение» |
|
||||
| 2 | «**≈ N лидов**» | `affordable_leads` крупно + tooltip «Точный расчёт по текущим ценам. Меняется при переходе ступеней.» + sub-line «сейчас по `current_tier.price_rub` ₽/лид» |
|
||||
| 3 | «Что входит» | `tariff.name` + список `tariff.features` (галочки). Без `price_monthly`. Кнопка «Сменить тариф» disabled остаётся. |
|
||||
|
||||
Удалить:
|
||||
|
||||
- «Баланс лидов (ГЦК)» текст.
|
||||
- Аббревиатуру «(ГЦК)».
|
||||
- Текст «округление вниз ₽→лиды».
|
||||
- Префикс `tariff_price` («₽/мес»).
|
||||
|
||||
#### §3.4.4 `TransactionsTable.vue`
|
||||
|
||||
- Массив `TABS` — удалить пункт `{ id: 'refund', ... }`.
|
||||
- Функция `txAmountText` — переписать: всегда выводит ₽-эквивалент через `display_amount_rub` (бэк отдаёт уже посчитанный).
|
||||
- `formatWhen` — добавить год: `{ year: '2-digit', day: '2-digit', month: '2-digit', hour, minute }` → «23.05.26, 14:30».
|
||||
|
||||
#### §3.4.5 `InvoicesTable.vue`
|
||||
|
||||
- Сумма с «₽»: `formatPlain(Number(inv.amount_total)) + ' ₽'`.
|
||||
- Empty-state без изменений («Счета появятся после первой оплаты»).
|
||||
|
||||
#### §3.4.6 `ChargesTab.vue`
|
||||
|
||||
- Удалить `v-select` «Источник» (`source` ref, `sources` массив).
|
||||
- Удалить колонку «Источник» из `headers`.
|
||||
- Колонка «Цена»: для исторических строк с `price_per_lead_kopecks === 0` (prepaid) — серое «0 ₽ (из бесплатного)» с tooltip «До перехода на новую модель эти лиды списывались из бесплатного остатка».
|
||||
- POST → GET для экспорта (находка #13) — отложено.
|
||||
|
||||
#### §3.4.7 `TopupDialog.vue`
|
||||
|
||||
В этом спеке **не трогаем** (VTB перекроит — спек C). Минимум 100₽ остаётся.
|
||||
|
||||
#### §3.4.8 Новый `TierPricesPanel.vue`
|
||||
|
||||
Свёрнутый по умолчанию `<v-expansion-panel>` с заголовком «Цены за лид (7 ступеней)». Внутри — таблица 7 строк:
|
||||
|
||||
| Ступень | Диапазон | Цена |
|
||||
|---|---|---|
|
||||
| 1 | 1–50 лидов | 120 ₽ |
|
||||
| 2 | 51–150 лидов | 100 ₽ |
|
||||
| ... | ... | ... |
|
||||
| 7 | 1501+ | 60 ₽ |
|
||||
|
||||
С подсветкой (бордер + чип «вы здесь») текущей ступени из `current_tier.no`. Данные — из `wallet.tiers_preview` (один API-запрос, не два).
|
||||
|
||||
### §3.5 Тесты
|
||||
|
||||
#### §3.5.1 Pest (новые)
|
||||
|
||||
- `Tests/Unit/Services/Billing/BalanceToLeadsConverterTest.php` — ≥8 кейсов (пустой баланс, одна ступень, переход ступеней, последняя `NULL`-ступень, `delivered_in_month` пропуск, граничные копейки, bcmath-точность, неактивные ступени).
|
||||
- `Tests/Feature/Billing/MigrationLeadsToRubTest.php` — конвертация по tier 1, INSERT `balance_transactions(type='migration')`, идемпотентность, lockForUpdate.
|
||||
- `Tests/Feature/Billing/WalletApiTest.php` — `/api/billing/wallet` отдаёт `affordable_leads`, `current_tier`, `next_tier`, `tiers_preview`, `tariff` без удалённых полей.
|
||||
|
||||
#### §3.5.2 Pest (обновляемые)
|
||||
|
||||
- `LedgerServiceTest` — удалить кейсы prepaid-ветки, оставить только rub.
|
||||
- `BillingControllerTest::transactions` — убрать кейс `type=refund`.
|
||||
- `AdminPricingTiersControllerTest` — кейс «цена 10.10 → 1010 копеек» через bcmul.
|
||||
- `TenantChargesControllerTest::export` — ассертить `balance_rub_after` заполнен.
|
||||
|
||||
#### §3.5.3 Pest (удаляемые)
|
||||
|
||||
- Все кейсы с `balance_leads--` или `charge_source='prepaid'` для **новых** сделок.
|
||||
|
||||
#### §3.5.4 Vitest
|
||||
|
||||
- `BalanceCard.spec.ts` — обновить (≈ N лидов, tooltip, без «(ГЦК)»).
|
||||
- `TransactionsTable.spec.ts` — без таба «Возвраты», конвертация через `display_amount_rub`.
|
||||
- `ChargesTab.spec.ts` — без фильтра/колонки «Источник».
|
||||
- `InvoicesTable.spec.ts` — формат суммы с «₽».
|
||||
- **Новый** `TierPricesPanel.spec.ts` — 7 ступеней рендерятся, текущая подсвечена.
|
||||
- `BillingView.spec.ts` — шапка без «лидов запас», `TierPricesPanel` свёрнут по умолчанию.
|
||||
|
||||
#### §3.5.5 Histoire
|
||||
|
||||
- `BillingView.story.vue`, `BalanceCard.story.vue` — обновить fixture'ы.
|
||||
- **Новый** `TierPricesPanel.story.vue` — 3 вариации (на ступени 1, 3, 7).
|
||||
|
||||
#### §3.5.6 Larastan / type-check
|
||||
|
||||
- Удалить `Tenant::balance_leads` свойство (PHPDoc + `$casts`).
|
||||
- vue-tsc после изменения `Wallet`-интерфейса найдёт все потребители — поправить точечно.
|
||||
|
||||
---
|
||||
|
||||
## §4. Миграция и релиз
|
||||
|
||||
### §4.1 Двухфазное развёртывание (критично)
|
||||
|
||||
#### Фаза A — код + data migration (PR #1)
|
||||
|
||||
1. Все code-side изменения (LedgerService, контроллеры, фронт, тесты, конвертер).
|
||||
2. Новая artisan-команда `php artisan billing:migrate-leads-to-rub`.
|
||||
3. **Колонка `balance_leads` остаётся в БД** — код её больше не читает/пишет, но физически на месте (страховка от мгновенного rollback).
|
||||
4. Прогон на проде:
|
||||
- бэкап БД (`pg_dump`),
|
||||
- деплой кода,
|
||||
- `php artisan billing:migrate-leads-to-rub`,
|
||||
- smoke-тесты на 2 demo тенантах (`/api/billing/wallet`, доставка тестового лида),
|
||||
- 24-72 ч наблюдения через `balance_transactions(type='migration')` audit-log.
|
||||
|
||||
#### Фаза B — schema cleanup (PR #2, через 1-3 дня после Фазы A в проде)
|
||||
|
||||
1. Grep-проверка: `grep -r 'balance_leads\|price_per_lead\|price_monthly\|included_leads\|trial_bonus_leads\|billing_model' app/` (исключая `lead_charges.price_per_lead_kopecks` — другое поле).
|
||||
2. Миграция Laravel `ALTER TABLE` (§3.2.2).
|
||||
3. Деплой.
|
||||
|
||||
**Rollback Фазы A:** `balance_leads` ещё в БД → обратный SQL по `balance_transactions.amount_leads` для строк `type='migration'`. Поэтому Фаза B — отдельный PR.
|
||||
|
||||
### §4.2 Регрессионные критерии (`/regression full` перед merge каждой фазы)
|
||||
|
||||
- Pest --parallel зелёный (целевое: +20-30 новых ассертов).
|
||||
- Vitest зелёный (+10-15 новых ассертов).
|
||||
- Larastan 0 ошибок.
|
||||
- Vite build OK.
|
||||
- Histoire build OK.
|
||||
- Pa11y `/billing` — 0 violations.
|
||||
- gitleaks 0, lychee 0 broken.
|
||||
|
||||
### §4.3 Контракты и инварианты
|
||||
|
||||
- **bcmath** для всех мутаций `balance_rub` (никогда PHP float).
|
||||
- **append-only** `balance_transactions` и `lead_charges` — hash-chain триггеры в БД не трогаем.
|
||||
- **Никогда** `balance_rub < 0` — `InsufficientBalanceException` перед мутацией.
|
||||
- **delivered_in_month** — единственный счётчик «лидов в этом месяце», обнуляется `ResetMonthlyCountersCommand` 1-го числа месяца.
|
||||
|
||||
---
|
||||
|
||||
## §5. Алгоритм конвертации `BalanceToLeadsConverter::convert` — рабочий пример
|
||||
|
||||
**Вход:**
|
||||
|
||||
- `balanceRub = '5000.00'`
|
||||
- `deliveredInMonth = 30`
|
||||
- `tiers`:
|
||||
- tier 1: leads_in_tier=50, price=120₽ (12000 коп)
|
||||
- tier 2: leads_in_tier=100, price=100₽ (10000 коп)
|
||||
- tier 3: leads_in_tier=200, price=80₽ (8000 коп)
|
||||
- ...
|
||||
- tier 7: leads_in_tier=NULL, price=60₽ (6000 коп)
|
||||
|
||||
**Прогон:**
|
||||
|
||||
- balance_kopecks = 500 000
|
||||
- **tier 1:** tier_start=1, tier_end=50, slots_left = 50−max(0, 30) = 20.
|
||||
- current_tier := { no:1, price:'120.00', leads_left:20 }
|
||||
- affordable_in_tier = floor(500000/12000) = 41 → take = min(20, 41) = 20
|
||||
- total = 20; balance_kopecks = 500000 − 20×12000 = 260000
|
||||
- take == slots_left → продолжаем; cumulative = 50.
|
||||
- **tier 2:** tier_start=51, tier_end=150, slots_left = 150−max(50, 30) = 100.
|
||||
- affordable = floor(260000/10000) = 26 → take = min(100, 26) = 26
|
||||
- total = 46; balance_kopecks = 260000 − 26×10000 = 0
|
||||
- take < slots_left → выход.
|
||||
- **Итог:** `{ leads: 46, breakdown: [{1, 20, '120.00'}, {2, 26, '100.00'}], current_tier: {1, '120.00', 20}, next_tier: {2, '100.00', 100} }`
|
||||
|
||||
UI: «**≈ 46 лидов**» крупно. Tooltip: «20 лидов по 120 ₽ + 26 по 100 ₽».
|
||||
|
||||
---
|
||||
|
||||
## §6. Реестр находок «Биллинг» (закрывается в этом спеке)
|
||||
|
||||
**P0 — критичные:**
|
||||
|
||||
- **№1.** «Баланс лидов (ГЦК)» карточка → «≈ N лидов» с tooltip. Убрать «(ГЦК)».
|
||||
- **№2.** Дубль `balance_leads` в шапке `BillingView` — удалить из `page-stats`.
|
||||
- **№3.** Таб «Возвраты» в `TransactionsTable` + фильтр `refund` в `BillingController::transactions` — удалить (без возвратов в этом спеке).
|
||||
- **№4.** Чип `prepaid` и фильтр «Источник» в `ChargesTab` — удалить (исторические строки помечаются tooltip'ом).
|
||||
- **№5.** `InvoicesTable.amount_total` без «₽» — добавить суффикс.
|
||||
|
||||
**P1 — важные:**
|
||||
|
||||
- **№6.** `BillingController::runwayDays` — переписать на `affordable_leads / средний_лидов_в_день` (одна формула с шапкой).
|
||||
- **№7.** `AdminPricingTiersController::store` — float → bcmul + `regex:/^\d+(\.\d{1,2})?$/` validation.
|
||||
- **№8.** «Округление вниз ₽→лиды» в `BalanceCard` — удалить (после конвертера термин не нужен).
|
||||
- **№9.** `TopupDialog` алерт «Платёжный шлюз...» — оставить как есть (VTB перекроит в спеке C).
|
||||
- **№10.** `TopupDialog.PRESETS` — синхронизировать с VTB после спека C; в этом спеке не трогаем.
|
||||
- **№11.** `txAmountText` «− 1 лид.» — переписать через `display_amount_rub` от бэка.
|
||||
|
||||
**P2 — нюансы:**
|
||||
|
||||
- **№12.** `TransactionsTable.formatWhen` — добавить год.
|
||||
- **№13.** `ChargesTab.exportCsv` POST → GET — отложено (не блокер).
|
||||
- **№14.** `TenantChargesController::export.balance_rub_after` пустой — заполнить через JOIN.
|
||||
- **№15.** `InvoicesTable.amount_total → Number()` precision — отложено (под VTB).
|
||||
|
||||
**Связанные (вне этого спека):**
|
||||
|
||||
- **№16.** `DuplicateDetector.WINDOW_HOURS = 24` → спек B.
|
||||
- **№17.** `SupplierQuotaAllocator::computeOrder` без учёта баланса → спек C.
|
||||
- **№18.** `RouteSupplierLeadJob::handleInsufficientBalance` останавливает один проект → спек C.
|
||||
- **№19.** `BillingTopupService` зачисляет сразу → спек C (VTB).
|
||||
|
||||
---
|
||||
|
||||
## §7. Открытые вопросы
|
||||
|
||||
Все решения согласованы в брейнсторме 23.05.2026:
|
||||
|
||||
- Вариант 3 (с унификацией `tariff_plans`) — выбран.
|
||||
- Точный расчёт по ступеням — выбран (не «по текущей ступени», не «по ступени 1»).
|
||||
- `balance_leads` удаляется полностью (не остаётся как «подарочный остаток»).
|
||||
- Возвраты — не реализуем.
|
||||
- Конвертер — pure, на бэке, один движок для шапки + карточки + runway.
|
||||
- `TierPricesPanel` — свёрнут по умолчанию.
|
||||
- `tiers_preview` встроен в `/api/billing/wallet` (один запрос).
|
||||
- Релиз — двухфазный (код+data → ALTER TABLE).
|
||||
- Миграция данных — artisan-команда, идемпотентная.
|
||||
|
||||
---
|
||||
|
||||
## §8. Связанные документы
|
||||
|
||||
- Брейнсторм-сессия: 23.05.2026 (transcript не сохранён отдельно — содержание этого спека отражает все решения).
|
||||
- Исходный дизайн биллинга: [docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md](2026-05-11-plan4-billing-csv-admin-design.md).
|
||||
- Спек B (дубли) — будет создан после Спека A.
|
||||
- Спек C (preflight + VTB) — будет создан после Спека B.
|
||||
|
||||
---
|
||||
|
||||
## §9. Следующие шаги
|
||||
|
||||
1. **Пользовательское ревью** этого спека.
|
||||
2. После одобрения — переход к `superpowers:writing-plans` для генерации детального плана реализации (`docs/superpowers/plans/2026-05-23-billing-v2-spec-a-balance-rub-plan.md`).
|
||||
3. Реализация по плану в отдельной ветке (предположительно `feat/billing-v2-spec-a`).
|
||||
4. Релиз Phase A → наблюдение → релиз Phase B.
|
||||
5. Переход к брейнсторму Спека C.
|
||||
@@ -1,160 +0,0 @@
|
||||
# Биллинг v2 — Спек B: политика дублей (дизайн)
|
||||
|
||||
**Дата:** 2026-05-23
|
||||
**Статус:** дизайн утверждён заказчиком, готов к writing-plans
|
||||
**Серия:** Биллинг v2 (Спек A — балансовая модель ✅ Phase A; Спек B — дубли; Спек C — preflight + VTB)
|
||||
**Связано:** `docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md`, memory `project-billing-v2`
|
||||
|
||||
---
|
||||
|
||||
## 1. Главное правило (governing rule)
|
||||
|
||||
**Дедупликация лидов — ответственность ПОСТАВЩИКА (crm.bp-gr.ru), не Лидерры.**
|
||||
|
||||
Поставщик чистит повторы в своём окне (≈30 дней). Что поставщик прислал — для Лидерры это «настоящий» лид: мы его раздаём клиентам и берём за него деньги. **Лидерра повторы по телефону НЕ фильтрует.**
|
||||
|
||||
Единственная наша забота — **не наделать своих дублей**: одна поставка одному клиенту = ровно один оплаченный лид. При этом один лид по-прежнему можно продать **до 3 РАЗНЫХ клиентов** (модель шеринга — это норма, не дубль).
|
||||
|
||||
Формулировка заказчика (брейнсторм 23.05.2026):
|
||||
|
||||
- «убираем все фильтры! но главное нам самим не наделать дублей — нам прислал поставщик в одном экземпляре, а мы клиенту выдали 2 раза! это не касается правила: 1 лид может быть продан 3-м».
|
||||
- «если 5 клиентов заказали лиды с одного источника, то мы можем продать лид только 3-м максимум».
|
||||
|
||||
«Смотри через это правило всё окружение» — спецификация проходит по всем местам, где есть логика дублей/дедупа, и приводит их в соответствие с правилом.
|
||||
|
||||
### Разрез понятий (важно не путать)
|
||||
|
||||
| Понятие | Что это | Решение |
|
||||
|---|---|---|
|
||||
| **Дубль по телефону** (антифрод) | Два разных лида с одинаковым телефоном | НЕ фильтруем — убираем (правило выше) |
|
||||
| **Технический повтор поставки** | Тот же физический лид пришёл дважды (один `vid`) | Идемпотентность по `vid` — оставляем |
|
||||
| **Наш собственный дубль** | Одна поставка → одному клиенту 2 копии | Запрещаем (новый замок в БД) |
|
||||
| **Шеринг** | Один лид → до 3 РАЗНЫХ клиентов | Норма, сохраняем (лимит теперь по клиентам) |
|
||||
| **CSV-восстановление повтора** | CSV тащит то, что уже принято вебхуком | Дедуп CSV по (телефон, проект) — оставляем |
|
||||
|
||||
---
|
||||
|
||||
## 2. Текущее состояние (as-is)
|
||||
|
||||
- **`app/app/Services/DuplicateDetector.php`** — телефонный фильтр. `findMaster(tenantId, phone, now)` ищет master-сделку `(tenant_id, phone)` с `received_at >= now − 24h` и `duplicate_of_id IS NULL`. `WINDOW_HOURS = 24`.
|
||||
- Вызывается в двух местах:
|
||||
- **`ProcessWebhookJob`** (прямой вебхук, `WebhookReceiveController` → `ProcessWebhookJob`; legacy-путь на `balance_leads`): `handle()` находит master → `markAsDuplicate()` (проставляет `duplicate_of_id`, НЕ списывает). Идемпотентность по `vid` обеспечена `webhook_dedup_keys (tenant_id, source_crm_id)`.
|
||||
- **`RouteSupplierLeadJob`** (шеринг, `SupplierWebhookController` → `SupplierLead` → `RouteSupplierLeadJob`; новый путь на `LedgerService`): `createDealCopyForProject()` находит master → помечает дубль, без `chargeForDelivery`.
|
||||
- **`LeadRouter::matchEligibleProjects`** — отдаёт ВСЕ подходящие проекты (по всем клиентам), среди них могут быть несколько проектов одного клиента.
|
||||
- **`LeadDistributor::selectRecipients`** — `CAP = 3`, берёт 3 случайных **проекта** (не клиента). → один лид может попасть в 2+ проекта одного клиента = «наш дубль».
|
||||
- **`CsvReconcileJob`** — дедуп CSV-строк по (телефон, проект) против уже принятых `supplier_leads` за окно 2 дня; недостающие → `SupplierLead(vid=NULL, source='csv_recovery')` → `RouteSupplierLeadJob`.
|
||||
- **Схема:** `deals.duplicate_of_id BIGINT` (без FK — `deals` партиционирована), индекс `ON deals (duplicate_of_id) WHERE duplicate_of_id IS NOT NULL`; индекс `(tenant_id, phone, received_at)`.
|
||||
- **UI:** строка `duplicate_detected` в матрице уведомлений `SettingsView.vue` (8×3). Уведомление **нигде в коде не отправляется** (мёртвая строка). На карточке сделки/в списке отображения дублей нет.
|
||||
|
||||
---
|
||||
|
||||
## 3. Целевое состояние (to-be)
|
||||
|
||||
### 3.1. Убираем наш антифрод-фильтр
|
||||
|
||||
- Удаляем сервис `DuplicateDetector`.
|
||||
- В `ProcessWebhookJob`: убираем вызов `findMaster` + метод `markAsDuplicate`; новая сделка всегда списывается через `chargeNewLead`. Защита от своих дублей здесь — существующая идемпотентность по `vid` (один `vid` → одна сделка на клиента; разные `vid` с одним телефоном → обе списываются — целевое поведение).
|
||||
- В `RouteSupplierLeadJob::createDealCopyForProject`: убираем вызов `findMaster` + ветку пометки дубля.
|
||||
|
||||
### 3.2. Раздача по клиентам, а не по проектам
|
||||
|
||||
- Подбор получателей схлопывается **до одного проекта на клиента**: среди подходящих проектов клиента берём проект с **наибольшим остатком дневного лимита** (`COALESCE(effective_daily_limit_today, daily_limit_target) − delivered_today` по убыванию; при равенстве — `created_at, id` по возрастанию). Детерминированно.
|
||||
- Лимит `CAP = 3` теперь применяется к **разным клиентам**. 5 клиентов под один источник → ровно 3 получают лид (случайный выбор среди клиентов через инъектируемый `Randomizer`, как сейчас).
|
||||
- Реализация (на усмотрение writing-plans): `DISTINCT ON (tenant_id)` в `LeadRouter` с соответствующим `ORDER BY`, либо явное схлопывание по `tenant_id` в отдельном шаге; `LeadDistributor::selectRecipients` остаётся cap=3 поверх уже «один-на-клиента» списка.
|
||||
|
||||
### 3.3. Замок в БД — «одна поставка одному клиенту = один раз» (ядро Варианта B)
|
||||
|
||||
Новая таблица-замок `supplier_lead_deliveries`:
|
||||
|
||||
| Колонка | Тип | Назначение |
|
||||
|---|---|---|
|
||||
| `supplier_lead_id` | BIGINT NOT NULL | поставка (FK на `supplier_leads(id)` ON DELETE CASCADE — `supplier_leads` не партиционирована) |
|
||||
| `tenant_id` | BIGINT NOT NULL | клиент |
|
||||
| `deal_id` | BIGINT NULL | созданная сделка (без FK — `deals` партиционирована, паттерн как `duplicate_of_id`) |
|
||||
| `created_at` | TIMESTAMPTZ NOT NULL DEFAULT now() | |
|
||||
|
||||
- **PRIMARY KEY (`supplier_lead_id`, `tenant_id`)** — уникальность «поставка ↔ клиент».
|
||||
- **RLS** `tenant_isolation` по `tenant_id` (USING + WITH CHECK на `app.current_tenant_id`), как у прочих tenant-таблиц.
|
||||
- GRANT'ы для 5 ролей (`crm_app_user`, `crm_app_admin`, `crm_supplier_worker` BYPASSRLS, `crm_readonly`, `crm_migrator`).
|
||||
|
||||
**Логика в `createDealCopyForProject`** (внутри той же транзакции с `SET LOCAL app.current_tenant_id`, ПОСЛЕ lock'а tenant и recheck'а лимита проекта, ДО создания сделки):
|
||||
|
||||
1. `INSERT INTO supplier_lead_deliveries (supplier_lead_id, tenant_id, created_at) VALUES (...) ON CONFLICT (supplier_lead_id, tenant_id) DO NOTHING`.
|
||||
2. Если вставлено 0 строк → эта поставка этому клиенту уже выдавалась → `return false` (сделку не создаём, баланс не списываем).
|
||||
3. Иначе → создаём `Deal`, `UPDATE supplier_lead_deliveries SET deal_id = ...`, затем `LedgerService::chargeForDelivery` + счётчики + уведомление.
|
||||
|
||||
**Почему ключ по `supplier_lead_id`, а не по телефону:** это НЕ возвращает телефонный фильтр. Два разных лида с одним телефоном — две разные поставки, два разных `supplier_lead_id`, обе оплачиваются. А одну и ту же поставку клиенту дважды БД физически не пропустит — даже при гонках, перезапусках задачи (`tries=3`) и CSV-восстановлении (где `vid=NULL`, но `supplier_lead_id` всегда есть).
|
||||
|
||||
**Scope:** замок нужен только в шеринг-пути (`RouteSupplierLeadJob`). Прямой вебхук (`ProcessWebhookJob`) не идёт через `supplier_leads` и уже защищён `webhook_dedup_keys (tenant_id, vid)` — там замок не требуется.
|
||||
|
||||
### 3.4. Чистка следов концепции дублей
|
||||
|
||||
- Перестаём писать `deals.duplicate_of_id`. Колонку оставляем **спящей** (решение заказчика 23.05 — безопаснее; удалить можно отдельной задачей, как `balance_leads` в Спеке A Phase B). Индекс `ON deals (duplicate_of_id) WHERE NOT NULL` становится лишним — удаляем сразу.
|
||||
- Убираем строку `duplicate_detected` из матрицы уведомлений `SettingsView.vue` (8×3 → 7×3). Старый ключ в сохранённых `users.notification_preferences` JSONB просто игнорируем (не ломает).
|
||||
|
||||
### 3.5. Что НЕ трогаем
|
||||
|
||||
- Идемпотентность по `vid` в `ProcessWebhookJob` (`webhook_dedup_keys`).
|
||||
- Дедуп CSV по (телефон, проект) в `CsvReconcileJob` — это защита от наших дублей, в духе правила.
|
||||
- Рабочие дни (`delivery_days_mask`), дневные лимиты, проверку баланса (eligibility) — настройки клиента/биллинг, не фильтры дублей.
|
||||
|
||||
---
|
||||
|
||||
## 4. Крайние случаи
|
||||
|
||||
| Случай | Поведение |
|
||||
|---|---|
|
||||
| Один телефон у РАЗНЫХ клиентов | Каждый клиент оплачивает свою копию (без изменений — корректно) |
|
||||
| Один телефон, ДВЕ разные поставки (`vid` A и B), один клиент | Обе списываются (НОВОЕ — раньше глушилось телефонным фильтром) |
|
||||
| Одна поставка, у клиента 2+ подходящих проекта | Один проект (max остаток лимита), одно списание (замок + раздача-по-клиентам) |
|
||||
| 5 клиентов под один источник | Ровно 3 получают и оплачивают |
|
||||
| CSV-восстановленный лид (`vid=NULL`) | Замок по `supplier_lead_id` работает; повторная выдача тому же клиенту не пройдёт |
|
||||
| Перезапуск задачи (`tries=3`) после частичного успеха | `processed_at` + замок не дают повторных списаний |
|
||||
| Прямой вебхук, тот же `vid` повторно | Одна сделка (идемпотентность `vid`), без изменений |
|
||||
|
||||
---
|
||||
|
||||
## 5. Тесты
|
||||
|
||||
Добавить:
|
||||
|
||||
- Один телефон, две разные поставки, один клиент → списано дважды.
|
||||
- Одна поставка, у клиента 2 подходящих проекта → одна сделка + одно списание; выбран проект с наибольшим остатком лимита (тай-брейк).
|
||||
- 5 клиентов eligible под один источник → ровно 3 списания у 3 разных клиентов.
|
||||
- Замок: повторный `INSERT` той же `(supplier_lead_id, tenant_id)` → `DO NOTHING`, сделка не создаётся, баланс не тронут.
|
||||
- CSV-восстановление: лид с `vid=NULL`, повторная выдача клиенту → замок срабатывает.
|
||||
|
||||
Удалить:
|
||||
|
||||
- Тесты телефонного фильтра в `ProcessWebhookJobTest`, `RouteSupplierLeadJobTest`, `RouteSupplierLeadJobBillingTest`, `SupplierLeadFlowTest`, `AutoPauseFlowTest`, `DealCreatePdLogTest` (по факту наличия — verify в writing-plans).
|
||||
|
||||
Регрессия: Pest на затронутом коде зелёный; Larastan/Pint/ESLint clean; Vitest на `SettingsView` (после правки матрицы).
|
||||
|
||||
---
|
||||
|
||||
## 6. Выкатка
|
||||
|
||||
- Изменение **одна-фазное**: код + новая аддитивная таблица `supplier_lead_deliveries`. Двухфазность (как в Спеке A с DROP COLUMN) не нужна — ничего разрушающего (`duplicate_of_id` остаётся в БД спящей).
|
||||
- `db/CHANGELOG_schema.md` — запись о новой таблице (правило проекта §4.2).
|
||||
- **Бизнес-эффект:** после выката клиенты начнут платить за то, что раньше глушилось как «дубль» — это и есть заказанная правка. Предупредить заказчика перед прод-merge.
|
||||
|
||||
---
|
||||
|
||||
## 7. Границы (out of scope)
|
||||
|
||||
- `DuplicateDetector.WINDOW_HOURS` тюнинг — N/A, сервис удаляется целиком.
|
||||
- Балансовая модель, тарифные ступени — Спек A.
|
||||
- Preflight баланса, `SupplierQuotaAllocator`, VTB-эквайринг — Спек C.
|
||||
- Физическое удаление `deals.duplicate_of_id` — потенциальная отдельная cleanup-задача.
|
||||
|
||||
---
|
||||
|
||||
## 8. Ключевые решения брейнсторма (зафиксированы)
|
||||
|
||||
1. Дедуп — ответственность поставщика; Лидерра телефон не фильтрует.
|
||||
2. `DuplicateDetector` удаляется полностью (Вариант B принят над «лёгким» A и отвергнутым C «помечать но списывать»).
|
||||
3. Лимит шеринга «3» = 3 РАЗНЫХ клиента; одному клиенту одна копия.
|
||||
4. Тай-брейк проекта внутри клиента — максимальный остаток дневного лимита (детерминированно).
|
||||
5. Защита от своих дублей — на уровне БД (замок `supplier_lead_deliveries`, ключ по поставке, не по телефону).
|
||||
6. `duplicate_of_id` остаётся спящей; индекс по ней удаляется.
|
||||
7. Одна-фазная выкатка.
|
||||
@@ -1,820 +0,0 @@
|
||||
# Спек C — Биллинг v2: preflight баланса + VTB-эквайринг
|
||||
|
||||
**Дата:** 2026-05-24
|
||||
**Статус:** Design (awaiting user review)
|
||||
**Автор:** Claude Opus 4.7 (под руководством заказчика)
|
||||
**Брейнсторм:** сессия 24.05.2026
|
||||
**Триггер:** «preflight баланса перед заказом у поставщика, чтобы не заказать лишнего; VTB-эквайринг для пополнения баланса» (заказчик 23.05.2026, расширено в брейнсторме 24.05).
|
||||
|
||||
**Часть серии из 3 спеков:**
|
||||
|
||||
- Спек A — единый ₽-баланс (DONE на проде, [спек](2026-05-23-billing-v2-spec-a-balance-rub-design.md)).
|
||||
- Спек B — политика дублей (DONE на проде, [спек](2026-05-23-billing-v2-spec-b-duplicates-design.md)).
|
||||
- **Спек C (этот)** — preflight баланса + VTB-эквайринг.
|
||||
|
||||
---
|
||||
|
||||
## §1. Контекст и проблема
|
||||
|
||||
### §1.1 Текущее поведение портала
|
||||
|
||||
**Расчёт заказа у поставщика** ([app/app/Services/Supplier/SupplierQuotaAllocator.php:88-98](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L88-L98)):
|
||||
|
||||
```
|
||||
order = max(самый_большой_лимит, ceil(сумма_лимитов ÷ 3))
|
||||
```
|
||||
|
||||
где входной массив — `daily_limit` всех eligible-на-сегодня проектов клиентов на источнике (источник = тег × субъект). Затем `order` делится между площадками B1/B2/B3 (`distributeForPlatform`, largest-remainder).
|
||||
|
||||
**Allocator не смотрит баланс.** На вход принимает только лимиты проектов. Если у клиента нулевой баланс — он всё равно учитывается в формуле, значит портал заказывает у поставщика лиды, которые этот клиент оплатить не сможет.
|
||||
|
||||
**Списание с клиента** ([app/app/Services/Billing/LedgerService.php](../../../app/app/Services/Billing/LedgerService.php)) происходит при доставке (`RouteSupplierLeadJob` создаёт `Deal` → `LedgerService::chargeForDelivery`). Если баланса не хватает — после Спека A не было защиты «на входе»; шёл лид, списывалось, баланс уходил в ноль.
|
||||
|
||||
**Пополнение баланса** ([app/app/Services/Billing/BillingTopupService.php](../../../app/app/Services/Billing/BillingTopupService.php)) — MVP-stub: мгновенно кредитует `balance_rub` + пишет `balance_transactions(type='topup')`. Реальной оплаты нет. UI — `AdminTenantsController::adjustBalance` (admin-only).
|
||||
|
||||
### §1.2 Проблемы
|
||||
|
||||
**Преfflight:**
|
||||
|
||||
1. **Портал переплачивает поставщику** за лиды клиентов, у которых нет денег. Это прямой убыток — закупка оплачена, а продажа не состоится.
|
||||
2. **Нет проактивной защиты при создании/изменении проектов.** Клиент может выставить лимит, который сам по себе не оплачиваем. Сейчас проблема всплывает только в момент списания.
|
||||
3. **Нет ясной коммуникации с клиентом** «у тебя баланса хватит на X лидов, ты заказал Y, нужно пополнить или сократить» — клиент узнаёт по факту остановки списания.
|
||||
|
||||
**VTB-эквайринг:**
|
||||
|
||||
4. **Реального пополнения нет** — `BillingTopupService` это заглушка. Деньги попадают на баланс только через ручное действие админа (`AdminTenantsController::adjustBalance`).
|
||||
5. **Нет 54-ФЗ фискализации** при ритейл-платежах (после подключения карт/СБП — обязательно).
|
||||
|
||||
### §1.3 Триггер
|
||||
|
||||
Заказчик 23.05.2026: «preflight баланса перед заказом у поставщика; VTB-эквайринг; аудит раздела Биллинг» → в брейнсторме 24.05 уточнено как один спек с детальной механикой preflight + полный охват трёх методов оплаты (безнал, СБП, карты).
|
||||
|
||||
**Главный принцип** (заказчик 24.05): «**не заказать лишнего у поставщика — это убыток**».
|
||||
|
||||
---
|
||||
|
||||
## §2. Scope
|
||||
|
||||
### §2.1 Что делаем
|
||||
|
||||
**Преfflight баланса:**
|
||||
|
||||
- Расширение `SupplierQuotaAllocator` для учёта баланса клиента (фильтрация eligible-проектов до `computeOrder`).
|
||||
- Активная проверка при создании/правке проекта в личном кабинете (диалог выбора).
|
||||
- Активная проверка перед cut-off (18:00 MSK ежедневно) — для пассивного износа баланса.
|
||||
- UI-баннер «приём приостановлен» в личном кабинете клиента.
|
||||
- Уведомления по email с правильной частотой (1 + 1д + 3д + «возобновлено»).
|
||||
- Журналирование событий заморозки/разморозки в `balance_transactions` или новой таблице.
|
||||
|
||||
**VTB-эквайринг:**
|
||||
|
||||
- Архитектура (интерфейс `TopupGateway` + три реализации: `BankTransfer`, `SBP`, `Card`).
|
||||
- **Полная реализация Безнала** (генерация PDF-счёта, журнал «ожидающих платежей» в админке, авто-поиск через VTB Бизнес API с подтверждением человеком, режимный переключатель «автомат / с подтверждением», часовые email-алерты).
|
||||
- **Dev-заглушки для СБП и Карт** (мгновенное подтверждение в dev/test, реальные эндпоинты VTB — после Б-1).
|
||||
- **Архитектура 54-ФЗ** через ОФД-Атол (заглушка в dev, реальная интеграция после Б-1 параллельно с СБП).
|
||||
|
||||
### §2.2 Что НЕ делаем (явно out of scope)
|
||||
|
||||
- **Реальное подключение VTB Acquiring и СБП** — требует реквизитов ООО (P0-блокер Б-1). Отдельные задачи после Б-1.
|
||||
- **Реальная интеграция ОФД-Атол** — параллельно с СБП после Б-1.
|
||||
- **Авто-сверка с банковской выпиской VTB** (`/api/vtb-business`) — отдельная задача после Б-1; в этом спеке только архитектурный интерфейс и ручной режим.
|
||||
- **«Отдать разморозившемуся клиенту лиды, уже купленные сегодня, через шеринг»** — отложено в Спек D (см. §8).
|
||||
- **Возвраты пополнений** (refund) — не реализуем (Спек A: «возвраты не делаем»).
|
||||
- **Recurring-платежи** (автосписание) — не реализуем.
|
||||
- **Изменение формулы `computeOrder`** — формула остаётся прежней, преfflight только фильтрует входной список.
|
||||
|
||||
---
|
||||
|
||||
## §3. Решение — часть 1: Преfflight баланса
|
||||
|
||||
### §3.1 Главный инвариант
|
||||
|
||||
**Баланс клиента никогда не уходит в минус.** Гарант — преfflight, который проверяет «хватит ли на полный дневной заказ» **до** того, как заказ уйдёт поставщику. Если хватает — клиент в заказе; если поставщик пришлёт меньше планируемого (норма), остаток баланса уходит в следующий день.
|
||||
|
||||
### §3.2 Когда срабатывает преfflight
|
||||
|
||||
**Одна основная точка:** ежедневный cut-off в **18:00 MSK** (включая выходные).
|
||||
|
||||
Между cut-off и cut-off (всё внутри текущего дня) никаких внутридневных стопов нет. Лиды, заказанные у поставщика на сегодня, идут клиенту полностью, списываются с его баланса как поступают. Защита от ухода в минус — на стороне cut-off предыдущего вечера.
|
||||
|
||||
**Дополнительные триггерные точки** (для UX в личном кабинете, не для блокировки заказа):
|
||||
|
||||
- Создание нового проекта в UI клиента.
|
||||
- Изменение лимита существующего проекта в UI клиента.
|
||||
- Активация ранее приостановленного проекта.
|
||||
- Пополнение баланса (для авто-разморозки при следующем cut-off).
|
||||
- Снижение/удаление проекта (может вернуть в зелёную зону).
|
||||
|
||||
### §3.3 Что значит «баланса хватает»
|
||||
|
||||
Сравнение делается **в лидах**, не в рублях, потому что в existing-сервисе `BalanceToLeadsConverter` (от Спека A) есть прямой расчёт «сколько лидов даст баланс с учётом 7 ступеней и уже отгруженного объёма за месяц»:
|
||||
|
||||
```php
|
||||
$capacity = $converter->convert(
|
||||
balanceRub: $tenant->balance_rub,
|
||||
deliveredInMonth: $tenant->delivered_in_month,
|
||||
tiers: $activePricingTiers
|
||||
)['leads'];
|
||||
|
||||
$requiredLeads = $tenant->projects()
|
||||
->where('status', 'active')
|
||||
->where('eligible_tomorrow', true)
|
||||
->sum('daily_limit');
|
||||
|
||||
$passes = $capacity >= $requiredLeads;
|
||||
```
|
||||
|
||||
Если `passes=true` — клиент проходит преfflight. Если `false` — не проходит.
|
||||
|
||||
7-ступенчатый расчёт уже реализован в `BalanceToLeadsConverter::convert` (Спек A) — он сам пройдёт по ступеням, учтёт «текущую» (где сейчас клиент в накопленном объёме) и переход на следующие при росте.
|
||||
|
||||
**NB:** проверяется на **полный лимит** проектов, не на «уже отгруженное + остаток сегодняшнего дня». Это потому, что преfflight работает один раз перед формированием заказа на завтра, а не во время выдачи.
|
||||
|
||||
### §3.4 Что делает портал при создании/правке «перегруженного» проекта
|
||||
|
||||
В UI клиента при попытке сохранить проект, после которого сумма `daily_limit` всех eligible-проектов превысит «потолок баланса»:
|
||||
|
||||
**Модальный диалог** (не блокирующая ошибка):
|
||||
|
||||
```
|
||||
Этот лимит превышает твой баланс.
|
||||
У тебя на счёте 1000₽ = 30 лидов по текущему тарифу.
|
||||
После сохранения этого проекта сумма лимитов будет 40 лидов.
|
||||
Не хватает: 10 лидов.
|
||||
|
||||
Чтобы он начал работать, нужно одно из:
|
||||
• Пополнить счёт (примерно 350₽ покроют 10 лидов недостачи)
|
||||
• Поставить лимит этого проекта 0
|
||||
• Уменьшить лимиты других проектов
|
||||
|
||||
[Сохранить и приостановить только этот] [Поставить лимит 0] [Отмена]
|
||||
```
|
||||
|
||||
- **«Сохранить и приостановить только этот»** — проект сохраняется с исходным лимитом, но при следующем cut-off исключается из расчёта заказа на завтра (`active_today = false`). Остальные проекты клиента работают как обычно.
|
||||
- **«Поставить лимит 0»** — проект сохраняется с лимитом 0 (фактически выключен). Не идёт в заказ.
|
||||
- **«Отмена»** — изменения отбрасываются.
|
||||
|
||||
**Ключевое:** заморозка точечная, **только перегружающий проект**. Не «весь тенант». Это позволяет клиенту работать над созданием 20-30 проектов поэтапно, постепенно понимая «нужно столько-то ₽ для запуска всех».
|
||||
|
||||
### §3.5 Что делает портал при пассивном износе баланса
|
||||
|
||||
Клиент не правил проекты, просто баланс таял по дням. На очередном cut-off (18:00 MSK) выясняется, что баланс уже не покрывает все активные проекты.
|
||||
|
||||
**Действие:**
|
||||
|
||||
1. **Все проекты клиента** исключаются из расчёта заказа на завтра.
|
||||
2. На `tenants` устанавливается флаг `frozen_by_balance_at = now()` (новая колонка).
|
||||
3. На email клиента — письмо «Приём лидов приостановлен» (детали §3.7).
|
||||
4. В личном кабинете — красный баннер на всех страницах (детали §3.6).
|
||||
|
||||
Клиент сам выбирает что делать в личном кабинете:
|
||||
|
||||
- Пополнить баланс (откроет UI «Пополнение», см. часть 2).
|
||||
- Снизить лимиты на проектах (UI «Проекты»).
|
||||
- Выключить часть проектов (paused).
|
||||
- Любое сочетание.
|
||||
|
||||
Как только сумма лимитов снова влезает в баланс (после пополнения, снижения лимита, или выключения проектов) — `frozen_by_balance_at = NULL`, баннер исчезает, отправляется письмо «Возобновлено» (если успели до 18:00 — клиент в завтрашнем заказе; если позже — в послезавтрашнем).
|
||||
|
||||
### §3.6 UI личного кабинета клиента
|
||||
|
||||
**Красный баннер на всех страницах** (компонент `BalanceFrozenBanner.vue`) когда `tenant.frozen_by_balance_at IS NOT NULL`:
|
||||
|
||||
```
|
||||
🔴 Приём лидов приостановлен
|
||||
Не хватает баланса на дневной заказ. Нужно ещё 380₽ (или сократи лимиты на 10 лидов).
|
||||
[Пополнить счёт] [Перейти к проектам]
|
||||
```
|
||||
|
||||
**Постоянная подсказка под балансом** (даже когда не в заморозке) — компонент `BalanceCapacityIndicator.vue`:
|
||||
|
||||
```
|
||||
Баланс: 1000₽ = до 30 лидов по тарифу
|
||||
Проекты заказывают: 25 лидов в день
|
||||
✅ Хватит на ~1.2 дня
|
||||
```
|
||||
|
||||
В состоянии «хватает на меньше 3 дней» — жёлтый цвет с подсказкой «скоро потребуется пополнение». В состоянии «не хватает» — красный (баннер выше).
|
||||
|
||||
### §3.7 Email-уведомления
|
||||
|
||||
**При входе в заморозку:**
|
||||
|
||||
- T+0 (сразу) — `BalanceFrozenMail` («Приём лидов приостановлен»).
|
||||
- T+24ч (если ещё в заморозке) — `BalanceFrozenReminderMail` («Всё ещё приостановлено»).
|
||||
- T+72ч (если ещё в заморозке) — `BalanceFrozenFinalMail` («Приостановлено 3 дня»).
|
||||
- Дальше — тишина до следующего цикла (если разморозится и снова попадёт — счёт идёт заново).
|
||||
|
||||
**При выходе из заморозки:**
|
||||
|
||||
- T+0 — `BalanceUnfrozenMail` («Приём возобновлён»).
|
||||
|
||||
Все письма throttled по `tenant_id` через `mail_log` (паттерн `ZeroBalancePausedMail` из Plan 4) — повторов не будет.
|
||||
|
||||
### §3.8 Cut-off режимы синхронизации с поставщиком
|
||||
|
||||
Сохраняем оба существующих режима (admin-переключатель уже есть):
|
||||
|
||||
- **Онлайн-режим** (сейчас, для малого числа клиентов): любое изменение в проектах Лидерры → немедленный апдейт на сервере поставщика (`SyncSupplierProjectJob` per-project). Поставщик сохраняет, использует в своём 21:00 слепке.
|
||||
- **Batch до 18:00** (на будущее при росте): накопленные изменения уезжают одним пакетом перед 18:00 (`SyncSupplierProjectsJob` daily cron).
|
||||
|
||||
**Преfflight работает одинаково в обоих режимах** — он только меняет, какие проекты идут в `SyncSupplierProjectJob` (исключает frozen-проекты из `active_today`). Дальше — стандартный механизм синхронизации.
|
||||
|
||||
### §3.9 Граничные случаи
|
||||
|
||||
| Случай | Поведение |
|
||||
|---|---|
|
||||
| Ретро-операция (CSV-импорт исторических лидов) между 18:00 и началом следующего дня списывает баланс ниже плана | Допускается, но админ предупреждается в UI «эта операция может вывести клиента в заморозку, продолжить?». Если согласился — выполняется; на следующем cut-off клиент будет в заморозке. Минусовых балансов не возникает (CSV-импорт делает обычные `lead_charges` через `LedgerService`, который остаётся защищён от минуса) |
|
||||
| Ручная правка баланса админом (`adjustBalance`) уменьшает баланс ниже плана | Аналогично — админ предупреждается, ответственность на нём. Преfflight отработает на следующем cut-off |
|
||||
| Клиент уже в минусовом балансе на момент запуска преfflight (legacy состояние) | Одноразовая artisan-команда `billing:preflight-initial-sweep` — проходит по всем тенантам, помечает `frozen_by_balance_at` где нужно, отправляет письма с пояснением «у вас активирована новая защита баланса». Запускается один раз при выкатке миграции |
|
||||
| Тарифная ступень меняется в течение дня (накопился объём) | Преfflight на 18:00 MSK использует **текущую** ступень. На завтра ступень может быть другой — но это уже зона следующего cut-off |
|
||||
| Поставщик прислал меньше планируемого (норма) | Остаток баланса клиента — экономия для следующего дня. Никаких корректировок |
|
||||
| Клиент пополнил после 18:00 | В сегодня-в-21:00-слепок поставщика не успевает, но в личном кабинете тут же «Возобновлено». В следующий вечерний cut-off — в заказ на послезавтра |
|
||||
|
||||
### §3.10 Шеринг с другими клиентами на том же источнике
|
||||
|
||||
**Формула** (живёт в `SupplierQuotaAllocator::computeOrder`, [код](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L88-L98)):
|
||||
|
||||
```
|
||||
order = max(самый_большой_лимит, ceil(сумма_лимитов ÷ 3))
|
||||
```
|
||||
|
||||
Преfflight **не меняет формулу**, а **фильтрует входной массив `daily_limits`** — выкидывает клиентов, не прошедших проверку.
|
||||
|
||||
**Эффект зависит от того, кого выкинули:**
|
||||
|
||||
| Кто выкинут | `max(...)` | Заказ у поставщика | Маржа портала |
|
||||
|---|---|---|---|
|
||||
| Крупнейший клиент группы | Падает (новый крупнейший меньше) | **Уменьшается** — реальная экономия закупки | Падает |
|
||||
| Любой некрупный | Не меняется | **Не меняется** | Падает на лимит выкинутого |
|
||||
|
||||
**Гарантии:**
|
||||
|
||||
1. **Никогда не вредит** другим клиентам в группе — их лимиты неприкосновенны.
|
||||
2. **Никогда не заказывает у поставщика на «бедного»** — он исключён из формулы.
|
||||
3. **Реальная экономия закупки** только при выкидывании крупнейшего. В остальных случаях защищаемся от логической ошибки «заказали лиды, оплатить которые некому» без эффекта на закупку.
|
||||
|
||||
### §3.11 Журналирование
|
||||
|
||||
При каждом срабатывании преfflight (заморозка / разморозка) — строка в новой таблице `balance_freeze_log`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE balance_freeze_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||
event_type VARCHAR(20) NOT NULL, -- 'frozen' | 'unfrozen' | 'project_overload_dialog'
|
||||
triggered_by VARCHAR(30) NOT NULL, -- 'cutoff_18msk' | 'project_update' | 'topup'
|
||||
balance_rub_at_event DECIMAL(12,2) NOT NULL,
|
||||
required_rub_at_event DECIMAL(12,2) NOT NULL,
|
||||
leads_capacity INTEGER NOT NULL,
|
||||
total_daily_limit INTEGER NOT NULL,
|
||||
details JSONB, -- какие проекты, какая причина, и т.д.
|
||||
created_at TIMESTAMP DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
RLS — `tenant_isolation` стандартный (как для большинства tenant-таблиц). Append-only через `audit_block_mutation` триггер. Цель — видеть в админке историю «когда и почему клиент попадал в заморозку».
|
||||
|
||||
---
|
||||
|
||||
## §4. Решение — часть 2: VTB-эквайринг (3 метода оплаты)
|
||||
|
||||
### §4.1 Архитектурный подход
|
||||
|
||||
**Интерфейс `TopupGatewayInterface`** + **три реализации**:
|
||||
|
||||
| Реализация | Метод оплаты | Статус в этом спеке |
|
||||
|---|---|---|
|
||||
| `BankTransferGateway` | Безнал (счёт PDF) | **Полная реализация** |
|
||||
| `SbpGateway` | СБП | Архитектура + dev-заглушка; реальный код после Б-1 |
|
||||
| `CardGateway` | Карта | Архитектура + dev-заглушка; реальный код после Б-1 |
|
||||
|
||||
`BillingTopupService` рефакторится — становится оркестратором:
|
||||
|
||||
```php
|
||||
public function initiateTopup(
|
||||
int $tenantId,
|
||||
string $amountRub,
|
||||
int $userId,
|
||||
string $method // 'bank_transfer' | 'sbp' | 'card'
|
||||
): TopupSession {
|
||||
$gateway = $this->resolveGateway($method);
|
||||
return $gateway->createSession($tenantId, $amountRub, $userId);
|
||||
}
|
||||
```
|
||||
|
||||
И отдельно обработка callback'а:
|
||||
|
||||
```php
|
||||
public function confirmPayment(string $providerRef, ?int $adminUserId): BalanceTransaction {
|
||||
// gateway-agnostic кредит баланса + запись audit
|
||||
}
|
||||
```
|
||||
|
||||
### §4.2 Состояния платежа
|
||||
|
||||
Новая таблица `topup_sessions`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE topup_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||
user_id BIGINT REFERENCES users(id),
|
||||
method VARCHAR(20) NOT NULL, -- 'bank_transfer' | 'sbp' | 'card'
|
||||
amount_rub DECIMAL(12,2) NOT NULL,
|
||||
provider_ref VARCHAR(100), -- номер счёта / VTB transaction ID / SBP QR ID
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- 'pending' | 'matched' | 'confirmed' | 'failed' | 'expired'
|
||||
matched_at TIMESTAMP, -- когда нашли соответствие (только bank_transfer)
|
||||
confirmed_at TIMESTAMP, -- когда человек подтвердил (или авто)
|
||||
confirmed_by INTEGER REFERENCES users(id), -- кто подтвердил (NULL = автомат)
|
||||
failed_reason VARCHAR(500),
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP DEFAULT now(),
|
||||
updated_at TIMESTAMP DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
RLS — `tenant_isolation` для tenant_id; админ видит все через `crm_app_admin` роль.
|
||||
|
||||
После `status='confirmed'` — кредит баланса (`BalanceTransaction` с `type='topup'` и ссылкой на `topup_session_id`).
|
||||
|
||||
### §4.3 Безнал — полная реализация
|
||||
|
||||
**Шаг 1: Клиент инициирует пополнение**
|
||||
|
||||
В личном кабинете → Биллинг → «Пополнить счёт» → выбор метода «Безнал (счёт)» → ввод суммы → кнопка «Сформировать счёт».
|
||||
|
||||
Backend: `BankTransferGateway::createSession` — создаёт `topup_sessions(method='bank_transfer', status='pending')`, генерирует уникальный номер счёта (`SCH-{YYYY}-{tenant_id}-{seq}`).
|
||||
|
||||
**Шаг 2: Генерация PDF-счёта**
|
||||
|
||||
Шаблон `resources/views/pdf/invoice.blade.php`:
|
||||
|
||||
- Шапка: реквизиты Лидерры (название ООО, ИНН, КПП, юр. адрес, расчётный счёт VTB).
|
||||
- Реквизиты плательщика (тенант): название, ИНН, КПП, юр. адрес — берутся из новых полей `tenants.legal_entity_*` (см. §4.7).
|
||||
- Назначение платежа: «Оплата лидов по договору публичной оферты, счёт №SCH-2026-15-001 от 24.05.2026».
|
||||
- Сумма: NNNN,NN ₽ (БЕЗ НДС или С НДС в зависимости от системы налогообложения Лидерры — `tax_regime` в admin-settings; для УСН 6% — без НДС).
|
||||
- Срок оплаты: 5 рабочих дней.
|
||||
|
||||
Возвращается клиенту как файл скачивания. URL `/api/billing/topup-sessions/{id}/invoice.pdf`.
|
||||
|
||||
**Шаг 3: Авто-поиск платежа в VTB Бизнес API**
|
||||
|
||||
Артизан-команда `billing:vtb-statement-sync --since=N` (cron: каждые 15 минут после Б-1; в dev — manual только):
|
||||
|
||||
- Запрашивает выписку у VTB Бизнес API за период N часов.
|
||||
- Для каждой входящей транзакции ищет в `назначение_платежа` номер счёта (`SCH-YYYY-...`).
|
||||
- При совпадении — `topup_sessions(provider_ref=...).status = 'matched'`, `matched_at = now()`.
|
||||
|
||||
В dev/test (когда нет реальных VTB-реквизитов) — режим симуляции: команда `billing:vtb-statement-simulate {session_id} --amount=N` для ручного тестирования флоу.
|
||||
|
||||
**Шаг 4: Подтверждение человеком или автомат (admin setting)**
|
||||
|
||||
В админке `Биллинг → Настройки` — переключатель:
|
||||
|
||||
- **«С подтверждением» (по умолчанию):** платёж в статусе `matched` ждёт ручного клика «Подтвердить зачисление». До этого баланс не растёт.
|
||||
- **«Автомат»:** платёж в статусе `matched` сразу переводится в `confirmed`, баланс растёт.
|
||||
|
||||
В админке `Биллинг → Ожидающие платежи` — список платежей в статусах `matched` и `pending`:
|
||||
|
||||
```
|
||||
[SCH-2026-15-001] ООО Альфа, 100000₽, найден 24.05 15:42 [Подтвердить] [Отклонить]
|
||||
[SCH-2026-22-003] ИП Иванов, 50000₽, не найден (3 дня) [Найти вручную] [Отменить]
|
||||
```
|
||||
|
||||
Кнопка «Подтвердить» → `confirmPayment(...)` → кредит баланса + `BalanceTransaction(type='topup', topup_session_id=...)`.
|
||||
|
||||
**Шаг 5: Часовые email-алерты админу (только в режиме «С подтверждением»)**
|
||||
|
||||
Cron `billing:notify-pending-confirmations` каждый час:
|
||||
|
||||
- Если есть платежи в статусе `matched`, не подтверждённые — отправить email админу (`admin_emails` из settings, по умолчанию `eclips9363@gmail.com`).
|
||||
- Throttle: один email в час суммарно.
|
||||
|
||||
### §4.4 СБП — архитектура + dev-заглушка
|
||||
|
||||
**Реализация в этом спеке:**
|
||||
|
||||
- `SbpGateway::createSession` — в dev/test возвращает фейковый QR-код PNG (`data:image/png;base64,...`) и через 5 секунд (фоновый job) переводит сессию в `confirmed`. Это позволяет полностью отлаживать UI и пост-обработку.
|
||||
- В prod (после Б-1): зовёт реальный VTB SBP API, получает QR-код / платёжную ссылку, регистрирует callback на `/api/billing/vtb-sbp/callback`.
|
||||
|
||||
**Реализация после Б-1 (отдельная задача):**
|
||||
|
||||
- Боевая интеграция VTB SBP API (REST + signed callbacks).
|
||||
- ОФД-Атол для 54-ФЗ чеков (см. §4.6).
|
||||
- Боевые секреты — в YC Lockbox (SEC-5).
|
||||
|
||||
### §4.5 Карты — архитектура + dev-заглушка
|
||||
|
||||
**Реализация в этом спеке:**
|
||||
|
||||
- `CardGateway::createSession` — в dev/test возвращает редирект-URL на локальную страницу `/dev-mock-vtb-acquiring/{session_id}` с двумя кнопками «Симулировать успех» / «Симулировать ошибку». Клик → переводит сессию в `confirmed` или `failed`.
|
||||
- В prod (после Б-1): редирект на боевую страницу VTB internet-эквайринга с 3DS Secure.
|
||||
|
||||
**Реализация после Б-1 (отдельная задача):**
|
||||
|
||||
- Боевая интеграция VTB Acquiring API.
|
||||
- 3DS Secure (обязательно для всех карт).
|
||||
- Обработка chargeback'ов.
|
||||
- ОФД-Атол для 54-ФЗ чеков.
|
||||
|
||||
### §4.6 54-ФЗ фискализация
|
||||
|
||||
**Текущее состояние:** не реализовано. Раздел подсветить в архитектуре, реальный код — после Б-1 параллельно с СБП.
|
||||
|
||||
**Когда нужен чек:**
|
||||
|
||||
| Метод оплаты | Чек 54-ФЗ |
|
||||
|---|---|
|
||||
| Безнал (юр→юр) | **Не нужен** (статья 1.2 п.9 закона) |
|
||||
| СБП | **Обязателен** (B2C ритейл-платёж) |
|
||||
| Карта | **Обязателен** (B2C ритейл-платёж) |
|
||||
|
||||
**Архитектура** (заглушка в этом спеке):
|
||||
|
||||
```php
|
||||
interface FiscalReceiptProvider {
|
||||
public function issueReceipt(TopupSession $session): FiscalReceipt;
|
||||
}
|
||||
```
|
||||
|
||||
Реализации:
|
||||
|
||||
- `AtolOnlineFiscalProvider` — реальная интеграция (после Б-1).
|
||||
- `NoOpFiscalProvider` — для безнала (возвращает «чек не требуется по 54-ФЗ»).
|
||||
- `DevMockFiscalProvider` — для dev/test (фейковый чек ID).
|
||||
|
||||
### §4.7 Реквизиты тенанта
|
||||
|
||||
Новые поля в `tenants` (для счёта-фактуры):
|
||||
|
||||
```sql
|
||||
ALTER TABLE tenants ADD COLUMN legal_entity_name VARCHAR(255); -- "ООО Альфа" или "ИП Иванов Иван"
|
||||
ALTER TABLE tenants ADD COLUMN legal_entity_inn VARCHAR(12);
|
||||
ALTER TABLE tenants ADD COLUMN legal_entity_kpp VARCHAR(9); -- NULL для ИП
|
||||
ALTER TABLE tenants ADD COLUMN legal_entity_address TEXT;
|
||||
ALTER TABLE tenants ADD COLUMN legal_entity_form VARCHAR(20); -- 'OOO' | 'IP' | 'OAO' | 'ZAO' | 'OTHER'
|
||||
```
|
||||
|
||||
В личном кабинете клиента → Настройки → «Реквизиты юр. лица» — форма для заполнения. **Обязательны** для безнала (без них счёт PDF не выписывается); для СБП/карт — опционально (но желательно).
|
||||
|
||||
Валидация ИНН (контрольное число), КПП (формат), форма юрлица — стандартными правилами laravel.
|
||||
|
||||
### §4.8 Минимум/максимум суммы пополнения
|
||||
|
||||
| Метод | Минимум | Максимум |
|
||||
|---|---|---|
|
||||
| Безнал | 1000 ₽ | 1 000 000 ₽ |
|
||||
| СБП | 100 ₽ | 600 000 ₽ (лимит СБП) |
|
||||
| Карта | 100 ₽ | 100 000 ₽ за одну транзакцию |
|
||||
|
||||
Конфигурация в `config/billing.php` (settable).
|
||||
|
||||
---
|
||||
|
||||
## §5. Архитектура изменений
|
||||
|
||||
### §5.1 Карта изменений по слоям
|
||||
|
||||
| Слой | Что |
|
||||
|---|---|
|
||||
| **БД** | `tenants` (+`frozen_by_balance_at`, +5 `legal_entity_*` полей); новые таблицы `balance_freeze_log`, `topup_sessions` |
|
||||
| **Бэк-сервисы** | `SupplierQuotaAllocator` (новый pre-filter pipeline); `BalancePreflightService` (новый, проверка платёжеспособности); `BillingTopupService` (рефакторинг под gateway pattern); `TopupGatewayInterface` + 3 реализации; `FiscalReceiptProvider` + 3 реализации |
|
||||
| **Бэк-джобы** | `BalancePreflightSweepJob` (cron @18:00 MSK ежедневно); `BalanceFrozenNotificationJob` (event-driven при заморозке); `VtbStatementSyncJob` (cron @ каждые 15 мин); `NotifyPendingConfirmationsJob` (cron hourly) |
|
||||
| **Бэк-команды** | `billing:preflight-sweep`, `billing:vtb-statement-sync`, `billing:notify-pending-confirmations`, `billing:preflight-initial-sweep` (one-time migration), `billing:vtb-statement-simulate` (dev only) |
|
||||
| **Бэк-контроллеры** | `BillingController` (+endpoints: `/api/billing/topup/initiate`, `/api/billing/topup-sessions/{id}`, `/api/billing/topup-sessions/{id}/invoice.pdf`); `ProjectController` (preflight check + 409 response с диалогом); `Admin/PendingTopupsController` (новый); `Admin/BillingSettingsController` (новый, для переключателя auto/manual) |
|
||||
| **Бэк-Mail** | `BalanceFrozenMail`, `BalanceFrozenReminderMail`, `BalanceFrozenFinalMail`, `BalanceUnfrozenMail`, `PendingConfirmationsAdminMail` |
|
||||
| **Фронт-компоненты** | `BalanceFrozenBanner.vue`, `BalanceCapacityIndicator.vue`, `ProjectLimitOverloadDialog.vue`, `TopupMethodPicker.vue`, `BankTransferInvoiceView.vue`, `SbpQrCodeView.vue`, `CardRedirectView.vue`, `PendingPaymentsAdminView.vue`, `BillingSettingsAdminView.vue`, `LegalEntityForm.vue` (в Настройки) |
|
||||
| **Фронт-views** | `TopupView.vue` (новый, обёртка с переключателем methods); `BillingFrozenInfoView.vue` |
|
||||
| **Pinia** | `billingStore` (расширение под topup-сессии); `tenantStore` (frozen-флаг) |
|
||||
|
||||
### §5.2 Sequence-диаграмма преfflight на cut-off
|
||||
|
||||
```
|
||||
18:00 MSK Cron
|
||||
│
|
||||
├── BalancePreflightSweepJob::handle()
|
||||
│ │
|
||||
│ ├── для каждого tenant:
|
||||
│ │ │
|
||||
│ │ ├── BalancePreflightService::evaluate(tenant)
|
||||
│ │ │ │
|
||||
│ │ │ ├── читает projects (active=true, eligible_tomorrow=true)
|
||||
│ │ │ ├── требуемые лиды = Σ daily_limit
|
||||
│ │ │ ├── ёмкость лидов = BalanceToLeadsConverter::convert(balance, delivered, tiers)['leads']
|
||||
│ │ │ ├── сравнивает: passes = capacity >= required
|
||||
│ │ │ └── возвращает PreflightResult { passes: bool, required_leads, capacity_leads, deficit_leads }
|
||||
│ │ │
|
||||
│ │ ├── если passes изменился:
|
||||
│ │ │ │
|
||||
│ │ │ ├── tenant.frozen_by_balance_at = NULL | now()
|
||||
│ │ │ ├── balance_freeze_log.insert(event_type='frozen' | 'unfrozen')
|
||||
│ │ │ ├── dispatch(BalanceFrozenMail | BalanceUnfrozenMail)
|
||||
│ │ │ │
|
||||
│ │ │ └── (если frozen) для каждого projects:
|
||||
│ │ │ projects.preflight_blocked_at = now() (новая колонка)
|
||||
│ │ │
|
||||
│ │ └── (если unfrozen) projects.preflight_blocked_at = NULL для всех
|
||||
│ │
|
||||
│ └── для следующего tenant...
|
||||
│
|
||||
18:05 MSK (после преfflight) — обычный SyncSupplierProjectsJob запускается
|
||||
│
|
||||
├── SupplierQuotaAllocator::allocate(eligible_projects)
|
||||
│ │
|
||||
│ ├── фильтр eligible_projects: only WHERE preflight_blocked_at IS NULL
|
||||
│ ├── computeOrder([daily_limit, ...]) (формула не меняется)
|
||||
│ └── distributeForPlatform(order, [B1, B2, B3])
|
||||
│
|
||||
└── SyncSupplierProjectJob (per project) → отправка лимитов поставщику
|
||||
```
|
||||
|
||||
### §5.3 Sequence-диаграмма пополнения (Безнал)
|
||||
|
||||
```
|
||||
Клиент в личном кабинете
|
||||
│
|
||||
├── Кнопка "Пополнить счёт"
|
||||
│
|
||||
└── TopupMethodPicker → выбор "Безнал (счёт)"
|
||||
│
|
||||
└── Ввод суммы → "Сформировать счёт"
|
||||
│
|
||||
└── POST /api/billing/topup/initiate { method: 'bank_transfer', amount_rub: '100000.00' }
|
||||
│
|
||||
├── BillingTopupService::initiateTopup(...)
|
||||
│ │
|
||||
│ └── BankTransferGateway::createSession(...)
|
||||
│ │
|
||||
│ ├── создание topup_sessions(status='pending', provider_ref='SCH-2026-15-001')
|
||||
│ └── возвращает { session_id, invoice_url: '/api/billing/topup-sessions/15001/invoice.pdf' }
|
||||
│
|
||||
└── Редирект клиента на invoice_url → скачивание PDF
|
||||
|
||||
Клиент оплачивает в своём банк-клиенте
|
||||
│
|
||||
└── Деньги поступают на расчётный счёт VTB Лидерры (за ~часы)
|
||||
|
||||
VtbStatementSyncJob (каждые 15 минут)
|
||||
│
|
||||
├── VTB Business API → выписка
|
||||
│
|
||||
└── для каждой входящей транзакции:
|
||||
│
|
||||
├── парсинг назначения платежа → ищем SCH-YYYY-tenant-seq
|
||||
│
|
||||
└── если совпадение найдено + сумма совпадает:
|
||||
│
|
||||
├── topup_sessions.status = 'matched'
|
||||
├── matched_at = now()
|
||||
│
|
||||
└── (если admin-setting auto_confirm=true):
|
||||
│
|
||||
├── BillingTopupService::confirmPayment(provider_ref, NULL)
|
||||
│ │
|
||||
│ ├── topup_sessions.status = 'confirmed'
|
||||
│ ├── tenant.balance_rub += amount_rub (bcadd)
|
||||
│ └── BalanceTransaction.create(type='topup', topup_session_id=...)
|
||||
│
|
||||
└── (если auto_confirm=false): ждёт ручного подтверждения
|
||||
|
||||
NotifyPendingConfirmationsJob (каждый час)
|
||||
│
|
||||
└── если есть topup_sessions(status='matched') не подтверждённые:
|
||||
│
|
||||
└── PendingConfirmationsAdminMail → admin email
|
||||
|
||||
Админ в кабинете
|
||||
│
|
||||
└── Биллинг → Ожидающие платежи → [Подтвердить SCH-2026-15-001]
|
||||
│
|
||||
└── POST /api/admin/topup-sessions/15001/confirm
|
||||
│
|
||||
└── BillingTopupService::confirmPayment(provider_ref, admin_user_id)
|
||||
│
|
||||
├── topup_sessions.status = 'confirmed'
|
||||
├── tenant.balance_rub += amount_rub
|
||||
├── BalanceTransaction.create(type='topup', confirmed_by=admin_user_id)
|
||||
│
|
||||
└── BalancePreflightService::evaluate(tenant) ← может разморозить!
|
||||
│
|
||||
└── если frozen_by_balance_at был NOT NULL и теперь passes:
|
||||
│
|
||||
├── tenant.frozen_by_balance_at = NULL
|
||||
├── balance_freeze_log.insert(event_type='unfrozen', triggered_by='topup')
|
||||
└── BalanceUnfrozenMail отправляется
|
||||
```
|
||||
|
||||
### §5.4 Изменения в `SupplierQuotaAllocator`
|
||||
|
||||
Минимальное и неинвазивное — добавление одного шага в caller (`SyncSupplierProjectsJob`):
|
||||
|
||||
**До:**
|
||||
|
||||
```php
|
||||
$eligibleProjects = $this->projectsQuery->getEligibleForToday($targetDate);
|
||||
$dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
|
||||
```
|
||||
|
||||
**После:**
|
||||
|
||||
```php
|
||||
$eligibleProjects = $this->projectsQuery->getEligibleForToday($targetDate);
|
||||
|
||||
// NEW: фильтр по frozen-флагу tenant
|
||||
$eligibleProjects = $eligibleProjects->reject(
|
||||
fn($p) => $p->tenant->frozen_by_balance_at !== null
|
||||
);
|
||||
|
||||
$dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
|
||||
```
|
||||
|
||||
`SupplierQuotaAllocator` сам не меняется — он pure-функция, принимает то что дали. Это важно для тестируемости (формула не сломалась).
|
||||
|
||||
---
|
||||
|
||||
## §6. Сценарии (end-to-end)
|
||||
|
||||
### §6.1 Преfflight — пассивный износ
|
||||
|
||||
**Воскресенье 00:00.** Клиент с балансом 1000₽ = 30 лидов (tier 3, цена ~33₽/лид). Проекты заказывают 25/день. Запас 5.
|
||||
|
||||
**Воскресенье 18:00.** Cron `BalancePreflightSweepJob`:
|
||||
- `BalancePreflightService::evaluate(client)` → `passes=true` (хватает на 25).
|
||||
- `frozen_by_balance_at` остаётся `NULL`.
|
||||
|
||||
**Воскресенье 18:05.** `SyncSupplierProjectsJob` — все 25 идут в заказ поставщику. Поставщик в 21:00 берёт слепок.
|
||||
|
||||
**Понедельник.** В течение дня партии лидов приходят, клиент получает все 25, баланс падает до 0. Никаких внутридневных стопов.
|
||||
|
||||
**Понедельник 18:00.** Cron snova:
|
||||
- `evaluate(client)` → `passes=false` (0₽ ≠ 25 лидов).
|
||||
- `frozen_by_balance_at = now()`.
|
||||
- `balance_freeze_log.insert(event='frozen', triggered_by='cutoff_18msk')`.
|
||||
- `BalanceFrozenMail` отправляется.
|
||||
|
||||
**Понедельник 18:05.** `SyncSupplierProjectsJob` — этот клиент исключён, формула пересчитывается для остальных в группе на источнике.
|
||||
|
||||
**Понедельник 19:00.** Клиент видит письмо, пополняет на 1000₽ через безнал PDF → 1-2 дня ждёт зачисления через сверку. Или через СБП/карту (после Б-1) — мгновенно.
|
||||
|
||||
**Вторник 14:00.** Безнал подтверждён админом (или авто) → баланс 1000₽. `confirmPayment` зовёт `evaluate` → `passes=true`. `frozen_by_balance_at = NULL`. `BalanceUnfrozenMail`. В личном кабинете баннер исчезает.
|
||||
|
||||
**Вторник 18:00.** Cron snova — клиент в заказе на среду. Со среды получает лиды.
|
||||
|
||||
### §6.2 Преfflight — активная нехватка при создании проекта
|
||||
|
||||
**Клиент с балансом 1000₽ = 30 лидов.** Имеет 3 проекта по 10 = 30 лимит. Всё впритык.
|
||||
|
||||
**Клиент создаёт 4-й проект с лимитом 20.** Бэк (`ProjectController::store`) делает превью-преfflight:
|
||||
|
||||
- `Σ daily_limit (после сохранения) = 50` лидов
|
||||
- `capacity = BalanceToLeadsConverter::convert(1000₽, delivered, tiers)['leads'] = 30` лидов
|
||||
- `30 < 50` → недостаток 20 лидов
|
||||
|
||||
Возвращает HTTP 409 с body:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "balance_insufficient",
|
||||
"current_balance_rub": "1000.00",
|
||||
"current_capacity_leads": 30,
|
||||
"would_be_required_leads": 50,
|
||||
"deficit_leads": 20
|
||||
}
|
||||
```
|
||||
|
||||
Фронт показывает диалог `ProjectLimitOverloadDialog.vue`:
|
||||
|
||||
```
|
||||
Этот лимит превышает баланс.
|
||||
У тебя 1000₽ = 30 лидов по текущему тарифу.
|
||||
После сохранения нужно 50 лидов.
|
||||
Не хватает: 20 лидов.
|
||||
|
||||
Чтобы он начал работать, нужно одно из:
|
||||
• Пополнить счёт (примерно 700₽ покроют 20 лидов недостачи)
|
||||
• Поставить лимит этого проекта 0
|
||||
• Уменьшить лимиты других проектов
|
||||
|
||||
[Сохранить и приостановить этот] [Поставить лимит 0] [Отмена]
|
||||
```
|
||||
|
||||
«Примерно 700₽» — оценка через обратное преобразование: добавляем баланс шагами по 100₽ и смотрим, при какой сумме `capacity` вырастет до 50 (фронт делает это локально, не дёргая бэк).
|
||||
|
||||
- Если «Сохранить и приостановить» → POST `/api/projects` с флагом `force_save_blocked=true`. Создаётся проект с `preflight_blocked_at = now()`. Остальные 3 проекта работают.
|
||||
- Если «Поставить лимит 0» → POST `/api/projects` с `daily_limit = 0`. Создаётся, не идёт в заказ.
|
||||
- Если «Отмена» → форма закрывается, ничего не сохраняется.
|
||||
|
||||
### §6.3 VTB — Безнал end-to-end (после Б-1)
|
||||
|
||||
1. Клиент → Биллинг → «Пополнить 100000₽» → выбор «Безнал».
|
||||
2. Backend создаёт `topup_sessions(status='pending', provider_ref='SCH-2026-15-001')`.
|
||||
3. Скачивает PDF-счёт с реквизитами Лидерры (ИНН, КПП, р/с) и своими реквизитами.
|
||||
4. Идёт в свой банк-клиент, оплачивает.
|
||||
5. Деньги в течение нескольких часов приходят на р/с Лидерры в VTB.
|
||||
6. `VtbStatementSyncJob` за 15 минут после прихода находит транзакцию с «SCH-2026-15-001» в назначении, ставит `status='matched'`.
|
||||
7. Часовой `NotifyPendingConfirmationsJob` шлёт админу email «есть 1 непровер. платёж».
|
||||
8. Админ в админке → Биллинг → Ожидающие платежи → видит запись → «Подтвердить» → 100000₽ кредитуется, `BalanceTransaction` пишется.
|
||||
9. Если клиент был в `frozen_by_balance_at` — `evaluate` пересчитывает → разморозка → `BalanceUnfrozenMail`.
|
||||
|
||||
В dev-режиме шаг 6 заменяется на `billing:vtb-statement-simulate 15001 --amount=100000` (manual).
|
||||
|
||||
---
|
||||
|
||||
## §7. Известные открытые вопросы
|
||||
|
||||
1. **Учёт перехода ступени за день.** `BalanceToLeadsConverter::convert` уже учитывает «текущую ступень и переход на следующие при росте объёма». Граничный случай «дневной заказ переваливает за порог ступени, после чего цена меняется» автоматически обрабатывается — `convert` итерирует по ступеням и считает разные цены для разных кусков. **Не нужна дополнительная логика.**
|
||||
2. **Что если клиент имеет несколько менеджеров с email?** Письмо `BalanceFrozenMail` — на главный email тенанта (`tenant.email`) или на всех users этого тенанта? **Решение по умолчанию:** на главный email тенанта (как сейчас в `ZeroBalancePausedMail`).
|
||||
3. **Формат номера счёта** `SCH-YYYY-{tenant_id}-{seq}` — гарантирована уникальность через `(tenant_id, seq)` сериал. Если переходим на ОФД-Атол, может потребоваться другой формат. **Решение:** сейчас наш формат, при подключении ОФД — модернизируем (отдельная задача).
|
||||
4. **Локализация PDF-счёта** — пока только русский. После Б-1 при необходимости — английский / казахский (если расширим географию).
|
||||
5. **Reconcilation между `topup_sessions` и `balance_transactions`** — есть ли инвариант «каждая completed-сессия = одна balance_transaction»? Да, должен быть — добавить foreign key + unique constraint.
|
||||
|
||||
---
|
||||
|
||||
## §8. Future enhancements (Спек D и далее)
|
||||
|
||||
1. **«Отдать разморозившемуся клиенту лиды, уже купленные сегодня, через шеринг»** (см. §3.10) — бизнес: разморозившийся в 15:00 клиент мог бы получить лиды, поступающие после 15:00 по его источнику, если есть свободные слоты шеринга. Технически — модификация `RouteSupplierLeadJob` + расширение eligible-кандидатов после mid-day events. Реальная частота сценария = низкая; делать после месяца сбора статистики.
|
||||
2. **«Приоритет шеринга 4+ клиентов — те, у кого хватало баланса на момент cut-off»** — снимок «утренней платёжеспособности» сохраняется, при шеринге сверяется. Полезно при переполненном шеринге (>3 покупателей конкурируют за лид).
|
||||
3. **Авто-сверка с VTB Бизнес API** — реальная интеграция (не заглушка), боевой `VtbStatementSyncJob` с retry и подписью.
|
||||
4. **Recurring-платежи** — автосписание с карты раз в месяц (отдельная фича, требует UX-проработки).
|
||||
5. **Возвраты** (refund) — если бизнес-нужда подтвердится. Сейчас Спек A явно говорит «не делаем».
|
||||
6. **Мульти-валютность** — если выходим на казахский / белорусский рынок (KZT/BYN).
|
||||
|
||||
---
|
||||
|
||||
## §9. Связи
|
||||
|
||||
- [Спек A](2026-05-23-billing-v2-spec-a-balance-rub-design.md) — единый ₽-баланс, `BalanceToLeadsConverter` (используется в этом спеке).
|
||||
- [Спек B](2026-05-23-billing-v2-spec-b-duplicates-design.md) — `supplier_lead_deliveries` lock-таблица, шеринг до 3 клиентов на лид (контекст для §3.10).
|
||||
- [App allocator](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php) — формула заказа, остаётся без изменений.
|
||||
- [App ledger](../../../app/app/Services/Billing/LedgerService.php) — списания с баланса, остаётся без изменений.
|
||||
- [App topup](../../../app/app/Services/Billing/BillingTopupService.php) — рефакторится в этом спеке.
|
||||
- [App webhook routing](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php) — добавляется фильтр frozen-проектов.
|
||||
- [App project controller](../../../app/app/Http/Controllers/Api/ProjectController.php) — добавляется preflight check.
|
||||
- [Pravila §13.2](../../../docs/Pravila_raboty_Claude_v1_1.md) — финансовая нормативка.
|
||||
- [CLAUDE.md §6](../../../CLAUDE.md) — текущая фаза.
|
||||
- [project_billing_v2 memory](../../../../C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/project_billing_v2.md) — серия из 3 спеков.
|
||||
- [SEC-3, SEC-5 (memory)](../../../../C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/project_server_hardening.md) — блокеры YC Lockbox для секретов VTB.
|
||||
|
||||
---
|
||||
|
||||
## §10. План реализации (overview)
|
||||
|
||||
Детальный план — отдельным документом через `superpowers:writing-plans` после утверждения этого спека.
|
||||
|
||||
Предварительная разбивка на фазы (для оценки масштаба):
|
||||
|
||||
**Phase 1 — Преfflight баланса** (~5-7 задач):
|
||||
|
||||
- Миграция БД (`frozen_by_balance_at`, `preflight_blocked_at`, `balance_freeze_log`).
|
||||
- `BalancePreflightService` (pure) + тесты.
|
||||
- `BalancePreflightSweepJob` + cron.
|
||||
- 4 Mailable + throttle.
|
||||
- `BalanceFrozenBanner.vue` + `BalanceCapacityIndicator.vue`.
|
||||
- `ProjectController` preflight check + 409 response.
|
||||
- `ProjectLimitOverloadDialog.vue`.
|
||||
- Фильтр в `SyncSupplierProjectsJob`.
|
||||
- One-time `billing:preflight-initial-sweep`.
|
||||
|
||||
**Phase 2 — Безнал PDF + админка** (~6-8 задач):
|
||||
|
||||
- Миграция БД (`legal_entity_*` в `tenants`, `topup_sessions`).
|
||||
- `TopupGatewayInterface` + `BankTransferGateway`.
|
||||
- PDF-генерация (`resources/views/pdf/invoice.blade.php`).
|
||||
- `LegalEntityForm.vue` + валидация.
|
||||
- `TopupView.vue` + `TopupMethodPicker.vue` + `BankTransferInvoiceView.vue`.
|
||||
- `Admin/PendingTopupsController` + `PendingPaymentsAdminView.vue`.
|
||||
- `Admin/BillingSettingsController` (auto/manual переключатель).
|
||||
- `VtbStatementSyncJob` (с dev-симулятором).
|
||||
- `NotifyPendingConfirmationsJob`.
|
||||
|
||||
**Phase 3 — СБП и Карты dev-заглушки** (~4-5 задач):
|
||||
|
||||
- `SbpGateway` (dev режим) + `SbpQrCodeView.vue`.
|
||||
- `CardGateway` (dev режим) + `/dev-mock-vtb-acquiring/...` страница.
|
||||
- `FiscalReceiptProvider` interface + `NoOpFiscalProvider` + `DevMockFiscalProvider`.
|
||||
|
||||
**Phase 4 — Тесты + smoke** (~3-4 задач):
|
||||
|
||||
- Pest end-to-end сценарии преfflight (frozen/unfrozen flow).
|
||||
- Pest end-to-end сценарии безнала (создание счёта → симуляция выписки → подтверждение).
|
||||
- Vitest на новые Vue-компоненты.
|
||||
- Регрессия (Pest --parallel + Vitest + lychee + gitleaks).
|
||||
|
||||
**Phase 5 — Документация + memory + ПИЛОТ.md** (~2 задач):
|
||||
|
||||
- Обновление `project_billing_v2.md` (Спек C статус, известные хвосты).
|
||||
- ADR (если требуется новый — `docs/adr/016-preflight-vtb-architecture.md`).
|
||||
- Обновление `ПИЛОТ.md` после выкатки на прод.
|
||||
|
||||
**Out of scope этого плана (post-Б-1, отдельные планы):**
|
||||
|
||||
- Боевая интеграция VTB Acquiring (карты).
|
||||
- Боевая интеграция VTB SBP API.
|
||||
- Боевая интеграция VTB Бизнес API (авто-сверка).
|
||||
- Боевая интеграция ОФД-Атол (фискализация).
|
||||
- Боевые секреты в YC Lockbox.
|
||||
|
||||
---
|
||||
|
||||
**Конец Спека C.**
|
||||
@@ -1,204 +0,0 @@
|
||||
# Удаление legacy прямого webhook-канала (`ProcessWebhookJob`)
|
||||
|
||||
**Дата:** 2026-05-24
|
||||
**Статус:** Design (awaiting user review)
|
||||
**Автор:** Claude Opus 4.7 (под руководством заказчика)
|
||||
**Брейнсторм:** сессия 2026-05-24
|
||||
**Триггер:** в коде осталось legacy-расхождение от старой prepaid-схемы. Изначально планировалась унификация под always-rub; в ходе брейнсторма выяснилось, что код — рудимент, не часть актуальной архитектуры каналов.
|
||||
|
||||
---
|
||||
|
||||
## §1. Контекст и проблема
|
||||
|
||||
### §1.1 Архитектура каналов приёма лидов
|
||||
|
||||
Лидерра принимает лиды через **два канала**:
|
||||
|
||||
| Канал | Назначение | Реализация | Биллинг |
|
||||
|---|---|---|---|
|
||||
| Основной | Real-time приём от `crm.bp-gr.ru` | `SupplierWebhookController::receive` → `INSERT supplier_leads` → `RouteSupplierLeadJob::dispatch` | `LedgerService` (always-rub) |
|
||||
| Резервный | Часовая доливка пропусков через CSV-отчёт `crm.bp-gr.ru` | `CsvReconcileJob` → доливает `supplier_leads` → `RouteSupplierLeadJob` | `LedgerService` (always-rub, косвенно через основной) |
|
||||
|
||||
Оба канала **уже** на единой биллинг-логике — рубли + 7-ступенчатая тарифная шкала через `LedgerService::chargeForDelivery`.
|
||||
|
||||
### §1.2 Что осталось от старой архитектуры
|
||||
|
||||
До эпика «шеринг» (Plan 2/5) в коде была другая модель: у каждого тенанта был свой `webhook_token`, поставщик стучался напрямую `POST /api/webhook/{token}` → `WebhookReceiveController` → `ProcessWebhookJob` → списание из `tenants.balance_leads` (штучный prepaid-баланс).
|
||||
|
||||
После шеринг-эпика этот путь **перестал использоваться**, но из кода не убран:
|
||||
|
||||
- `ProcessWebhookJob` ([app/app/Jobs/ProcessWebhookJob.php](../../../app/app/Jobs/ProcessWebhookJob.php), 342 строки) — на старой prepaid-модели (`tenant->decrement('balance_leads')`, `BalanceTransaction(amount_leads=-1)`).
|
||||
- `WebhookReceiveController` + публично открытый роут `POST /api/webhook/{token}` ([app/routes/web.php:276](../../../app/routes/web.php#L276)).
|
||||
- Таблицы `webhook_log` (партиционированная, 13 партиций) и `webhook_dedup_keys` — источник записей только этот job.
|
||||
- Колонки `tenants.webhook_token`, `tenants.webhook_token_rotated_at`.
|
||||
- Тесты `ProcessWebhookJobTest`, `WebhookReceiveTest` (плюс упоминания в 4 других файлах-тестах).
|
||||
|
||||
**Подтверждение «не используется на проде»:**
|
||||
|
||||
- `SELECT COUNT(*) FROM webhook_log` = 0 (за всю историю боевого сервера).
|
||||
- `last_webhook_at` у всех 5 тенантов = NULL.
|
||||
- Боевые лиды (`deals.tenant_id=2`, 412 шт.) пришли через основной канал — `source_crm_id` имеет формат `vid` от `crm.bp-gr.ru`.
|
||||
|
||||
### §1.3 Проблемы рудимента
|
||||
|
||||
1. **Открытый публичный эндпоинт.** `POST /api/webhook/{token}` доступен из интернета без middleware-проверки, аутентификация по знанию токена в URL. Лишний attack surface для DAST/нагрузочных атак.
|
||||
2. **Блокирует Phase B Спека A.** Phase B = `ALTER TABLE tenants DROP COLUMN balance_leads`. После него `ProcessWebhookJob` сломается на первом же вызове (или на запуске тестов).
|
||||
3. **Расхождение биллинг-моделей в коде.** Два разных списания на одну и ту же сущность (`Deal`) — путаница для будущих изменений.
|
||||
4. **Test-debt.** Тесты на старую prepaid-модель продолжают занимать набор тестов и время CI.
|
||||
|
||||
### §1.4 Решение
|
||||
|
||||
Удалить рудимент целиком (код + контроллер + роут + модель `WebhookDedupKey` + связанные тесты + таблицы БД + колонки `tenants`). Одним PR, выкатка одним релизом.
|
||||
|
||||
---
|
||||
|
||||
## §2. Scope
|
||||
|
||||
### §2.1 Что удаляем
|
||||
|
||||
**PHP-код:**
|
||||
|
||||
- `app/app/Jobs/ProcessWebhookJob.php` (целиком)
|
||||
- `app/app/Http/Controllers/Api/WebhookReceiveController.php` (целиком, если используется ТОЛЬКО для legacy-роута)
|
||||
- роут `Route::post('/api/webhook/{token}', ...)` в [app/routes/web.php:276](../../../app/routes/web.php#L276)
|
||||
- `app/app/Models/WebhookDedupKey.php` (целиком — используется только `ProcessWebhookJob`)
|
||||
- `app/app/Mail/LowBalanceNotification.php`, `app/app/Mail/ZeroBalanceNotification.php` — **только если impact-check (§3) подтвердит, что нет других caller'ов**
|
||||
|
||||
**Методы в `NotificationService`** (только если impact-check подтвердит, что нет других caller'ов):
|
||||
|
||||
- `notifyLowBalance`
|
||||
- `notifyZeroBalance` (NB: не путать с `notifyZeroBalancePaused` — это разный метод шеринг-канала, оставляем)
|
||||
- `notifyNewLead` — **оставляем** (использует и шеринг через `RouteSupplierLeadJob`)
|
||||
|
||||
**Тесты (целиком):**
|
||||
|
||||
- `app/tests/Feature/ProcessWebhookJobTest.php`
|
||||
- `app/tests/Feature/WebhookReceiveTest.php`
|
||||
|
||||
**Тесты (частично — удалить только релевантные кейсы, проверить не опустели ли файлы):**
|
||||
|
||||
- `app/tests/Feature/Pd/DealCreatePdLogTest.php`
|
||||
- `app/tests/Feature/Notifications/BalanceNotificationsTest.php`
|
||||
- `app/tests/Feature/Notifications/NewLeadNotificationTest.php`
|
||||
- `app/tests/Feature/Notifications/InAppNotificationTest.php`
|
||||
|
||||
**Миграция БД (одной миграцией, идемпотентной):**
|
||||
|
||||
- `DROP TABLE webhook_log` (партиционированная) + все 13 партиций
|
||||
- `DROP TABLE webhook_dedup_keys`
|
||||
- `DROP TABLE rejected_deals_log` — **только если impact-check подтвердит, что писалось только `ProcessWebhookJob`**
|
||||
- `ALTER TABLE tenants DROP COLUMN webhook_token, DROP COLUMN webhook_token_rotated_at` — **только если impact-check подтвердит, что не используется в UI/API**
|
||||
- удаление сидов / system_settings ключей, относящихся только к legacy: `low_balance_threshold_leads` (если не унифицируется в рамках другого спека)
|
||||
|
||||
### §2.2 Что НЕ трогаем (явно out of scope)
|
||||
|
||||
- `failed_webhook_jobs` — используется `RouteSupplierLeadJob::failed()` (шеринг-канал).
|
||||
- `SupplierLeadCost` — пишется и шеринг-каналом (через `LedgerService::chargeForDelivery`).
|
||||
- `MonthlyPartitionManager` — управляет партициями нескольких таблиц, не только `webhook_log`.
|
||||
- `SupplierResolver` — используется в админке (`AdminSupplierIntegrationController`).
|
||||
- Phase B Спека A (`DROP COLUMN balance_leads`) — отдельная задача после ≥72ч наблюдения Phase A.
|
||||
- Унификация `notifyLowBalance` под рубли — отдельная задача, если решим возрождать low-balance уведомления.
|
||||
|
||||
---
|
||||
|
||||
## §3. Impact-checks (обязательны перед удалением)
|
||||
|
||||
Каждая удаляемая сущность пройдёт автоматическую проверку «не использует ли её живой код». Список проверок — задачи в плане:
|
||||
|
||||
| Сущность | Проверка | Решение если есть use |
|
||||
|---|---|---|
|
||||
| `WebhookReceiveController` | `grep -r "WebhookReceiveController" app/` | Если есть use вне роута — удалить только метод `receive`, контроллер оставить |
|
||||
| `NotificationService::notifyLowBalance` | `grep -r "notifyLowBalance" app/` | Если есть caller вне `ProcessWebhookJob` — оставить метод |
|
||||
| `NotificationService::notifyZeroBalance` | `grep -r "notifyZeroBalance\b" app/` (с word boundary, чтобы не зацепить `notifyZeroBalancePaused`) | Если есть caller — оставить метод |
|
||||
| `LowBalanceNotification`, `ZeroBalanceNotification` (Mailable) | `grep -r "LowBalanceNotification\|ZeroBalanceNotification" app/` | Если есть use — оставить класс |
|
||||
| `tenants.webhook_token`, `webhook_token_rotated_at` | grep по `app/` и `app/resources/js/` — поиск в UI, API-resource, ресурс-сериализаторах, фабриках, сидах | Если есть UI/API consumer — отдельная задача на удаление UI |
|
||||
| `rejected_deals_log` | `grep -r "RejectedDealsLog\|rejected_deals_log" app/` | Если есть use вне `ProcessWebhookJob` — таблицу не дропать |
|
||||
| `webhook_dedup_keys` | `grep -r "webhook_dedup_keys\|WebhookDedupKey" app/` | Должен быть пустым после удаления `ProcessWebhookJob` |
|
||||
| `low_balance_threshold_leads` (system_settings) | `grep -r "low_balance_threshold_leads" app/` | Если есть caller — мигрировать или удалить настройку |
|
||||
|
||||
Все проверки делаются на текущем коде (после mental-удаления `ProcessWebhookJob` и тестов).
|
||||
|
||||
---
|
||||
|
||||
## §4. Решение по архитектуре
|
||||
|
||||
### §4.1 Главный инвариант
|
||||
|
||||
После выпиливания **остаётся ровно одна труба биллинга**: `RouteSupplierLeadJob::createDealCopyForProject` → `LedgerService::chargeForDelivery`. Все списания всех каналов идут через неё.
|
||||
|
||||
### §4.2 Что меняется в публичном API
|
||||
|
||||
- `POST /api/webhook/{token}` — **404**. Старые токены тенантов перестают принимать вход (попадание в логи nginx как 404 — это нормально, на проде вызовов 0).
|
||||
- `POST /api/webhook/supplier/{secret}` — **без изменений** (это шеринг-канал от `crm.bp-gr.ru`).
|
||||
|
||||
### §4.3 Откатываемость
|
||||
|
||||
Миграция БД **необратимая** (DROP TABLE / DROP COLUMN). Бэкап `pg_dump` снимается перед выкаткой по runbook `docs/deploy/test-server-runbook.md`. В случае critical-инцидента — restore из бэкапа + откат git revert.
|
||||
|
||||
Риск отката оценивается как **нулевой** — на проде webhook_log = 0, рудимент никем не используется.
|
||||
|
||||
### §4.4 Совместимость с другими спеками
|
||||
|
||||
- **Спек A (₽-баланс, Phase A на проде)** — этот спек снимает блокер для Phase B.
|
||||
- **Спек B (дубли, на проде)** — не пересекается (Спек B про шеринг-канал; legacy webhook имеет собственный `webhook_dedup_keys`, который тоже удаляется).
|
||||
- **Спек C (preflight + VTB)** — не пересекается (preflight работает на уровне `SupplierQuotaAllocator`; VTB — на пополнении баланса, не на списании).
|
||||
|
||||
---
|
||||
|
||||
## §5. Тестирование
|
||||
|
||||
### §5.1 Регрессионная проверка
|
||||
|
||||
- `composer test` — Pest --parallel, должно пройти на dev (после удаления тестов количество suite уменьшится).
|
||||
- `npm run test:vue` — Vitest, должен остаться зелёным (UI не трогаем кроме возможного раздела webhook-token, если impact-check найдёт).
|
||||
- Lefthook pre-commit — все джобы зелёные.
|
||||
- Larastan — без новых ошибок (baseline регенерация только если потребуется).
|
||||
|
||||
### §5.2 Smoke-проверка на проде после деплоя
|
||||
|
||||
- `curl -X POST https://liderra.ru/api/webhook/test-token -d '{}'` → ожидается **404** (роут больше не существует).
|
||||
- `curl -X POST https://liderra.ru/api/webhook/supplier/$SECRET -d '{...}'` → ожидается **200/202** (шеринг-канал работает как раньше).
|
||||
- `SELECT * FROM information_schema.tables WHERE table_name IN ('webhook_log', 'webhook_dedup_keys')` → 0 строк (миграция применилась).
|
||||
- `SELECT column_name FROM information_schema.columns WHERE table_name='tenants' AND column_name LIKE 'webhook_%'` → 0 строк (если impact-check подтвердил удаление колонок).
|
||||
|
||||
### §5.3 7-дневное наблюдение
|
||||
|
||||
После деплоя — наблюдать:
|
||||
|
||||
- `failed_jobs` (новые fail'ы только от шеринг-канала).
|
||||
- nginx access log на `/api/webhook/{token}` 404 — если **кто-то** реально начнёт долбиться (что маловероятно: 0 вызовов за всю историю), но если начнёт — это сигнал к ретроспективе.
|
||||
- Sentry-алерты — без новых регрессий (Sentry pending Б-1).
|
||||
|
||||
---
|
||||
|
||||
## §6. Выкатка
|
||||
|
||||
**Один PR, один релиз** (заказчик подтвердил 2026-05-24).
|
||||
|
||||
Шаги (детали — в плане реализации):
|
||||
|
||||
1. Изолированный worktree.
|
||||
2. Impact-checks (§3) — финальный список «что удаляем точно, что оставляем».
|
||||
3. Code-удаление + удаление/чистка тестов.
|
||||
4. Миграция БД (одна идемпотентная миграция с DROP).
|
||||
5. Полная регрессия (`composer test` + `npm run test:vue` + lefthook).
|
||||
6. Subagent code-review.
|
||||
7. Push в main, FF merge.
|
||||
8. Деплой на боевой:
|
||||
- Бэкап `pg_dump` перед миграцией (runbook).
|
||||
- `git archive | scp | tar -xf` (10-15 файлов).
|
||||
- `redeploy.sh` (composer + migrate + cache + reload php-fpm).
|
||||
- Smoke-проверка (§5.2).
|
||||
9. 7-дневное наблюдение (§5.3).
|
||||
|
||||
---
|
||||
|
||||
## §7. Связано
|
||||
|
||||
- [Спек A Биллинг v2 — единый ₽-баланс](2026-05-23-billing-v2-spec-a-balance-rub-design.md) (Phase B = блокирован этим документом до выпиливания).
|
||||
- [Спек B Биллинг v2 — политика дублей](2026-05-23-billing-v2-spec-b-duplicates-design.md) (не пересекается, но даёт контекст шеринг-канала).
|
||||
- [Спек C Биллинг v2 — preflight + VTB](2026-05-24-billing-v2-spec-c-preflight-vtb-design.md) (не пересекается).
|
||||
- [Supplier integration spec](2026-05-10-supplier-integration-design.md) §5–§6 (определение шеринг-канала, который остаётся единственным боевым).
|
||||
- [CSV reconcile channel spec](2026-05-18-supplier-csv-reconcile-channel-design.md) (резервный канал, не трогается).
|
||||
- `docs/deploy/test-server-runbook.md` (бэкап перед миграцией).
|
||||
- Памятки в коде комментариев: [routes/web.php:282](../../../app/routes/web.php#L282) уже маркирует роут как «legacy».
|
||||
@@ -1,291 +0,0 @@
|
||||
# Supplier webhook reliability — design spec
|
||||
|
||||
**Дата:** 2026-05-25
|
||||
**Статус:** draft → готов к плану
|
||||
**Ветка:** `feat/supplier-webhook-fixes`
|
||||
**Связано:** Спек B Phase 1 (`docs/superpowers/specs/2026-05-23-billing-v2-spec-b-duplicates-design.md`) — снят DuplicateDetector; данная спека закрывает race condition, оставшийся после Спека B.
|
||||
|
||||
---
|
||||
|
||||
## 1. Проблема
|
||||
|
||||
На боевом liderra.ru за сутки 25.05.2026 для тенанта `client1` (tenant_id=2):
|
||||
|
||||
- Поставщик crm.bp-gr.ru отдал **205 уникальных лидов** (учётка `info@lkomega.ru`, страница `/admin/visit/index-visit?visit=rt`)
|
||||
- На портале — **160 сделок**, из них **123 уникальных телефона** (37 — дубликаты `phone+project`)
|
||||
- **Расхождения:** 82 лида у поставщика не дошли до портала; 37 deals в портале дублированы
|
||||
|
||||
### 1.1. Корневая причина потерь (76 из 82)
|
||||
|
||||
Из 234 POST-запросов поставщика на `/api/webhook/supplier/<secret>` сегодня:
|
||||
- **132** → 202 Accepted (приняты)
|
||||
- **76** → 302 Found (Location: `https://liderra.ru`)
|
||||
- 29 → 301 (http→https на `/`)
|
||||
|
||||
Воспроизведено вручную: `curl -X POST` с пустым `{}` → 302 + Set-Cookie. Это **дефолтный Laravel behavior**: для запросов, где `Accept` НЕ содержит `application/json`, `ValidationException` рендерится через `redirect()->back()->withErrors()` — 302 на referer (которого нет у webhook-вызывающего) → fallback на `/`.
|
||||
|
||||
Запросы 302 — это webhook-и где `project` НЕ матчится regex `'project' => regex:/^B[123]_.+$/'` ([app/app/Http/Controllers/Api/SupplierWebhookController.php:86](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L86)).
|
||||
|
||||
Конкретные «непринимаемые» проекты (видны в supplier rt-list):
|
||||
- `client.carmoney.ru` — 55 лидов
|
||||
- `B2_Caranga` — 7
|
||||
- `cabinet.caranga.ru` — 3
|
||||
- `cashmotor.ru` — 2
|
||||
- остальные единичные: `73912346386`, `79135191264`, `78006009393`, `78007006600`, `79029248888`, `B2_drivezaim`, `B3_+7 (495) 023-66-52` и т.п.
|
||||
|
||||
### 1.2. Корневая причина дублей (37)
|
||||
|
||||
[app/app/Jobs/Supplier/CsvReconcileJob.php:146-155](../../../app/app/Jobs/Supplier/CsvReconcileJob.php#L146-L155) каждые 30 мин создаёт «recovered» `SupplierLead` с **`vid: null`**, `source: csv_recovery` для лидов, найденных в CSV поставщика но отсутствующих в наших `supplier_leads` за окно.
|
||||
|
||||
Затем поставщик ретраит webhook с настоящим `vid` (численный) → создаётся **новый** `SupplierLead` (UNIQUE по `vid`, NULL ≠ NULL → не считается дублем) → `RouteSupplierLeadJob` создаёт **второй Deal**.
|
||||
|
||||
`supplier_lead_deliveries` уник-индекс на `(supplier_lead_id, tenant_id)` ([app/app/Jobs/RouteSupplierLeadJob.php:249-262](../../../app/app/Jobs/RouteSupplierLeadJob.php#L249-L262)) **не блокирует**, потому что у CSV-recovered и webhook разные `supplier_lead.id`.
|
||||
|
||||
Раньше эту race-condition закрывал `DuplicateDetector` (24h-фильтр по `phone+project`), который был снят в Спеке B Phase 1 (commit `ccfecd5e`, 24.05) с обоснованием «за повторы поставщика берём».
|
||||
|
||||
### 1.3. Цепочка B-префикса (5 точек)
|
||||
|
||||
Regex `B[123]_` встречается в коде в **5 точках**, и все обязательны для текущего flow:
|
||||
|
||||
| # | Место | file:line | Поведение без B-префикса |
|
||||
|---|---|---|---|
|
||||
| 1 | Webhook validation | [SupplierWebhookController.php:86](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L86) | ValidationException → 302 (см. 1.1) |
|
||||
| 2 | parsePlatform fallback | [SupplierWebhookController.php:183-188](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L183-L188) | silent fallback 'B1' |
|
||||
| 3 | parseProjectField | [RouteSupplierLeadJob.php:172-200](../../../app/app/Jobs/RouteSupplierLeadJob.php#L172-L200) | **RuntimeException** → retry 3x → failed_webhook_jobs |
|
||||
| 4 | extractPlatform | [CsvReconcileJob.php:237-244](../../../app/app/Jobs/Supplier/CsvReconcileJob.php#L237-L244) | возвращает `null` → строка в `unparseable_count` (56 сегодня) |
|
||||
| 5 | БД constraint | `supplier_projects.platform CHECK IN (B1,B2,B3)` | нельзя сохранить platform=`DIRECT` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Цели и не-цели
|
||||
|
||||
### Цели
|
||||
|
||||
- **C1.** Webhook на `/api/webhook/supplier/*` ВСЕГДА отвечает JSON (202/200/422/429/404), никогда не редиректит. Любая `ValidationException` для этого URL — JSON 422 с полем `errors`.
|
||||
- **C2.** Webhook, поступивший после CSV-recovered deal по тому же `(tenant_id, phone, project_id)` в окне 24h, **обновляет** существующий deal (`source_crm_id`, `received_at` если новее, `phones`), а не создаёт второй. Биллинг не списывает второй раз.
|
||||
- **C3.** Webhook на проекты без префикса `B[123]_` (`client.carmoney.ru`, `cashmotor.ru`, числовые) принимается, проходит routing, создаёт Deal под новой платформой `DIRECT`.
|
||||
|
||||
### Не-цели
|
||||
|
||||
- **NG1.** Восстановление 82 потерянных лидов 25.05 — оффлайн-операция после деплоя, через `php artisan supplier:reconcile-force` или ручное добавление по списку (вне scope этой спеки).
|
||||
- **NG2.** Очистка 37 текущих дублей в проде — отдельная миграция данных или ручной SQL (вне scope).
|
||||
- **NG3.** Изменение бизнес-правил биллинга для DIRECT-платформы. Берётся та же тарификация, что для B1/B2/B3 (по умолчанию tier по `signal_type`). Альтернативная цена для DIRECT — отдельный спек если потребуется.
|
||||
- **NG4.** Отказ от CSV reconcile job — он остаётся как safety net, но теперь дедупликация не приводит к дублям.
|
||||
|
||||
---
|
||||
|
||||
## 3. Решение
|
||||
|
||||
Три независимые фазы. Каждая фаза — отдельный PR, отдельный план, отдельный выкат на боевой. Между фазами — observation period (1-2 часа на проде, потом следующая фаза).
|
||||
|
||||
### Phase 1 (низкий риск) — Always JSON 422 для webhook validation errors
|
||||
|
||||
**Изменения:**
|
||||
|
||||
- В [app/bootstrap/app.php:35](../../../app/bootstrap/app.php#L35) `withExceptions()` добавить render:
|
||||
```php
|
||||
$exceptions->render(function (\Illuminate\Validation\ValidationException $e, Request $request) {
|
||||
if ($request->is('api/webhook/supplier/*')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $e->errors(),
|
||||
], 422);
|
||||
}
|
||||
return null; // дефолтный рендер для остальных
|
||||
});
|
||||
```
|
||||
- Тест: POST с `Accept: text/html` (имитация поставщика без JSON-Accept) на webhook с невалидным payload → assert 422 + JSON Content-Type + ошибка в `errors`.
|
||||
- Существующие тесты `SupplierWebhookTest.php` — все `postJson(...)` → 422 уже работают. Добавляется один новый тест с обычным `post()`.
|
||||
|
||||
**Risk:** низкий. Изменение не трогает control flow webhook'а, только формат ответа на ошибку.
|
||||
|
||||
**Откатываемость:** одной строчкой revert.
|
||||
|
||||
### Phase 2 (средний риск) — Идемпотентность webhook ↔ CSV-recovered
|
||||
|
||||
**Изменения:**
|
||||
|
||||
- В [app/app/Jobs/RouteSupplierLeadJob.php:207](../../../app/app/Jobs/RouteSupplierLeadJob.php#L207) `createDealCopyForProject()` ДО создания Deal — поиск:
|
||||
```php
|
||||
$existingDeal = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('phone', (string) $lead->phone)
|
||||
->where('project_id', $project->id)
|
||||
->where('received_at', '>=', now()->subDay())
|
||||
->whereNull('source_crm_id') // только CSV-recovered ждут vid
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
```
|
||||
- Если найден → `UPDATE deals SET source_crm_id = vid, received_at = MAX(...)` + `supplier_lead_deliveries` запись + **НЕ списываем баланс повторно** (Ledger.alreadyChargedForDeal или просто отсутствие второго `chargeForDelivery`) → возврат `false`/`'merged'`.
|
||||
- Если не найден → текущий путь создания нового Deal без изменений.
|
||||
- `supplier_lead_deliveries.deal_id` обновляется на найденный deal.id.
|
||||
|
||||
**Биллинг safety:**
|
||||
- `LedgerService::chargeForDelivery` уже идемпотентен по `supplier_lead_id` (PK lead_charges) — проверить.
|
||||
- Если не идемпотентен — добавить guard: SELECT lead_charges WHERE deal_id=$existingDeal->id; если есть — skip charge.
|
||||
|
||||
**Тесты:**
|
||||
- TDD: CSV-recovered deal без vid → webhook на тот же phone+project → assert 1 deal (не 2), source_crm_id заполнен, lead_charges = 1 запись.
|
||||
- Regression: повтор поставщика по тому же vid (память Спека B — «за повторы берём») → assert 2 deals (если разные supplier_lead с разными vid).
|
||||
- Race: одновременный webhook и CSV-recovery → lockForUpdate гарантирует один deal.
|
||||
|
||||
**Risk:** средний — затрагивает биллинг. Нужно убедиться что `chargeForDelivery` не списывает второй раз.
|
||||
|
||||
### Phase 3 (высокий риск) — DIRECT platform для проектов без B-префикса
|
||||
|
||||
**Изменения:**
|
||||
|
||||
1. **Миграция БД** `database/migrations/2026_05_25_120000_add_direct_platform.php`:
|
||||
```sql
|
||||
ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform;
|
||||
ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform
|
||||
CHECK (platform IN ('B1','B2','B3','DIRECT'));
|
||||
ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform;
|
||||
ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform
|
||||
CHECK (platform IN ('B1','B2','B3','DIRECT'));
|
||||
```
|
||||
Также снять constraint `chk_supplier_projects_b1_not_for_sms` (он про B1+sms) если он мешает.
|
||||
|
||||
2. **Webhook regex** [SupplierWebhookController.php:86](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L86):
|
||||
```php
|
||||
'project' => ['required', 'string', 'max:255'], // снят regex
|
||||
```
|
||||
|
||||
3. **parsePlatform** [SupplierWebhookController.php:183-188](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L183-L188):
|
||||
```php
|
||||
private function parsePlatform(string $project): string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
return 'DIRECT';
|
||||
}
|
||||
```
|
||||
|
||||
4. **parseProjectField** [RouteSupplierLeadJob.php:172-200](../../../app/app/Jobs/RouteSupplierLeadJob.php#L172-L200) — добавить DIRECT branch:
|
||||
```php
|
||||
private function parseProjectField(string $project): array
|
||||
{
|
||||
if (preg_match('/^(B[123])_(.+)$/', $project, $m) === 1) {
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
} else {
|
||||
$platform = 'DIRECT';
|
||||
$rest = $project; // весь project считается identifier-частью
|
||||
}
|
||||
// далее существующая логика определения signal_type/identifier на $rest
|
||||
// (call / site / sms по тем же regex'ам)
|
||||
}
|
||||
```
|
||||
|
||||
5. **extractPlatform** [CsvReconcileJob.php:237-244](../../../app/app/Jobs/Supplier/CsvReconcileJob.php#L237-L244):
|
||||
```php
|
||||
private function extractPlatform(string $project): string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
return 'DIRECT';
|
||||
}
|
||||
```
|
||||
Логика `unparseable_count` снимается для DIRECT-кейса; остаётся только для **реального мусора** (телефоны/URL в поле project). Различение через дополнительный regex проверки `[a-z0-9]` в начале.
|
||||
|
||||
6. **SupplierProjectResolver** — резолв по `(platform=DIRECT, signal_type, identifier)` создаёт/находит `supplier_projects` row с platform=DIRECT.
|
||||
|
||||
7. **LeadRouter::matchEligibleProjects** — DIRECT-platform fetches по тем же signal_type/identifier-полям проекта; никаких B1/B2/B3 специальных условий.
|
||||
|
||||
**Тесты:**
|
||||
- Существующий тест `'rejects invalid project format with 422'` ([SupplierWebhookTest.php:95](../../../app/tests/Feature/Http/Webhook/SupplierWebhookTest.php#L95)) переписать: теперь invalid_format → 202 (принят), platform=DIRECT.
|
||||
- Новый тест: webhook с `project: "client.carmoney.ru"` → 202, supplier_lead.platform=DIRECT, RouteSupplierLeadJob создаёт SupplierProject под DIRECT, Deal создаётся.
|
||||
- Существующие тесты RouteSupplierLeadJobTest / CsvReconcileJobTest — добавить DIRECT-кейсы.
|
||||
- Регрессия: все B1/B2/B3 кейсы продолжают работать без изменений.
|
||||
|
||||
**Risk:** высокий — затрагивает миграцию БД, ⩾5 файлов кода, тесты, бизнес-семантику биллинга для DIRECT.
|
||||
|
||||
**Сложность:** одновременная правка должна быть атомарной — если деплоится миграция но не код, controller примет lid'ы которые job не сможет обработать. Один PR, один деплой, очередь queue:restart после.
|
||||
|
||||
---
|
||||
|
||||
## 4. Стратегия деплоя
|
||||
|
||||
Три отдельных деплоя на liderra.ru через `redeploy.sh` (per memory: «`sudo -u www-data php artisan optimize` в строке 9 скрипта»):
|
||||
|
||||
1. **Деплой 1 (Phase 1):** ~10 мин outage риск 0. Сразу после деплоя смотрим nginx logs — все POST → 422 или 202, нет 30x. Ждём 30 мин — drift_alert не должен подниматься.
|
||||
2. **Деплой 2 (Phase 2):** ~10 мин outage риск 0. Смотрим что новые deals не дублируются (`SELECT phone, project_id, COUNT(*) FROM deals WHERE created_at > NOW()-interval'2h' GROUP BY 1,2 HAVING COUNT(*)>1`). Ждём 1-2 часа.
|
||||
3. **Деплой 3 (Phase 3):** включает миграцию БД. Сначала миграция (idempotent CHECK extension), затем код. Smoke: POST `project: "client.carmoney.ru"` с правильным secret и IP → 202, supplier_lead создан, deal создан. Ждём 6 часов на наблюдение, после — закрытие задачи.
|
||||
|
||||
Перед каждым деплоем — обязательно агент `prod-deploy-validator` (per [Pravila §2.4](../../Pravila_raboty_Claude_v1_1.md)).
|
||||
|
||||
---
|
||||
|
||||
## 5. Тестирование
|
||||
|
||||
### Pest unit/feature
|
||||
|
||||
Все три фазы — TDD: тест → fail → имплементация → pass → commit. Запуск `composer test -- --filter='Supplier'` после каждой фазы.
|
||||
|
||||
Существующие тесты, которые гарантированно адаптируются:
|
||||
- `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` — line 95 «invalid_format → 422» переписывается на «invalid_format → 202 DIRECT» в Phase 3.
|
||||
- `app/tests/Feature/Supplier/CsvReconcileJobTest.php` — добавить кейс DIRECT в Phase 3.
|
||||
- `app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php` — добавить «webhook после CSV-recovered не списывает второй раз» в Phase 2.
|
||||
- `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` — добавить кейс «разные SupplierLead.id, тот же phone+project — не дубль» в Phase 2.
|
||||
|
||||
### Регрессия
|
||||
|
||||
`/regression full` ПОСЛЕ каждой фазы (Pest --parallel + Larastan + Vitest + Vite build + lychee + gitleaks). Каждая фаза — отдельный коммит на ветке `feat/supplier-webhook-fixes`, отдельный PR, отдельный merge → отдельный redeploy.
|
||||
|
||||
### Прод-smoke
|
||||
|
||||
После каждого деплоя — конкретные SQL-проверки в `db/`, описаны в каждом плане.
|
||||
|
||||
---
|
||||
|
||||
## 6. Откат
|
||||
|
||||
- Phase 1 — revert single commit.
|
||||
- Phase 2 — revert commit + dedup кода. Миграции БД нет.
|
||||
- Phase 3 — revert commit + миграция down: `DROP CONSTRAINT ... ADD CONSTRAINT ... CHECK IN (B1,B2,B3)`. Если в БД уже есть `platform=DIRECT` rows — миграция down упадёт. Нужен seed-cleanup перед откатом.
|
||||
|
||||
---
|
||||
|
||||
## 7. Файлы (общий список)
|
||||
|
||||
**Создать:**
|
||||
- `database/migrations/2026_05_25_120000_add_direct_platform.php` (Phase 3)
|
||||
- `app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php` (Phase 1, новый файл)
|
||||
- `app/tests/Feature/Supplier/CsvWebhookRaceTest.php` (Phase 2, новый файл)
|
||||
- `app/tests/Feature/Supplier/DirectPlatformTest.php` (Phase 3, новый файл)
|
||||
|
||||
**Изменить:**
|
||||
- `app/bootstrap/app.php` (Phase 1)
|
||||
- `app/app/Http/Controllers/Api/SupplierWebhookController.php` (Phase 3)
|
||||
- `app/app/Jobs/RouteSupplierLeadJob.php` (Phase 2 + Phase 3)
|
||||
- `app/app/Jobs/Supplier/CsvReconcileJob.php` (Phase 3)
|
||||
- `app/app/Services/SupplierProjects/SupplierProjectResolver.php` (Phase 3)
|
||||
- `app/app/Services/LeadRouter.php` (Phase 3)
|
||||
- `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` (Phase 3 — переписать line 95)
|
||||
- `db/schema.sql` (Phase 3 — sync с миграцией)
|
||||
- `db/CHANGELOG_schema.md` (Phase 3)
|
||||
|
||||
**Возможно затронуть:**
|
||||
- `app/app/Services/Billing/LedgerService.php` (Phase 2 — guard от двойного списания, если ещё не идемпотентен)
|
||||
|
||||
---
|
||||
|
||||
## 8. Открытые вопросы (на момент написания спеки)
|
||||
|
||||
- **OQ-1.** Идемпотентен ли `LedgerService::chargeForDelivery` по `(deal_id, lead_id)` или может списать дважды? — выяснится в Phase 2 Task 1 (read code).
|
||||
- **OQ-2.** `supplier_projects.subject_code` — обязательное поле для DIRECT? — выяснится в Phase 3 Task 2 (миграция).
|
||||
- **OQ-3.** `chk_supplier_projects_b1_not_for_sms` constraint конфликтует с DIRECT? — выяснится в Phase 3 Task 1.
|
||||
|
||||
Каждый вопрос разрешается inline во время реализации, не блокирует план.
|
||||
|
||||
---
|
||||
|
||||
## 9. Ссылки
|
||||
|
||||
- План Phase 1: `docs/superpowers/plans/2026-05-25-supplier-webhook-phase-1-json-422.md`
|
||||
- План Phase 2: `docs/superpowers/plans/2026-05-25-supplier-webhook-phase-2-dedup.md`
|
||||
- План Phase 3: `docs/superpowers/plans/2026-05-25-supplier-webhook-phase-3-direct-platform.md`
|
||||
- Memory project_supplier_integration.md — историческая информация о supplier flow
|
||||
- ADR-008 (если потребуется DIRECT — оформить как ADR-018 «Supplier DIRECT platform»)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user