Files
portal/docs/superpowers/plans/2026-05-21-project-delete-dedup-errors.md
T
2026-05-21 06:31:45 +03:00

33 KiB
Raw Blame History

Project delete + source dedup + human errors — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Заменить архивацию проектов настоящим удалением (с защитой по сделкам и корректной обработкой шеринга у поставщика), добавить дедуп источника внутри клиента и заменить сырые SQL-ошибки человеческими сообщениями.

Architecture: Бэкенд — вся логика в ProjectService (create/update/delete) + новый DeleteSupplierProjectJob для удаления/пере-синка донора у поставщика с учётом шеринга + глобальный handler QueryException. Архивация (archived_at) убирается полностью (миграция-снос колонки). Фронтенд — «Архивировать»→«Удалить», убрать фильтр «Архивные».

Tech Stack: PHP 8.3 / Laravel 13, Pest 4; Vue 3 + Vuetify 3 + Pinia, Vitest; PostgreSQL 16.

Спека: docs/superpowers/specs/2026-05-21-project-delete-dedup-errors-design.md

Команды (из app/): C:/tools/php83/php.exe artisan test --filter=<name>, composer pint, composer stan, npm run test:vue.

Ключевые факты (разведка):

  • Project — БЕЗ SoftDeletes → $project->delete() = hard delete. У projects нет deleted_at (только archived_at custom + is_active).
  • DealС SoftDeletes (deals.deleted_at). Guard считает ВСЕ сделки через DB::table('deals') (минует scope).
  • deals.project_id без FK; cascade на projects(id) только у служебных таблиц.
  • supplier_projects.supplier_external_id VARCHAR(64) — id донора у поставщика (числовой; cast к int для deleteProject(int)).
  • project_supplier_links — pivot (project_id, supplier_project_id), ON DELETE CASCADE на оба.
  • Источник: signal_identifier (call/site) либо sms_senders[]+sms_keyword (sms).

Task 1: Дедуп источника + имени в ProjectService::create()

Files:

  • Modify: app/app/Services/Project/ProjectService.php (метод create, +helper assertSourceUnique)

  • Test: app/tests/Feature/Project/ProjectCreateDedupTest.php (create)

  • Step 1: Написать падающие тесты

<?php
declare(strict_types=1);

use App\Models\Project;
use App\Models\Tenant;

beforeEach(function () {
    $this->tenant = Tenant::factory()->create(['balance_leads' => 100]);
});

function makeCall(array $over = []): array {
    return array_merge([
        'name' => 'Проект A', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
        'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
    ], $over);
}

it('blocks duplicate source within tenant with human message', function () {
    app(\App\Services\Project\ProjectService::class)->create($this->tenant, makeCall());
    expect(fn () => app(\App\Services\Project\ProjectService::class)
        ->create($this->tenant, makeCall(['name' => 'Проект B'])))
        ->toThrow(\Illuminate\Http\Exceptions\HttpResponseException::class);
});

it('allows same source for a different tenant (sharing)', function () {
    $other = Tenant::factory()->create(['balance_leads' => 100]);
    app(\App\Services\Project\ProjectService::class)->create($this->tenant, makeCall());
    $p = app(\App\Services\Project\ProjectService::class)->create($other, makeCall(['name' => 'Проект B']));
    expect($p)->toBeInstanceOf(Project::class);
});

it('blocks duplicate name within tenant with human message (not SQL)', function () {
    app(\App\Services\Project\ProjectService::class)->create($this->tenant, makeCall());
    try {
        app(\App\Services\Project\ProjectService::class)
            ->create($this->tenant, makeCall(['name' => 'Проект A', 'signal_identifier' => '79992220000']));
        $this->fail('expected HttpResponseException');
    } catch (\Illuminate\Http\Exceptions\HttpResponseException $e) {
        $body = $e->getResponse()->getData(true);
        expect($body['errors']['name'][0] ?? '')->not->toContain('SQLSTATE');
    }
});
  • Step 2: Запустить — убедиться что падают

Run: C:/tools/php83/php.exe artisan test --filter=ProjectCreateDedupTest Expected: FAIL (дедупа нет — второй create проходит / бьёт DB-констрейнт).

  • Step 3: Реализация в ProjectService::create()

