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

747 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<?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'ы:
```php
// перед Project::create($data):
$this->assertNameUnique($tenant->id, (string) $data['name']);
$this->assertSourceUnique($tenant->id, $data);
```
Добавить методы в класс:
```php
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**
```bash
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
<?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)` добавить:
```php
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**
```bash
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
<?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 { // });` на:
```php
->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**
```bash
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 `archive``delete`)
- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (`destroy``delete`)
- Modify: `app/app/Http/Requests/BulkProjectActionRequest.php` (`archive``delete`)
- Test: `app/tests/Feature/Project/ProjectDeleteTest.php`
- [ ] **Step 1: Падающие тесты**
```php
<?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()`:
```php
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' => ...` заменить на:
```php
'delete' => $this->bulkDelete($query),
```
Добавить `bulkDelete` (guard per-project, не роняет батч):
```php
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**
```bash
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
<?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
<?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**
```bash
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
<?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**
```bash
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` (`archive``del`, 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}`:
```ts
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`: переименовать `archive``del` (метод + 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-delete``Trash2`); если маппинга нет — добавить.
- [ ] **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**
```bash
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`) по решению заказчика.