Schema CHECK constraint on projects.region_mode accepts только 'include'/'exclude'. Spec/plan изначально использовали 'all'/'whitelist'/'blacklist' (semantic naming), что не соответствует БД-схеме. При имплементации Task 3 implementer выбрал 'include'/'exclude' (match schema = source of truth). Propagate-fix: - plan (2 PHP Rule::in + ~10 payload mentions + 4 TS form defaults) - spec (§4.2 описание, 3 JSON API examples, §6.4 текст, §7.1 StoreProjectRequest) Чтобы Task 5+ (UpdateProjectRequest, frontend tasks 7-11) не повторили плановую ошибку. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
31 KiB
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 — общий дизайн интеграции с supplier portal (Plan 2/3/4 база)
- db/schema.sql v8.19 —
projects,supplier_projects - docs/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через middlewaretenant.
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
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: include / exclude (значения зашиты в CHECK-constraint schema.sql v8.20). В Plan 5 используем только include (default; пустой region_mask=0 = все РФ; ненулевой region_mask = whitelist выбранных регионов). exclude (blacklist-семантика) остаётся в schema на будущее (Plan 6+), сейчас в UI недоступен. Note: изначально spec/plan использовали all/whitelist semantic-naming, но при имплементации Task 3 обнаружено что schema CHECK требует include/exclude — schema = источник истины, поэтому propagate во все Task'и. |
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:
- Резолвит набор платформ:
- site/call → B1 + B2 + B3
- sms с keyword → B2 + B3
- sms без keyword → только B3
- На каждой платформе ищет existing
supplier_projectsпоunique_key:- site: домен
- call: номер
- sms B2:
<senders[0]>+<keyword> - sms B3:
<senders[0]>(если sms_senders.length > 1 — несколько supplier_projects)
- Если не нашёл — создаёт через SupplierPortalClient → INSERT в
supplier_projects. - Записывает foreign keys в
projects.supplier_b1_project_id|b2|b3. - Выставляет
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:
{
"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": "include",
"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 с детализацией:
{
"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):
{
"name": "Окна СПб",
"signal_type": "site",
"signal_identifier": "okna-spb.ru",
"daily_limit_target": 50,
"region_mask": 0,
"region_mode": "include",
"delivery_days_mask": 127
}
Тело (sms с keyword):
{
"name": "Ипотека Тинькофф",
"signal_type": "sms",
"sms_senders": ["TINKOFF"],
"sms_keyword": "ипотека",
"daily_limit_target": 100,
"region_mask": 0,
"region_mode": "include",
"delivery_days_mask": 127
}
Ответ 201:
{
"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. Возвращает:
{ "queued": true, "sync_status": "pending" }
Status 202 Accepted. UI добавляет id в pendingIds → polling.
5.7. PATCH /api/projects/{id}/toggle-active
{ "is_active": false }
Не диспатчит sync job. Возвращает 200 + project. UI обновляет state.
5.8. POST /api/projects/bulk
{ "action": "pause", "ids": [1, 5, 7] }
action ∈ {"pause", "resume", "archive"}. Возвращает:
{ "updated": 3 }
Все ids фильтруются по tenant_id (RLS) — если в массиве чужие — они молча отбрасываются (см. §7).
6. UI/UX детали
6.1. Layout списка (карточки)
- Vue 3 + Vuetify 3, palette Forest (
#0F6E56primary) - 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+ horizontalv-progress-linear(Forest при OK, серый при paused, красный при failed) - Footer: sync-status indicator с цветом и текстом
- Header: name (bold) + type-chip (по цветам) +
- Архивные не показываются по умолчанию; фильтр включает их по
?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='include')
- Выбраны — 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-логика
// 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
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(['include', 'exclude'])],
'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
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}/syncre-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'а.