docs(specs): Plan 5 (Frontend Projects UI + Backend CRUD) implementation design
Spec для full-stack плана: backend CRUD на projects (POST/PATCH/DELETE/sync/bulk), frontend ProjectsView с карточками+прогресс-баром, NewProjectDialog с 3 табами (Site/Call/SMS), polling sync-статуса через setTimeout-recursion + backoff, schema delta v8.19→v8.20 (projects.archived_at). Через superpowers:brainstorming skill. 11-13 task'ов по vertical-slice TDD (паттерн Plan 4). Self-review прошёл — 4 inline-фиксы внесены. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,612 @@
|
||||
# Spec: Plan 5 — Frontend Project UI + Backend CRUD
|
||||
|
||||
**Версия:** 1.0
|
||||
**Дата:** 11.05.2026
|
||||
**Автор:** Claude Code (skill: superpowers:brainstorming)
|
||||
**Заказчик:** Дмитрий
|
||||
**Базовый HEAD:** `4bc488e` (origin/main, Plan 4 закрыт + display-fix)
|
||||
**Источники:**
|
||||
|
||||
- [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](2026-05-10-supplier-integration-design.md) — общий дизайн интеграции с supplier portal (Plan 2/3/4 база)
|
||||
- [db/schema.sql](../../db/schema.sql) v8.19 — `projects`, `supplier_projects`
|
||||
- [docs/CRM_bp-gr_Инструкция_v8_5.md](../../CRM_bp-gr_Инструкция_v8_5.md) — продуктовое ТЗ, §6 (проекты)
|
||||
|
||||
## 1. Контекст
|
||||
|
||||
После Plans 1-4 у Лидерры есть:
|
||||
|
||||
- Schema `projects` с полным набором полей (signal_type, signal_identifier, sms_senders/keyword, daily_limit_target, supplier_b1/b2/b3_project_id, region_mask, delivery_days_mask, assignment_strategy, ttfr_target_minutes).
|
||||
- Schema `supplier_projects` (sync_status, last_synced_at).
|
||||
- Backend supplier-sync инфраструктура (Plan 3): `SupplierPortalClient`, `SupplierWebhookController`, `RouteSupplierLeadJob`.
|
||||
- `ProjectController@index` — **только GET-list-stub** (4 поля: id/name/tag/type) для NewDealDialog dropdown'а.
|
||||
|
||||
Чего **нет**:
|
||||
|
||||
- Backend: full CRUD на `projects` (POST/PATCH/DELETE/sync-trigger/bulk/toggle-active).
|
||||
- Frontend: **ProjectsView с нуля**. Нет route `/projects`, нет пункта в nav-tree, нет компонентов.
|
||||
|
||||
Tenant Лидерры не может управлять своими источниками лидов через UI. Plan 5 закрывает этот разрыв.
|
||||
|
||||
## 2. Scope и non-goals
|
||||
|
||||
### В scope
|
||||
|
||||
- Backend CRUD: GET list/show, POST/PATCH/DELETE, sync-trigger, toggle-active, bulk operations
|
||||
- Frontend ProjectsView: карточки с прогресс-баром «доставлено/лимит сегодня», фильтры, поиск
|
||||
- NewProjectDialog: 3 таба (Сайт / Звонок / СМС) + общие поля (name, daily_limit, regions, workdays)
|
||||
- EditProjectDialog: re-use NewProjectDialog в режиме edit (одна форма для create + edit)
|
||||
- Async sync flow: SyncSupplierProjectJob + polling sync_status каждые 5 сек
|
||||
- Schema delta v8.19 → v8.20: `projects.archived_at TIMESTAMPTZ NULL`
|
||||
- Bulk operations: pause/resume/archive нескольких проектов через один endpoint
|
||||
- Pause/Resume toggle (быстрый toggle прямо на карточке)
|
||||
- Manual «Sync now» кнопка (для retry после sync_status='failed')
|
||||
|
||||
### Не в scope
|
||||
|
||||
- **SSE/WebSocket** для sync-статусов — polling 5 сек достаточно для MVP (5-50 проектов на tenant).
|
||||
- **Карта РФ** для выбора регионов — chip-multiselect с поиском (см. §6.4).
|
||||
- **Hard delete** — только soft (`is_active=false` + `archived_at`). Schema RESTRICT FK от deals на projects, hard delete сломал бы историю.
|
||||
- **Project templates** (создание из шаблона) — Plan 6+.
|
||||
- **Шаблонизация sms_senders** (один sender для нескольких проектов) — поведение supplier portal на B3 сделает это автоматически (Plan 3 базис).
|
||||
- **Project analytics** (history, conversion stats) — задача ReportsView, Plan 6+.
|
||||
- **Manual sender-conflict resolution UI** — если sender уже занят, пользователь видит ошибку, должен выбрать другой. Auto-merge на B3 — следующий план.
|
||||
|
||||
### Допущения
|
||||
|
||||
- Tenant Лидерры лимитирован `tenants.limits.max_projects` (по умолчанию 10 для тарифа «Команда»).
|
||||
- `delivered_today` / `delivered_in_month` обновляются существующим RouteSupplierLeadJob — Plan 5 их не трогает, только читает.
|
||||
- На MVP RLS изоляция: `auth()->user()->tenant_id` устанавливается в `app.current_tenant_id` через middleware `tenant`.
|
||||
|
||||
## 3. Архитектура
|
||||
|
||||
### Backend компоненты
|
||||
|
||||
```
|
||||
app/Http/Controllers/Api/ProjectController.php ← расширение (~250 строк)
|
||||
- index() GET /api/projects
|
||||
- show($id) GET /api/projects/{id}
|
||||
- store() POST /api/projects
|
||||
- update($id) PATCH /api/projects/{id}
|
||||
- destroy($id) DELETE /api/projects/{id}
|
||||
- sync($id) POST /api/projects/{id}/sync
|
||||
- toggle($id) PATCH /api/projects/{id}/toggle-active
|
||||
- bulk() POST /api/projects/bulk
|
||||
|
||||
app/Http/Requests/StoreProjectRequest.php ← новый
|
||||
app/Http/Requests/UpdateProjectRequest.php ← новый
|
||||
app/Http/Requests/BulkProjectActionRequest.php ← новый
|
||||
|
||||
app/Services/Project/ProjectService.php ← новый
|
||||
- create(array $data): Project
|
||||
- update(Project $p, array $data): Project
|
||||
- archive(Project $p): void
|
||||
- triggerSync(Project $p): void
|
||||
- bulkAction(string $action, array $ids): int
|
||||
|
||||
app/Jobs/SyncSupplierProjectJob.php ← новый (Plan 3 уже имеет похожие jobs)
|
||||
- handle(): резолвит платформы, дёргает SupplierPortalClient,
|
||||
привязывает supplier_projects, выставляет sync_status
|
||||
|
||||
database/migrations/2026_05_..._add_archived_at_to_projects.php
|
||||
- ALTER TABLE projects ADD COLUMN archived_at TIMESTAMPTZ NULL
|
||||
```
|
||||
|
||||
### Frontend компоненты
|
||||
|
||||
```
|
||||
resources/js/router/index.ts ← +route /projects
|
||||
resources/js/components/layout/AppLayout.vue ← +пункт «Проекты» в nav-tree
|
||||
|
||||
resources/js/views/ProjectsView.vue ← новый (~200 строк)
|
||||
- header: title, фильтры (signal_type/status), search input, кнопка «+ Создать»
|
||||
- body: grid карточек ProjectCard + BulkActionsBar (если selected.length > 0)
|
||||
- state: pagination, фильтры, выбранные ids
|
||||
|
||||
resources/js/views/projects/NewProjectDialog.vue ← новый (~250 строк)
|
||||
- <v-tabs>: Сайт / Звонок / СМС
|
||||
- под каждым табом — соответствующие поля + общие
|
||||
- validation через VeeValidate или нативный (на выбор имплементатора)
|
||||
|
||||
resources/js/views/projects/EditProjectDialog.vue ← новый (~30 строк)
|
||||
- re-use NewProjectDialog с :initial-data и :mode="edit"
|
||||
- signal_type readonly после создания (нельзя поменять)
|
||||
|
||||
resources/js/components/projects/ProjectCard.vue ← новый (~120 строк)
|
||||
- имя, type-chip, identifier, прогресс-бар (delivered_today / daily_limit_target)
|
||||
- sync-status indicator (color-coded по 4 значениям)
|
||||
- меню действий ⋮: Edit, Pause/Resume toggle, Sync now, Archive
|
||||
- чекбокс для bulk
|
||||
|
||||
resources/js/components/projects/BulkActionsBar.vue ← новый (~80 строк)
|
||||
- toolbar fixed-bottom с кнопками: Pause, Resume, Archive
|
||||
- confirm-dialog перед action
|
||||
|
||||
resources/js/stores/projectsStore.ts ← новый (~150 строк)
|
||||
- state: items, filters, pagination, selectedIds, pendingIds (Set)
|
||||
- actions: fetch, create, update, archive, sync, toggle, bulkAction
|
||||
- polling: пока pendingIds.size > 0, refetch по этим ids каждые 5 сек
|
||||
- cleanup polling на unmount
|
||||
|
||||
resources/js/views/ProjectsView.story.vue ← новый (Histoire)
|
||||
resources/js/views/projects/NewProjectDialog.story.vue
|
||||
resources/js/components/projects/ProjectCard.story.vue
|
||||
resources/js/components/projects/BulkActionsBar.story.vue
|
||||
```
|
||||
|
||||
## 4. Data model
|
||||
|
||||
### 4.1. Schema delta v8.19 → v8.20
|
||||
|
||||
```sql
|
||||
ALTER TABLE projects ADD COLUMN archived_at TIMESTAMPTZ NULL;
|
||||
```
|
||||
|
||||
**Зачем колонка отдельно от `is_active`:**
|
||||
|
||||
- `is_active=false` означает «приостановлен» — обратимо, статус виден в UI как «На паузе».
|
||||
- `archived_at IS NOT NULL` — «архивирован», не отображается в default-list, требует `?show=archived` для просмотра. Restore возможен через POST `/api/projects/{id}/restore` (отдельный endpoint, Plan 6 если нужен).
|
||||
|
||||
В Plan 5 restore-endpoint **не реализуется** — архивный проект восстанавливается только через прямой SQL или admin SaaS UI (out-of-scope).
|
||||
|
||||
### 4.2. Использование существующих полей `projects`
|
||||
|
||||
| Поле | Использование в Plan 5 |
|
||||
|---|---|
|
||||
| `id`, `tenant_id`, `name` | Базовое |
|
||||
| `signal_type` | enum: `site` / `call` / `sms` (зашит в валидации) |
|
||||
| `signal_identifier` | Для site/call: домен или 11-значный номер с 7 |
|
||||
| `sms_senders` (jsonb) | Для sms: `array<string max:11>` |
|
||||
| `sms_keyword` (text) | Для sms: nullable, при пустом — только B3 |
|
||||
| `daily_limit_target` | Целевой лимит в день |
|
||||
| `effective_daily_limit_today` | **Read-only в UI** (вычисляется существующим backend'ом на основании balance) |
|
||||
| `delivered_today`, `delivered_in_month` | **Read-only** (обновляется RouteSupplierLeadJob) |
|
||||
| `supplier_b1/b2/b3_project_id` | **Read-only** (обновляется SyncSupplierProjectJob) |
|
||||
| `is_active` | pause/resume toggle |
|
||||
| `region_mask` | int (bitmask 89 регионов; 0 = вся РФ) |
|
||||
| `region_mode` | string: `all` / `whitelist` / `blacklist`. В Plan 5 используем только `all` (пустой `region_mask=0`) и `whitelist` (`region_mask` с битами выбранных регионов). `blacklist` остаётся в schema на будущее (Plan 6+), сейчас в UI недоступен. |
|
||||
| `delivery_days_mask` | int 1-127 (bitmask 7 дней; например 31 = пн-пт) |
|
||||
| `assignment_strategy` | string (по умолчанию `round-robin`) — Plan 5 не редактирует, дефолт |
|
||||
| `ttfr_target_minutes` | int — Plan 5 не редактирует, дефолт из schema |
|
||||
| `archived_at` | **новое**: timestamp архивирования |
|
||||
|
||||
### 4.3. Связь с `supplier_projects`
|
||||
|
||||
В Plan 5 при создании/редактировании Лидерра-проекта `SyncSupplierProjectJob`:
|
||||
|
||||
1. Резолвит набор платформ:
|
||||
- site/call → B1 + B2 + B3
|
||||
- sms с keyword → B2 + B3
|
||||
- sms без keyword → только B3
|
||||
2. На каждой платформе ищет existing `supplier_projects` по `unique_key`:
|
||||
- site: домен
|
||||
- call: номер
|
||||
- sms B2: `<senders[0]>+<keyword>`
|
||||
- sms B3: `<senders[0]>` (если sms_senders.length > 1 — несколько supplier_projects)
|
||||
3. Если не нашёл — создаёт через SupplierPortalClient → INSERT в `supplier_projects`.
|
||||
4. Записывает foreign keys в `projects.supplier_b1_project_id|b2|b3`.
|
||||
5. Выставляет `supplier_projects.sync_status='ok'` (или `'failed'` с `last_error`).
|
||||
|
||||
UI читает `sync_status` через `GET /api/projects/{id}` (включая joined supplier_projects).
|
||||
|
||||
## 5. API контракт
|
||||
|
||||
### 5.1. GET /api/projects
|
||||
|
||||
**Запрос:**
|
||||
|
||||
```
|
||||
GET /api/projects?signal_type=site&status=active&search=окна&page=1&per_page=20
|
||||
```
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Параметр | Тип | Значение |
|
||||
|---|---|---|
|
||||
| `signal_type` | string | `site` / `call` / `sms` (фильтр; пусто = все) |
|
||||
| `status` | string | `active` / `paused` / `archived` (default — `active`+`paused`, исключая archived) |
|
||||
| `search` | string | LIKE-поиск по `name` + `signal_identifier` (debounce 300ms на фронте) |
|
||||
| `page`, `per_page` | int | пагинация (default 1 / 20; max per_page=100) |
|
||||
| `ids` | csv-string | batch-fetch для polling: `?ids=1,5,7` — возвращает только эти ids (без пагинации, без фильтров) |
|
||||
|
||||
**Ответ 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Окна СПб",
|
||||
"signal_type": "site",
|
||||
"signal_identifier": "okna-spb.ru",
|
||||
"sms_senders": null,
|
||||
"sms_keyword": null,
|
||||
"daily_limit_target": 50,
|
||||
"effective_daily_limit_today": 50,
|
||||
"delivered_today": 32,
|
||||
"delivered_in_month": 412,
|
||||
"is_active": true,
|
||||
"archived_at": null,
|
||||
"region_mask": 0,
|
||||
"region_mode": "all",
|
||||
"delivery_days_mask": 127,
|
||||
"sync_status": "ok",
|
||||
"last_synced_at": "2026-05-11T13:30:00Z"
|
||||
}
|
||||
],
|
||||
"meta": { "current_page": 1, "per_page": 20, "total": 12 }
|
||||
}
|
||||
```
|
||||
|
||||
`sync_status` — агрегированный (worst-of по supplier_projects): если хоть один `failed` → `failed`; иначе если хоть один `pending` → `pending`; иначе `ok`.
|
||||
|
||||
### 5.2. GET /api/projects/{id}
|
||||
|
||||
Включает массив `supplier_links` с детализацией:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"...": "поля как в index",
|
||||
"supplier_links": [
|
||||
{ "platform": "b1", "supplier_project_id": 42, "sync_status": "ok", "last_synced_at": "..." },
|
||||
{ "platform": "b2", "supplier_project_id": 43, "sync_status": "ok", "last_synced_at": "..." },
|
||||
{ "platform": "b3", "supplier_project_id": 44, "sync_status": "ok", "last_synced_at": "..." }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3. POST /api/projects
|
||||
|
||||
**Тело (site):**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Окна СПб",
|
||||
"signal_type": "site",
|
||||
"signal_identifier": "okna-spb.ru",
|
||||
"daily_limit_target": 50,
|
||||
"region_mask": 0,
|
||||
"region_mode": "all",
|
||||
"delivery_days_mask": 127
|
||||
}
|
||||
```
|
||||
|
||||
**Тело (sms с keyword):**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Ипотека Тинькофф",
|
||||
"signal_type": "sms",
|
||||
"sms_senders": ["TINKOFF"],
|
||||
"sms_keyword": "ипотека",
|
||||
"daily_limit_target": 100,
|
||||
"region_mask": 0,
|
||||
"region_mode": "all",
|
||||
"delivery_days_mask": 127
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ 201:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": { "id": 13, "...": "...", "sync_status": "pending" }
|
||||
}
|
||||
```
|
||||
|
||||
После 201 Pinia store добавляет `13` в `pendingIds` → polling запускается.
|
||||
|
||||
### 5.4. PATCH /api/projects/{id}
|
||||
|
||||
Partial body — любое подмножество полей кроме `signal_type` (immutable после create), `tenant_id` (immutable), `delivered_*` / `supplier_b*_project_id` (read-only).
|
||||
|
||||
Если изменился `signal_identifier` / `sms_senders` / `sms_keyword` — диспатчится `SyncSupplierProjectJob` (sync_status переходит в pending).
|
||||
|
||||
### 5.5. DELETE /api/projects/{id}
|
||||
|
||||
Soft delete:
|
||||
|
||||
```
|
||||
UPDATE projects SET is_active=false, archived_at=NOW() WHERE id=:id AND tenant_id=current_tenant_id();
|
||||
```
|
||||
|
||||
Возвращает **204 No Content**. SyncSupplierProjectJob НЕ диспатчится — supplier_projects остаются как есть (могут шарить другие Лидерра-проекты). Если supplier_projects больше никем не используются — это уберёт отдельный `CleanupOrphanSupplierProjectsJob` (out-of-scope Plan 5).
|
||||
|
||||
### 5.6. POST /api/projects/{id}/sync
|
||||
|
||||
Re-dispatch sync job. Возвращает:
|
||||
|
||||
```json
|
||||
{ "queued": true, "sync_status": "pending" }
|
||||
```
|
||||
|
||||
Status 202 Accepted. UI добавляет id в pendingIds → polling.
|
||||
|
||||
### 5.7. PATCH /api/projects/{id}/toggle-active
|
||||
|
||||
```json
|
||||
{ "is_active": false }
|
||||
```
|
||||
|
||||
Не диспатчит sync job. Возвращает 200 + project. UI обновляет state.
|
||||
|
||||
### 5.8. POST /api/projects/bulk
|
||||
|
||||
```json
|
||||
{ "action": "pause", "ids": [1, 5, 7] }
|
||||
```
|
||||
|
||||
`action` ∈ `{"pause", "resume", "archive"}`. Возвращает:
|
||||
|
||||
```json
|
||||
{ "updated": 3 }
|
||||
```
|
||||
|
||||
Все ids фильтруются по tenant_id (RLS) — если в массиве чужие — они молча отбрасываются (см. §7).
|
||||
|
||||
## 6. UI/UX детали
|
||||
|
||||
### 6.1. Layout списка (карточки)
|
||||
|
||||
- Vue 3 + Vuetify 3, palette Forest (`#0F6E56` primary)
|
||||
- Grid responsive: 1 колонка мобильно, 2 — tablet, 3 — desktop
|
||||
- ProjectCard:
|
||||
- Header: name (bold) + type-chip (по цветам) + `⋮` action menu
|
||||
- Sub: signal_identifier (или `<senders>·<keyword>` для sms) — text-medium-emphasis
|
||||
- Прогресс: `delivered_today / daily_limit_target` + horizontal `v-progress-linear` (Forest при OK, серый при paused, красный при failed)
|
||||
- Footer: sync-status indicator с цветом и текстом
|
||||
- Архивные не показываются по умолчанию; фильтр включает их по `?show=archived`
|
||||
|
||||
### 6.2. NewProjectDialog (3 таба)
|
||||
|
||||
- `<v-tabs>` сверху: Сайт / Звонок / СМС (с иконками)
|
||||
- **Общие поля** размещаются **под `<v-tabs>` в стационарной секции** (не внутри `<v-tab-item>`-ов): `name`, `daily_limit_target`, `regions`, `workdays`. Так они не «плывут» при переключении таба.
|
||||
- Поля по табу (внутри `<v-tabs-window-item>` соответствующего signal_type):
|
||||
- **Сайт**: signal_identifier (label «Домен конкурента», placeholder «okna-konkurent.ru», hint про формат)
|
||||
- **Звонок**: signal_identifier (label «Номер конкурента», маска `+7 (XXX) XXX-XX-XX`, валидация 11-значный)
|
||||
- **СМС**: sms_senders (chip-input до 11 символов, multiple), sms_keyword (optional, hint про B2/B3)
|
||||
|
||||
Кнопки внизу: «Отмена» / «Создать». При submit — POST, success → close dialog + add to pendingIds.
|
||||
|
||||
### 6.3. EditProjectDialog
|
||||
|
||||
Тот же компонент с `:mode="edit"`. Различия:
|
||||
|
||||
- `<v-tabs>` disabled (signal_type не меняется)
|
||||
- signal_identifier readonly для site/call
|
||||
- sms_senders / sms_keyword редактируется (но триггерит resync)
|
||||
- name / daily_limit / regions / workdays — редактируется свободно
|
||||
|
||||
### 6.4. Регионы
|
||||
|
||||
- `<v-autocomplete multiple chips clearable>` с поиском
|
||||
- Items — массив 89 регионов РФ из локального constants (`resources/js/constants/regions.ts`)
|
||||
- Каждый регион = `{ code: int, name: string }` (например `{ code: 78, name: 'Санкт-Петербург' }`)
|
||||
- Пусто = вся РФ (region_mask=0, region_mode='all')
|
||||
- Выбраны — chip'ы; bit-mask вычисляется на фронте перед submit
|
||||
|
||||
### 6.5. Workdays
|
||||
|
||||
- 7 toggle-buttons (Пн Вт Ср Чт Пт Сб Вс)
|
||||
- Shortcut buttons: «Будни» (1-5), «Все дни» (1-7), «Только выходные» (6-7)
|
||||
- bit-mask: bit0=Пн, ..., bit6=Вс
|
||||
|
||||
### 6.6. Sync-status indicator
|
||||
|
||||
| Status | Цвет | Текст |
|
||||
|---|---|---|
|
||||
| `ok` | `#0F6E56` (Forest primary) | «● Sync OK» |
|
||||
| `pending` | `#FF9800` (orange) | «● Sync pending» + small spinner |
|
||||
| `failed` | `#D32F2F` (error red) | «● Sync failed» + tooltip с `last_error` |
|
||||
| paused (is_active=false) | `#999` (grey) | «● На паузе» |
|
||||
|
||||
### 6.7. Polling-логика
|
||||
|
||||
```typescript
|
||||
// projectsStore.ts (псевдокод)
|
||||
const pendingIds = ref<Set<number>>(new Set());
|
||||
let pollTimeout: number | null = null;
|
||||
let currentDelay = 5000; // ms; растёт при ошибках до 30000 макс
|
||||
const DELAY_OK = 5000;
|
||||
const DELAY_MAX = 30000;
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimeout) return;
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
function scheduleNext() {
|
||||
pollTimeout = setTimeout(async () => {
|
||||
if (pendingIds.value.size === 0) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ids = Array.from(pendingIds.value);
|
||||
const { data } = await axios.get('/api/projects', { params: { ids: ids.join(',') } });
|
||||
for (const project of data.data) {
|
||||
updateItem(project);
|
||||
if (project.sync_status === 'ok' || project.sync_status === 'failed') {
|
||||
pendingIds.value.delete(project.id);
|
||||
}
|
||||
}
|
||||
currentDelay = DELAY_OK; // reset на success
|
||||
} catch (e) {
|
||||
currentDelay = Math.min(currentDelay * 2, DELAY_MAX); // exponential backoff
|
||||
// если currentDelay уже на потолке после 3-й ошибки подряд — show snackbar и стоп
|
||||
}
|
||||
scheduleNext();
|
||||
}, currentDelay);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimeout) clearTimeout(pollTimeout);
|
||||
pollTimeout = null;
|
||||
currentDelay = DELAY_OK;
|
||||
}
|
||||
```
|
||||
|
||||
Используем `setTimeout`-recursion (не `setInterval`) чтобы переменный delay при backoff'е работал корректно.
|
||||
|
||||
GET endpoint должен поддерживать `?ids=1,5,7` для batch-fetch (расширение в §5.1).
|
||||
|
||||
## 7. Validation rules
|
||||
|
||||
### 7.1. StoreProjectRequest
|
||||
|
||||
```php
|
||||
public function rules(): array
|
||||
{
|
||||
$signalType = $this->input('signal_type');
|
||||
|
||||
$rules = [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'signal_type' => ['required', Rule::in(['site', 'call', 'sms'])],
|
||||
'daily_limit_target' => ['required', 'integer', 'min:1', 'max:10000'],
|
||||
'region_mask' => ['required', 'integer', 'min:0'],
|
||||
'region_mode' => ['required', Rule::in(['all', 'whitelist', 'blacklist'])],
|
||||
'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
|
||||
];
|
||||
|
||||
if ($signalType === 'site') {
|
||||
$rules['signal_identifier'] = ['required', 'string', 'regex:/^[a-z0-9.-]+\.[a-z]{2,}$/i'];
|
||||
}
|
||||
if ($signalType === 'call') {
|
||||
$rules['signal_identifier'] = ['required', 'string', 'regex:/^7\d{10}$/'];
|
||||
}
|
||||
if ($signalType === 'sms') {
|
||||
$rules['sms_senders'] = ['required', 'array', 'min:1'];
|
||||
$rules['sms_senders.*'] = ['string', 'max:11'];
|
||||
$rules['sms_keyword'] = ['nullable', 'string', 'max:50'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2. UpdateProjectRequest
|
||||
|
||||
Тот же набор кроме `signal_type` (immutable) и `signal_identifier` для site/call (immutable; для sms `sms_senders/keyword` редактируется).
|
||||
|
||||
### 7.3. BulkProjectActionRequest
|
||||
|
||||
```php
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'action' => ['required', Rule::in(['pause', 'resume', 'archive'])],
|
||||
'ids' => ['required', 'array', 'min:1', 'max:100'],
|
||||
'ids.*' => ['integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Max 100 ids — защита от DoS-payload'ов.
|
||||
|
||||
## 8. Обработка ошибок
|
||||
|
||||
| Место падения | Что может упасть | Поведение |
|
||||
|---|---|---|
|
||||
| `StoreProjectRequest` | Невалидный домен / телефон / senders | 422 + payload `{errors: {field: [msg]}}` (стандарт Laravel). UI рендерит ошибки в `<v-text-field error-messages>`. |
|
||||
| `ProjectService::create` | Лимит `tenants.limits.max_projects` исчерпан | 403 `{message: "Достигнут лимит проектов (X) тарифа «Y»"}`. UI: alert с ссылкой на /billing «Смените тариф». |
|
||||
| `SyncSupplierProjectJob` | Supplier portal недоступен | sync_status='failed', `last_error="Не удалось подключиться к порталу поставщика"`. Retry 3 раза с backoff 15с/60с/300с. После 3-го — финальный fail. UI отображает кнопку «Sync now» для retry. |
|
||||
| `SyncSupplierProjectJob` | Sender уже занят (B3 не разрешает дубликаты) | sync_status='failed', `last_error="Sender '<X>' уже зарегистрирован на платформе B3 — выберите другой"`. UI отображает текст из tooltip'а. |
|
||||
| `ProjectService::update` | daily_limit_target < delivered_today | 422 `{errors: {daily_limit_target: ["Лимит не может быть меньше уже доставленных лидов сегодня (X)"]}}`. |
|
||||
| `ProjectService::archive` | Project уже архивирован | 409 Conflict `{message: "Project уже архивирован"}`. |
|
||||
| `bulk` action | Некоторые ids чужие (другой tenant) | RLS отфильтрует через `WHERE tenant_id=current_tenant_id()`. Чужие ids молча игнорируются. Ответ `{updated: N}` где N = только свои. |
|
||||
| Polling 5xx | Backend временно недоступен | Frontend exponential backoff: 5с → 10с → 30с → стоп с `v-snackbar` «Не удалось проверить статус. Обновите страницу.». |
|
||||
|
||||
## 9. Тестирование
|
||||
|
||||
### 9.1. Pest (Feature, integration)
|
||||
|
||||
```
|
||||
tests/Feature/Projects/
|
||||
├── ProjectsCrudTest.php # index/show/store/update/destroy happy path
|
||||
├── ProjectsValidationTest.php # все 422-ветки (3 signal_type × invalid поля)
|
||||
├── ProjectsTenantIsolationTest.php # RLS: tenant X не видит проекты tenant Y
|
||||
├── ProjectsSyncTriggerTest.php # POST /sync → job dispatched, status pending
|
||||
├── ProjectsToggleActiveTest.php # PATCH /toggle-active
|
||||
├── ProjectsBulkTest.php # bulk pause/resume/archive + mixed-tenant отфильтровывается
|
||||
├── ProjectsLimitTest.php # max_projects превышен → 403
|
||||
└── ProjectsArchiveTest.php # destroy → is_active=false, archived_at != null
|
||||
|
||||
tests/Feature/Jobs/
|
||||
└── SyncSupplierProjectJobTest.php # happy + 3 failure scenarios (timeout/conflict/partial)
|
||||
```
|
||||
|
||||
### 9.2. Vitest (unit-frontend)
|
||||
|
||||
```
|
||||
tests/Frontend/
|
||||
├── ProjectsView.spec.ts # рендер, фильтры, search-debounce, empty state
|
||||
├── NewProjectDialog.spec.ts # переключение табов, валидация, submit
|
||||
├── EditProjectDialog.spec.ts # readonly-поля, prefill initial-data
|
||||
├── ProjectCard.spec.ts # sync-status цвета, прогресс-бар, меню действий
|
||||
├── BulkActionsBar.spec.ts # selection-flow, confirm-dialog
|
||||
└── projectsStore.spec.ts # polling (mock setInterval / fake timers)
|
||||
```
|
||||
|
||||
### 9.3. Histoire (visual regression baseline)
|
||||
|
||||
```
|
||||
resources/js/views/ProjectsView.story.vue # empty/5 projects/50 projects
|
||||
resources/js/views/projects/NewProjectDialog.story.vue # 3 tabs × 3 states (valid/error/loading)
|
||||
resources/js/views/projects/EditProjectDialog.story.vue # site/call/sms readonly states
|
||||
resources/js/components/projects/ProjectCard.story.vue # ok/pending/failed/paused
|
||||
resources/js/components/projects/BulkActionsBar.story.vue # 1/3/many selected
|
||||
```
|
||||
|
||||
### 9.4. TDD-цикл
|
||||
|
||||
Каждая task = vertical-slice: failing test → minimal code → green → refactor → commit. По паттерну Plan 4.
|
||||
|
||||
## 10. Acceptance criteria
|
||||
|
||||
- **AC-1.** `GET /api/projects` возвращает paginated list текущего tenant'а с фильтрами по signal_type, status, search.
|
||||
- **AC-2.** `POST /api/projects` для site/call/sms создаёт запись + диспатчит SyncSupplierProjectJob (sync_status='pending').
|
||||
- **AC-3.** `PATCH /api/projects/{id}` обновляет разрешённые поля, не трогает immutable (signal_type, supplier_b*_project_id).
|
||||
- **AC-4.** `DELETE /api/projects/{id}` ставит `is_active=false, archived_at=NOW()`.
|
||||
- **AC-5.** `POST /api/projects/{id}/sync` re-dispatch'ит job.
|
||||
- **AC-6.** `PATCH /api/projects/{id}/toggle-active` переключает is_active без resync.
|
||||
- **AC-7.** `POST /api/projects/bulk` атомарно выполняет action на до 100 ids, чужие отфильтровывает.
|
||||
- **AC-8.** ProjectsView отображает карточки с прогресс-баром и sync-status indicator.
|
||||
- **AC-9.** NewProjectDialog имеет 3 таба (Сайт/Звонок/СМС) с правильной валидацией по типу.
|
||||
- **AC-10.** EditProjectDialog re-use NewProjectDialog с readonly signal_type.
|
||||
- **AC-11.** Polling каждые 5 сек обновляет sync_status pending → ok/failed, останавливается когда pendingIds пуст.
|
||||
- **AC-12.** Bulk-toolbar появляется при selected.length>0, confirm-dialog перед действием.
|
||||
- **AC-13.** Все 422-ошибки валидации рендерятся в `<v-text-field error-messages>`.
|
||||
- **AC-14.** Pest зелёный (всё новое + не сломано существующее). Vitest зелёный. Histoire 5 новых story'ев билдятся.
|
||||
- **AC-15.** Pa11y zero violations на /projects (с уже существующими story.vue baseline).
|
||||
- **AC-16.** lychee 0 broken links в новых .md (plan + spec).
|
||||
|
||||
## 11. Открытые вопросы
|
||||
|
||||
- **OPEN-Plan5-01.** `assignment_strategy` (round-robin / by-region / по приоритету tenant'а) — в Plan 5 фиксируется как `round-robin` по schema-дефолту. Расширение — Plan 6.
|
||||
- **OPEN-Plan5-02.** `ttfr_target_minutes` — фиксируется по schema-дефолту. Редактирование через admin SaaS.
|
||||
- **OPEN-Plan5-03.** Restore из архива — endpoint не делаем в Plan 5. Если нужно — Plan 6 или admin SaaS интерфейс.
|
||||
- **OPEN-Plan5-04.** Что показывать в `EditProjectDialog` если `last_synced_at` старше N дней — оставляем как есть, без warning. Если потребуется — Plan 6.
|
||||
|
||||
## 12. Зависимости
|
||||
|
||||
- **Plans 1-4** — все merged на origin/main `4bc488e`. Schema v8.19, supplier sync backend, billing работают.
|
||||
- **Plan 3 supplier backend** — `SupplierPortalClient` уже умеет всё что нужно для SyncSupplierProjectJob. Plan 5 интегрируется как consumer.
|
||||
- **Plans 1-4 frontend stack** — Vue 3.5 + Vuetify 3.12 + Pinia 3.0 + axios 1.16 + Histoire 1.0 — без изменений.
|
||||
|
||||
## 13. Оценка объёма
|
||||
|
||||
Сопоставимо с Plan 4 (12 vertical-slice tasks):
|
||||
|
||||
| Категория | Tasks (ориентир) |
|
||||
|---|---|
|
||||
| Schema delta + миграция | 1 |
|
||||
| Backend controllers + requests + service + job | 4-5 |
|
||||
| Frontend ProjectsView + ProjectCard | 2 |
|
||||
| NewProjectDialog (3 таба + валидация) | 2 |
|
||||
| BulkActionsBar + polling store | 1-2 |
|
||||
| Pest + Vitest + Histoire | покрыты внутри каждой task'и (TDD) |
|
||||
| Wire-up: router + nav-tree + smoke | 1 |
|
||||
| **Итого:** | **~11-13 tasks** |
|
||||
|
||||
Точная декомпозиция — отдельный план через `superpowers:writing-plans` после approval этого spec'а.
|
||||
Reference in New Issue
Block a user