В начало create() (после получения $tenant, до Project::create) добавить вызовы и helper'ы:

// перед Project::create($data):
$this->assertNameUnique($tenant->id, (string) $data['name']);
$this->assertSourceUnique($tenant->id, $data);

Добавить методы в класс:

private function assertNameUnique(int $tenantId, string $name, ?int $exceptId = null): void
{
    $q = Project::where('tenant_id', $tenantId)->where('name', $name);
    if ($exceptId !== null) {
        $q->where('id', '!=', $exceptId);
    }
    if ($q->exists()) {
        throw new HttpResponseException(response()->json([
            'errors' => ['name' => ['Проект с таким названием у вас уже есть. Выберите другое название.']],
        ], 422));
    }
}

/** @param array<string,mixed> $data */
private function assertSourceUnique(int $tenantId, array $data, ?int $exceptId = null): void
{
    $signalType = $data['signal_type'] ?? null;
    $q = Project::where('tenant_id', $tenantId)->where('signal_type', $signalType);
    if ($exceptId !== null) {
        $q->where('id', '!=', $exceptId);
    }

    if (in_array($signalType, ['call', 'site'], true)) {
        $identifier = (string) ($data['signal_identifier'] ?? '');
        if ($identifier === '') {
            return;
        }
        $q->where('signal_identifier', $identifier);
    } elseif ($signalType === 'sms') {
        $senders = (array) ($data['sms_senders'] ?? []);
        $norm = collect($senders)->map(fn ($s) => mb_strtolower(trim((string) $s)))->sort()->values()->all();
        if ($norm === []) {
            return;
        }
        // sms-источник идентичен, если совпадают набор отправителей и ключевое слово.
        $keyword = $data['sms_keyword'] ?? null;
        $q->where('sms_keyword', $keyword)
            ->whereJsonContains('sms_senders', $norm)
            ->whereRaw('jsonb_array_length(sms_senders::jsonb) = ?', [count($norm)]);
    } else {
        return;
    }

    $existing = $q->first();
    if ($existing !== null) {
        throw new HttpResponseException(response()->json([
            'errors' => ['signal_identifier' => ["У вас уже есть проект с этим источником: «{$existing->name}»."]],
        ], 422));
    }
}

Убедиться, что в шапке есть use Illuminate\Http\Exceptions\HttpResponseException; (уже есть).

  • Step 4: Запустить — PASS

Run: C:/tools/php83/php.exe artisan test --filter=ProjectCreateDedupTest Expected: PASS (3 теста).

  • Step 5: Commit
git add -- app/app/Services/Project/ProjectService.php app/tests/Feature/Project/ProjectCreateDedupTest.php
git commit -m "feat(projects): source+name dedup with human messages on create"

Task 2: Дедуп источника при смене источника (update)

Files:

  • Modify: app/app/Services/Project/ProjectService.php (метод update)

  • Test: app/tests/Feature/Project/ProjectUpdateDedupTest.php

  • Step 1: Падающий тест

<?php
declare(strict_types=1);

use App\Models\Project;
use App\Models\Tenant;

it('blocks update that collides source with another project of same tenant', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 100]);
    $svc = app(\App\Services\Project\ProjectService::class);
    $a = $svc->create($tenant, ['name' => 'A', 'signal_type' => 'call', 'signal_identifier' => '79991110000', 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31]);
    $b = $svc->create($tenant, ['name' => 'B', 'signal_type' => 'call', 'signal_identifier' => '79992220000', 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31]);

    expect(fn () => $svc->update($b, ['signal_identifier' => '79991110000']))
        ->toThrow(\Illuminate\Http\Exceptions\HttpResponseException::class);
});

it('allows update keeping same source on the same project', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 100]);
    $svc = app(\App\Services\Project\ProjectService::class);
    $a = $svc->create($tenant, ['name' => 'A', 'signal_type' => 'call', 'signal_identifier' => '79991110000', 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31]);
    $updated = $svc->update($a, ['signal_identifier' => '79991110000', 'daily_limit_target' => 7]);
    expect($updated->daily_limit_target)->toBe(7);
});
  • Step 2: Запустить — FAIL

