docs(plan): project delete + source dedup + human errors implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,746 @@
|
||||
# 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`) по решению заказчика.
|
||||
Reference in New Issue
Block a user