Files
portal/docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-4-admin-lk.md
T
Дмитрий e5883c4c18 docs(plan): Plan 4 (админка + ЛК) — переделка миграции проектов
5 TDD-задач: тумблер режима экспорта (endpoint + UI), экран «Проекты у поставщика»
(кто заказывал/дата последней поставки + bulk-delete бэк/фронт), ЛК require-region
UI-гейт + «Вся РФ» предупреждение/подтверждение, полная регрессия. Финальный из
4 планов эпика. +cspell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:09:26 +03:00

26 KiB
Raw Blame History

Переделка миграции проектов — План 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) — включить 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 + Коммит
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 методы.