Run: C:/tools/php83/php.exe artisan test --filter=ProjectUpdateDedupTest Expected: FAIL (первый кейс не бросает).

  • Step 3: Реализация в update()

После блока immutable-unset и до $project->update($data) добавить:

if (array_key_exists('signal_identifier', $data) || array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data)) {
    $this->assertSourceUnique($project->tenant_id, array_merge([
        'signal_type' => $project->signal_type,
        'signal_identifier' => $project->signal_identifier,
        'sms_senders' => $project->sms_senders,
        'sms_keyword' => $project->sms_keyword,
    ], $data), exceptId: $project->id);
}
if (array_key_exists('name', $data)) {
    $this->assertNameUnique($project->tenant_id, (string) $data['name'], exceptId: $project->id);
}
  • Step 4: Запустить — PASS

Run: C:/tools/php83/php.exe artisan test --filter=ProjectUpdateDedupTest Expected: PASS.

  • Step 5: Commit
git add -- app/app/Services/Project/ProjectService.php app/tests/Feature/Project/ProjectUpdateDedupTest.php
git commit -m "feat(projects): source+name dedup on update"

Task 3: Глобальный handler QueryException (никакого SQL в UI)

Files:

  • Modify: app/bootstrap/app.php (withExceptions)

  • Test: app/tests/Feature/Project/QueryExceptionRenderTest.php

  • Step 1: Падающий тест (бьём прямой DB-констрейнт мимо app-проверок — два проекта с одинаковым именем через прямой insert невозможно из API после Task 1, поэтому тестируем рендер handler'а на искусственном маршруте)

<?php
declare(strict_types=1);

use Illuminate\Support\Facades\Route;

it('renders QueryException as human JSON message, not SQLSTATE', function () {
    Route::get('/_test/boom-query', function () {
        throw new \Illuminate\Database\QueryException('pgsql', 'SELECT 1', [], new \Exception('SQLSTATE[23505] duplicate key'));
    });

    $res = $this->getJson('/_test/boom-query');
    $res->assertStatus(422);
    expect($res->json('message'))->not->toContain('SQLSTATE');
    expect($res->json('message'))->toContain('Не удалось');
});
  • Step 2: Запустить — FAIL

Run: C:/tools/php83/php.exe artisan test --filter=QueryExceptionRenderTest Expected: FAIL (по умолчанию 500 + SQL-текст в debug).

  • Step 3: Реализация в bootstrap/app.php

Заменить тело ->withExceptions(function (Exceptions $exceptions): void { // }); на:

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->render(function (\Illuminate\Database\QueryException $e, \Illuminate\Http\Request $request) {
        \Illuminate\Support\Facades\Log::error('db.query_exception', [
            'message' => $e->getMessage(),
            'sql' => $e->getSql(),
            'path' => $request->path(),
        ]);
        if ($request->expectsJson()) {
            return response()->json([
                'message' => 'Не удалось сохранить. Проверьте данные или попробуйте ещё раз.',
            ], 422);
        }

        return null; // дефолтный рендер для не-JSON
    });
})
  • Step 4: Запустить — PASS

Run: C:/tools/php83/php.exe artisan test --filter=QueryExceptionRenderTest Expected: PASS.

  • Step 5: Commit
git add -- app/bootstrap/app.php app/tests/Feature/Project/QueryExceptionRenderTest.php
git commit -m "feat(errors): global QueryException handler returns human message"

Task 4: ProjectService::delete() с guard по сделкам (+ снос archive())

Files:

  • Modify: app/app/Services/Project/ProjectService.php (+delete(), archive(), bulk archivedelete)

  • Modify: app/app/Http/Controllers/Api/ProjectController.php (destroydelete)

  • Modify: app/app/Http/Requests/BulkProjectActionRequest.php (archivedelete)

  • Test: app/tests/Feature/Project/ProjectDeleteTest.php

  • Step 1: Падающие тесты

<?php
declare(strict_types=1);

use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;

it('hard-deletes an empty project', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 100]);
    $project = app(\App\Services\Project\ProjectService::class)->create($tenant, [
        'name' => 'Empty', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
        'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
    ]);

    app(\App\Services\Project\ProjectService::class)->delete($project);

    expect(Project::find($project->id))->toBeNull();
});

