5 TDD-задач: тумблер режима экспорта (endpoint + UI), экран «Проекты у поставщика» (кто заказывал/дата последней поставки + bulk-delete бэк/фронт), ЛК require-region UI-гейт + «Вся РФ» предупреждение/подтверждение, полная регрессия. Финальный из 4 планов эпика. +cspell. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
26 KiB
Переделка миграции проектов — План 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 §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
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:
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):
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 + Коммит
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
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;):
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: Роуты
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 + Коммит
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
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 + Коммит
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
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) — включить sentinelcode: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 + Коммит
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[](пусто=Вся РФ) совпадает с StoreProjectRequestpresent|array. - Граница require-region: на бэке
regions=[](Вся РФ) и «забыл» неотличимы → гейт намеренно UI-only (T4); backend-валидацияpresent|arrayне меняется (P1: Вся РФ — валидный пустой массив). - Риск: admin-аутентификация в тестах (
saas_adminguard / factory) — привести к фактическому паттерну существующихapp/tests/Feature/Admin/*; роуты — в ту же группу, что текущиеAdminSupplierIntegrationControllerметоды.