it('blocks delete when project has deals', function () {
    $tenant = Tenant::factory()->create(['balance_leads' => 100]);
    $project = app(\App\Services\Project\ProjectService::class)->create($tenant, [
        'name' => 'WithDeals', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
        'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
    ]);
    DB::table('deals')->insert([
        'tenant_id' => $tenant->id, 'project_id' => $project->id, 'phone' => '79990001122',
        'status' => 'new', 'received_at' => now(), 'created_at' => now(),
    ]);

    expect(fn () => app(\App\Services\Project\ProjectService::class)->delete($project))
        ->toThrow(\Illuminate\Http\Exceptions\HttpResponseException::class);
    expect(Project::find($project->id))->not->toBeNull();
});
  • Step 2: Запустить — FAIL

Run: C:/tools/php83/php.exe artisan test --filter=ProjectDeleteTest Expected: FAIL (метода delete() нет).

  • Step 3: Реализация

В ProjectService добавить delete() и удалить archive():

public function delete(Project $project): void
{
    $hasDeals = DB::table('deals')->where('project_id', $project->id)->exists();
    if ($hasDeals) {
        throw new HttpResponseException(response()->json([
            'errors' => ['project' => ['Нельзя удалить проект: по нему есть сделки. Поставьте приём на паузу, чтобы скрыть проект из работы.']],
        ], 422));
    }

    // Доноров фиксируем ДО удаления — pivot уйдёт каскадом.
    $supplierProjectIds = DB::table('project_supplier_links')
        ->where('project_id', $project->id)
        ->pluck('supplier_project_id')
        ->all();

    $project->delete(); // hard delete (Project без SoftDeletes); cascade чистит pivot + служебные.

    if ($supplierProjectIds !== []) {
        \App\Jobs\Supplier\DeleteSupplierProjectJob::dispatch(array_map('intval', $supplierProjectIds));
    }
}

Добавить use Illuminate\Support\Facades\DB; в шапку. Удалить метод archive(). В bulkAction() строку 'archive' => ... заменить на:

'delete' => $this->bulkDelete($query),

Добавить bulkDelete (guard per-project, не роняет батч):

private function bulkDelete($query): array
{
    $projects = (clone $query)->get(['id']);
    $deleted = 0; $skipped = [];
    foreach ($projects as $p) {
        $model = Project::find($p->id);
        if ($model === null) { continue; }
        try {
            $this->delete($model);
            $deleted++;
        } catch (HttpResponseException) {
            $skipped[] = ['id' => $p->id, 'reason' => 'has_deals'];
        }
    }

    return ['updated' => $deleted, 'skipped' => $skipped, 'warnings' => []];
}

В update() убрать из unset строку $data['archived_at'], (колонка уходит в Task 6). В resolveBulkScope() ветку match 'archived' => ... удалить; 'active'/'paused' оставить без whereNull('archived_at') (см. Task 6).

В ProjectController::destroy() заменить $this->projects->archive($project); на $this->projects->delete($project); и docblock «soft-archive» → «hard delete (guard по сделкам)».

В BulkProjectActionRequest: в Rule::in([...]) для action 'archive''delete'; убрать 'archived' из Rule::in(['active','paused','archived']).

  • Step 4: Запустить — PASS + регрессия bulk

Run: C:/tools/php83/php.exe artisan test --filter=ProjectDeleteTest Then: C:/tools/php83/php.exe artisan test --filter=Project Expected: PASS; падений по archive нет (если есть старые тесты на archive — обновить на delete в этом же шаге).

  • Step 5: Commit
git add -- app/app/Services/Project/ProjectService.php app/app/Http/Controllers/Api/ProjectController.php app/app/Http/Requests/BulkProjectActionRequest.php app/tests/Feature/Project/ProjectDeleteTest.php
git commit -m "feat(projects): hard delete with deals-guard, replace archive"

Task 5: DeleteSupplierProjectJob — удаление/пере-синк донора с учётом шеринга

Files:

  • Create: app/app/Jobs/Supplier/DeleteSupplierProjectJob.php

  • Test: app/tests/Feature/Supplier/DeleteSupplierProjectJobTest.php

  • Step 1: Падающие тесты (mock SupplierPortalClient)

<?php
declare(strict_types=1);

use App\Jobs\Supplier\DeleteSupplierProjectJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;

it('deletes donor at supplier when no consumers remain', function () {
    $sp = SupplierProject::create(['platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79991110000', 'supplier_external_id' => '555', 'current_limit' => 1]);

    $mock = Mockery::mock(SupplierPortalClient::class);
    $mock->shouldReceive('deleteProject')->once()->with(555);
    app()->instance(SupplierPortalClient::class, $mock);

    (new DeleteSupplierProjectJob([$sp->id]))->handle(app(SupplierPortalClient::class));

    expect(SupplierProject::find($sp->id))->toBeNull();
});

it('does NOT delete donor at supplier when other consumers remain; re-syncs', function () {
    Bus::fake([SyncSupplierProjectsJob::class]);
    $tenant = Tenant::factory()->create(['balance_leads' => 100]);
    $sp = SupplierProject::create(['platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79991110000', 'supplier_external_id' => '555', 'current_limit' => 1]);
    $other = Project::factory()->create(['tenant_id' => $tenant->id]);
    DB::table('project_supplier_links')->insert(['project_id' => $other->id, 'supplier_project_id' => $sp->id, 'subject_code' => null]);

    $mock = Mockery::mock(SupplierPortalClient::class);
    $mock->shouldNotReceive('deleteProject');
    app()->instance(SupplierPortalClient::class, $mock);

    (new DeleteSupplierProjectJob([$sp->id]))->handle(app(SupplierPortalClient::class));

    expect(SupplierProject::find($sp->id))->not->toBeNull();
    Bus::assertDispatched(SyncSupplierProjectsJob::class);
});
  • Step 2: Запустить — FAIL

Run: C:/tools/php83/php.exe artisan test --filter=DeleteSupplierProjectJobTest Expected: FAIL (класса нет).

  • Step 3: Реализация джоба
<?php
declare(strict_types=1);

namespace App\Jobs\Supplier;

use App\Models\SupplierProject;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

/**
 * Удаление/пере-синк доноров у поставщика после удаления Лидерра-проекта.
 *
 * Для каждого supplier_project S (донора), к которому был привязан удалённый проект:
 *   - остались другие потребители (project_supplier_links) → донор нужен другим клиентам:
 *     НЕ удаляем у поставщика, пере-синкаем агрегат (SyncSupplierProjectsJob).
 *   - потребителей не осталось → удаляем у поставщика (deleteProject) + локальную запись S.
 *
 * Spec: docs/superpowers/specs/2026-05-21-project-delete-dedup-errors-design.md §Решение 2.
 */
class DeleteSupplierProjectJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;

    public int $backoff = 60;

    public const DB_CONNECTION = 'pgsql_supplier';

    /** @param array<int,int> $supplierProjectIds */
    public function __construct(public array $supplierProjectIds) {}

    public function handle(SupplierPortalClient $client): void
    {
        $needsResync = false;

        foreach ($this->supplierProjectIds as $id) {
            $sp = SupplierProject::on(self::DB_CONNECTION)->find($id);
            if ($sp === null) {
                continue;
            }

            $remaining = DB::connection(self::DB_CONNECTION)
                ->table('project_supplier_links')
                ->where('supplier_project_id', $id)
                ->count();

            if ($remaining > 0) {
                $needsResync = true;
                continue;
            }

            if ($sp->supplier_external_id !== null && $sp->supplier_external_id !== '') {
                try {
                    $client->deleteProject((int) $sp->supplier_external_id);
                } catch (\Throwable $e) {
                    Log::warning('supplier.delete_donor_failed', ['supplier_project_id' => $id, 'error' => $e->getMessage()]);
                    throw $e; // ретрай джоба
                }
            }
            $sp->delete();
        }

        if ($needsResync) {
            SyncSupplierProjectsJob::dispatch();
        }
    }
}
  • Step 4: Запустить — PASS

Run: C:/tools/php83/php.exe artisan test --filter=DeleteSupplierProjectJobTest Expected: PASS (2 теста).

  • Step 5: Commit
git add -- app/app/Jobs/Supplier/DeleteSupplierProjectJob.php app/tests/Feature/Supplier/DeleteSupplierProjectJobTest.php
git commit -m "feat(supplier): delete/re-sync donor on project delete respecting sharing"

Task 6: Снос archived_at — модель/ресурс/синк/дашборд/контроллер + миграция

Files:

  • Modify: app/app/Models/Project.php (scopeArchived, scopeActive, fillable+cast archived_at)

  • Modify: app/app/Http/Resources/ProjectResource.php (archived_at)

  • Modify: app/app/Http/Controllers/Api/ProjectController.php (index status=archived/active())

  • Modify: app/app/Http/Controllers/Api/DashboardController.php (whereNull('archived_at'))

  • Modify: app/app/Jobs/Supplier/SyncSupplierProjectsJob.php (whereNull('archived_at'))

  • Create: app/database/migrations/2026_05_21_000000_drop_projects_archived_at.php

  • Modify: db/schema.sql (убрать строку archived_at из projects + header v8.27) + db/CHANGELOG_schema.md

  • Test: запуск всей backend-регрессии

  • Step 1: Миграция

<?php
declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration {
    public function up(): void
    {
        DB::statement('ALTER TABLE projects DROP COLUMN IF EXISTS archived_at');
    }

    public function down(): void
    {
        DB::statement('ALTER TABLE projects ADD COLUMN archived_at TIMESTAMPTZ NULL');
    }
};
  • Step 2: Чистка кода

  • Project.php: удалить 'archived_at' из $fillable и из casts; удалить методы scopeArchived и scopeActive (scope scopeActiveOnDay — НЕ трогать, это про день недели). После удаления заменить всех вызывающих ->active() на чистый query:

    • ProjectService::create() лимит-проверка: Project::where('tenant_id', $tenant->id)->active()->count()Project::where('tenant_id', $tenant->id)->count() (после сноса архива «активные» = все проекты тенанта).
    • ProjectController::index() — см. ниже. Проверить grep -rn "->active(" app/app после правок (должны остаться только scopeActiveOnDay/PricingTier/SupplierProject).
  • ProjectResource.php: удалить строку 'archived_at' => ....

  • ProjectController::index(): удалить ветку if ($status === 'archived'); для active/paused/default убрать вызовы ->active()/->archived() (фильтрация только по is_active).

  • DashboardController.php:77: убрать ->whereNull('archived_at').

  • SyncSupplierProjectsJob.php:89: убрать ->whereNull('archived_at').

  • Step 3: schema.sql + CHANGELOG

В db/schema.sql удалить строку archived_at TIMESTAMPTZ NULL, из CREATE TABLE projects; обновить header-комментарий → v8.27 (drop projects.archived_at). В db/CHANGELOG_schema.md добавить запись v8.27.

  • Step 4: Прогнать миграцию на dev + регрессия

Run:

C:/tools/php83/php.exe artisan migrate
C:/tools/php83/php.exe artisan test --filter=Project
composer stan

Expected: миграция OK; тесты зелёные; Larastan 0 (или обновить baseline, если всплыло legacy).

  • Step 5: Commit
git add -- app/app/Models/Project.php app/app/Http/Resources/ProjectResource.php app/app/Http/Controllers/Api/ProjectController.php app/app/Http/Controllers/Api/DashboardController.php app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/database/migrations/2026_05_21_000000_drop_projects_archived_at.php db/schema.sql db/CHANGELOG_schema.md
git commit -m "refactor(projects): remove archive feature, drop archived_at column (schema v8.27)"

Task 7: Фронтенд — «Архивировать» → «Удалить»

Files:

  • Modify: app/resources/js/stores/projectsStore.ts (archivedel, bulk type, archived_at из интерфейса)

  • Modify: app/resources/js/components/projects/BulkActionsBar.vue

  • Modify: app/resources/js/components/projects/ProjectCard.vue

  • Modify: app/resources/js/components/projects/ProjectDetailsDrawer.vue

  • Modify: app/resources/js/views/ProjectsView.vue (фильтр «Архивные», @archive@delete)

  • Test: app/resources/js/stores/projectsStore.spec.ts (или существующий) + затронутые spec'и

  • Step 1: Падающий тест стора

В spec проверить, что метод удаления дёргает DELETE /api/projects/{id}:

it('delete() calls DELETE /api/projects/{id}', async () => {
    const store = useProjectsStore();
    vi.spyOn(axios, 'delete').mockResolvedValue({ data: null });
    vi.spyOn(axios, 'get').mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
    await store.del(7);
    expect(axios.delete).toHaveBeenCalledWith('/api/projects/7');
});
  • Step 2: Запустить — FAIL

Run: npm --prefix app run test:vue -- projectsStore Expected: FAIL (del не существует).

  • Step 3: Реализация фронта

  • projectsStore.ts: переименовать archivedel (метод + return); в bulkAction/BulkPayload тип 'archive''delete'; убрать archived_at из interface Project.

  • BulkActionsBar.vue: кнопка «Архивировать»→«Удалить» (data-testid="bulk-delete", иконка mdi-delete→Lucide Trash2 через IconSet), confirm-текст про удаление; confirmAndRun('delete'); тип union 'pause'|'resume'|'delete'.

  • ProjectCard.vue: пункт меню «Архивировать»→«Удалить» (иконка mdi-delete), $emit('delete', project); emit-тип delete: [project: Project].

  • ProjectDetailsDrawer.vue: «Архивировать проект?»→«Удалить проект? Действие необратимо.»; store.del(props.project.id).

  • ProjectsView.vue: @archive@delete="(p) => store.del(p.id)"; убрать { title: 'Архивные', value: 'archived' } из фильтра статусов.

NB: иконки — через существующий IconSet mapping в plugins/vuetify.ts (mdi-deleteTrash2); если маппинга нет — добавить.

  • Step 4: Запустить — PASS + тип-чек + сборка

Run:

npm --prefix app run test:vue
npm --prefix app run type-check
npm --prefix app run build

Expected: тесты зелёные (обновить spec'и, где был archive/archived); type-check 0; build OK.

  • Step 5: Commit
git add -- app/resources/js/stores/projectsStore.ts app/resources/js/components/projects/BulkActionsBar.vue app/resources/js/components/projects/ProjectCard.vue app/resources/js/components/projects/ProjectDetailsDrawer.vue app/resources/js/views/ProjectsView.vue
git commit -m "feat(projects-ui): replace archive with delete, drop archived filter"

Task 8: Живая проверка всех 4 задач + чистка тестовых данных

Среда: portal serve :8000 + queue:work (запустить, если не идёт). Демо tenant 1 (admin@demo.local/password).

  • Шаг 1 (задача 4 — человеческие ошибки): создать проект с именем существующего → ожидать понятное 422-сообщение (не SQL). Создать проект с именем-дублем через UI/tinker POST → проверить тело ответа.
  • Шаг 2 (задача 3 — дедуп источника): в рамках tenant 1 создать 2-й проект на тот же signal_identifier → ожидать «У вас уже есть проект с этим источником…».
  • Шаг 3 (задача 2 — шеринг): под другим тенантом создать проект с тем же источником → проходит (демонстрация «ок»).
  • Шаг 4 (задача 1 — удаление): удалить пустой проект → исчез; удалить проект со сделкой → блок с сообщением, проект жив. Если есть привязка к донору и других потребителей нет → проверить, что DeleteSupplierProjectJob удалил донора у поставщика (или пере-синкнул при наличии других).
  • Шаг 5 (чистка): удалить все тестовые проекты/сделки/доноров, созданные в шагах 1–4; вернуть БД к чистому демо; зафиксировать в отчёте, что изменилось.

(Verify-skill: каждый шаг — реальный рантайм, capture результата; затем cleanup.)


Финальная регрессия (после всех задач)

Run (из корня/app/):

C:/tools/php83/php.exe artisan test
npm --prefix app run test:vue
npm --prefix app run type-check
npm --prefix app run build
composer pint && composer stan

Все зелёные → готово к push (git push origin <ветка>:main) по решению заказчика.