Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c350572df | |||
| 22e81cc896 | |||
| 3bbd7787d8 | |||
| 07d73870ba | |||
| 7408bc4232 | |||
| 9d68fc0ad6 | |||
| e2fb20ef05 | |||
| 5427cdc740 | |||
| f3250ce178 | |||
| 472ea8c75c | |||
| b053796182 | |||
| 3b6992d8e9 | |||
| 233f9984fc | |||
| 4f5cf263f6 |
@@ -0,0 +1,6 @@
|
||||
# gitleaks false-positive allowlist (fingerprints).
|
||||
# Format: one fingerprint per line. `gitleaks detect --report-format json` outputs them.
|
||||
|
||||
# Nuclei docs `-u http://...` — nuclei's -u flag is "target URL", not curl basic-auth.
|
||||
# Rule `curl-auth-user` matches the pattern but it's not authentication.
|
||||
f696ca50266eb1c2974b5fc89f6fa585edaf4b6b:docs/security/nuclei-setup.md:curl-auth-user:27
|
||||
@@ -74,7 +74,6 @@ class DashboardController extends Controller
|
||||
// --- active projects ---
|
||||
$activeProjects = DB::table('projects')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('archived_at')
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
$maxProjects = (int) (($tenant->limits['max_projects'] ?? 0));
|
||||
|
||||
@@ -52,16 +52,12 @@ class ProjectController extends Controller
|
||||
|
||||
// Фильтр по статусу жизненного цикла
|
||||
$status = $request->query('status');
|
||||
if ($status === 'archived') {
|
||||
$query->archived();
|
||||
} elseif ($status === 'active') {
|
||||
$query->active()->where('is_active', true);
|
||||
if ($status === 'active') {
|
||||
$query->where('is_active', true);
|
||||
} elseif ($status === 'paused') {
|
||||
$query->active()->where('is_active', false);
|
||||
} else {
|
||||
// По умолчанию: все не архивированные (active + paused)
|
||||
$query->active();
|
||||
$query->where('is_active', false);
|
||||
}
|
||||
// default → no extra filter
|
||||
|
||||
// Поиск по name и signal_identifier
|
||||
if ($search = $request->query('search')) {
|
||||
@@ -111,11 +107,11 @@ class ProjectController extends Controller
|
||||
return response()->json(['data' => new ProjectResource($project)]);
|
||||
}
|
||||
|
||||
/** DELETE /api/projects/{id} — soft-archive (sets archived_at, is_active=false) */
|
||||
/** DELETE /api/projects/{id} — hard delete (guard по сделкам: 422 если есть сделки) */
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
$this->projects->archive($project);
|
||||
$this->projects->delete($project);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
@@ -139,7 +135,7 @@ class ProjectController extends Controller
|
||||
return response()->json(['data' => new ProjectResource($project->fresh())]);
|
||||
}
|
||||
|
||||
/** POST /api/projects/bulk — batch pause/resume/archive/update_regions/update_days/update_limit */
|
||||
/** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */
|
||||
public function bulk(BulkProjectActionRequest $request): JsonResponse
|
||||
{
|
||||
$tenantId = $request->user()->tenant_id;
|
||||
|
||||
@@ -20,7 +20,7 @@ class BulkProjectActionRequest extends FormRequest
|
||||
|
||||
$rules = [
|
||||
'action' => ['required', Rule::in([
|
||||
'pause', 'resume', 'archive',
|
||||
'pause', 'resume', 'delete',
|
||||
'update_regions', 'update_days', 'update_limit',
|
||||
])],
|
||||
'ids' => ['nullable', 'array', 'max:500'],
|
||||
@@ -28,7 +28,7 @@ class BulkProjectActionRequest extends FormRequest
|
||||
'scope' => ['nullable', 'array'],
|
||||
'scope.filter' => ['nullable', 'array'],
|
||||
'scope.filter.signal_type' => ['nullable', 'string', Rule::in(['site', 'call', 'sms'])],
|
||||
'scope.filter.status' => ['nullable', 'string', Rule::in(['active', 'paused', 'archived'])],
|
||||
'scope.filter.status' => ['nullable', 'string', Rule::in(['active', 'paused'])],
|
||||
'scope.filter.search' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
|
||||
@@ -13,9 +13,6 @@ class ProjectResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->resource;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
@@ -28,7 +25,6 @@ class ProjectResource extends JsonResource
|
||||
'delivered_today' => $this->delivered_today,
|
||||
'delivered_in_month' => $this->delivered_in_month,
|
||||
'is_active' => $this->is_active,
|
||||
'archived_at' => $project->archived_at?->toIso8601String(),
|
||||
'region_mask' => $this->region_mask,
|
||||
'region_mode' => $this->region_mode,
|
||||
'regions' => $this->regions,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?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;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Удаление/пере-синк доноров у поставщика после удаления Лидерра-проекта.
|
||||
*
|
||||
* Для каждого 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 string 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; // retry the job
|
||||
}
|
||||
}
|
||||
|
||||
$sp->delete();
|
||||
}
|
||||
|
||||
if ($needsResync) {
|
||||
SyncSupplierProjectsJob::dispatch();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ use Throwable;
|
||||
* (расписание перенесено 20:30 → 18:00, см. routes/console.php).
|
||||
*
|
||||
* Алгоритм (план 3 Task 5 → переработан: one-group-per-identifier):
|
||||
* 1. Загрузить активные Лидерра-projects (is_active=true, archived_at IS NULL).
|
||||
* 1. Загрузить активные Лидерра-projects (is_active=true).
|
||||
* 2. Сгруппировать по (signal_type, identifier) — БЕЗ subject_code:
|
||||
* - identifier = buildUniqueKeyAgnostic() (site/call → signal_identifier; sms+keyword → sender+keyword; sms → sender).
|
||||
* - platforms = resolvePlatforms() (site/call → B1+B2+B3; sms+keyword → B2+B3; sms → B3).
|
||||
@@ -86,7 +86,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
/** @var Collection<int, Project> $projects */
|
||||
$projects = Project::on(self::DB_CONNECTION)
|
||||
->where('is_active', true)
|
||||
->whereNull('archived_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
|
||||
@@ -40,8 +40,6 @@ class Project extends Model
|
||||
'tag',
|
||||
'type',
|
||||
'is_active',
|
||||
// Plan 5 Task 1 (schema v8.20): soft archive flow — lifecycle-state рядом с is_active.
|
||||
'archived_at',
|
||||
'daily_limit_target',
|
||||
'effective_daily_limit_today',
|
||||
'effective_limit_calculated_at',
|
||||
@@ -87,8 +85,6 @@ class Project extends Model
|
||||
'sms_senders' => 'array',
|
||||
'delivered_in_month' => 'integer',
|
||||
'delivered_today' => 'integer',
|
||||
// Plan 5 Task 1 (schema v8.20): soft archive.
|
||||
'archived_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -151,33 +147,6 @@ class Project extends Model
|
||||
return $query->where('signal_type', $signalType)->where('signal_identifier', $identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Не архивированные проекты (archived_at IS NULL).
|
||||
*
|
||||
* Внимание: scope не фильтрует is_active. Приостановленные (is_active=false)
|
||||
* проекты сюда попадают — это разные lifecycle-состояния. Если нужны только
|
||||
* «работающие» (не архив И не на паузе) — комбинируйте:
|
||||
* ->active()->where('is_active', true).
|
||||
*
|
||||
* @param Builder<Project> $query
|
||||
* @return Builder<Project>
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('archived_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Архивированные проекты (archived_at IS NOT NULL).
|
||||
*
|
||||
* @param Builder<Project> $query
|
||||
* @return Builder<Project>
|
||||
*/
|
||||
public function scopeArchived(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('archived_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Все связанные SupplierProject из eager-loaded BelongsTo отношений.
|
||||
*
|
||||
|
||||
@@ -4,10 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Project;
|
||||
|
||||
use App\Jobs\Supplier\DeleteSupplierProjectJob;
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProjectService
|
||||
{
|
||||
@@ -19,7 +21,6 @@ class ProjectService
|
||||
$data['tenant_id'], $data['signal_type'],
|
||||
$data['delivered_today'], $data['delivered_in_month'],
|
||||
$data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'],
|
||||
$data['archived_at'],
|
||||
);
|
||||
|
||||
if (isset($data['daily_limit_target']) && $data['daily_limit_target'] < $project->delivered_today) {
|
||||
@@ -41,6 +42,18 @@ class ProjectService
|
||||
|| array_key_exists('daily_limit_target', $data)
|
||||
|| array_key_exists('delivery_days_mask', $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);
|
||||
}
|
||||
|
||||
$project->update($data);
|
||||
|
||||
if ($needsResync) {
|
||||
@@ -50,17 +63,26 @@ class ProjectService
|
||||
return $project->fresh();
|
||||
}
|
||||
|
||||
public function archive(Project $project): void
|
||||
public function delete(Project $project): void
|
||||
{
|
||||
if ($project->archived_at !== null) {
|
||||
$hasDeals = DB::table('deals')->where('project_id', $project->id)->exists();
|
||||
if ($hasDeals) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'message' => 'Project уже архивирован.',
|
||||
], 409));
|
||||
'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 !== []) {
|
||||
DeleteSupplierProjectJob::dispatch(array_map('intval', $supplierProjectIds));
|
||||
}
|
||||
$project->update([
|
||||
'is_active' => false,
|
||||
'archived_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function triggerSync(Project $project): void
|
||||
@@ -83,9 +105,8 @@ class ProjectService
|
||||
}
|
||||
if (! empty($filter['status'])) {
|
||||
match ($filter['status']) {
|
||||
'active' => $query->where('is_active', true)->whereNull('archived_at'),
|
||||
'paused' => $query->where('is_active', false)->whereNull('archived_at'),
|
||||
'archived' => $query->whereNotNull('archived_at'),
|
||||
'active' => $query->where('is_active', true),
|
||||
'paused' => $query->where('is_active', false),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
@@ -108,7 +129,7 @@ class ProjectService
|
||||
return match ($action) {
|
||||
'pause' => $this->bulkSimpleUpdate($query, ['is_active' => false]),
|
||||
'resume' => $this->bulkSimpleUpdate($query, ['is_active' => true]),
|
||||
'archive' => $this->bulkSimpleUpdate($query, ['is_active' => false, 'archived_at' => now()]),
|
||||
'delete' => $this->bulkDelete($query),
|
||||
'update_regions' => $this->bulkUpdateRegions($query, $payload),
|
||||
'update_days' => $this->bulkUpdateDays($query, $payload),
|
||||
'update_limit' => $this->bulkUpdateLimit($query, $payload),
|
||||
@@ -122,6 +143,29 @@ class ProjectService
|
||||
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
|
||||
}
|
||||
|
||||
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' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan 6.5: субъект-уровневый bulk-edit `regions` INT[].
|
||||
*
|
||||
@@ -213,10 +257,60 @@ class ProjectService
|
||||
return ['updated' => $updated, 'skipped' => $skipped, 'warnings' => []];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
$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));
|
||||
}
|
||||
}
|
||||
|
||||
public function create(Tenant $tenant, array $data): Project
|
||||
{
|
||||
$limit = (int) ($tenant->limits['max_projects'] ?? 10);
|
||||
$current = Project::where('tenant_id', $tenant->id)->active()->count();
|
||||
$current = Project::where('tenant_id', $tenant->id)->count();
|
||||
if ($current >= $limit) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'message' => "Достигнут лимит проектов ({$limit}). Смените тариф.",
|
||||
@@ -230,6 +324,10 @@ class ProjectService
|
||||
// PhonePrefixService / LeadRouter, удаляются в Plan 6.5 после переключения читателей.
|
||||
$data['region_mask'] = 255;
|
||||
$data['region_mode'] = 'include';
|
||||
|
||||
$this->assertNameUnique($tenant->id, (string) $data['name']);
|
||||
$this->assertSourceUnique($tenant->id, $data);
|
||||
|
||||
$project = Project::create($data);
|
||||
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
|
||||
+17
-1
@@ -2,9 +2,12 @@
|
||||
|
||||
use App\Http\Middleware\EnsureSaasAdmin;
|
||||
use App\Http\Middleware\SetTenantContext;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -30,5 +33,18 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
$exceptions->render(function (QueryException $e, Request $request) {
|
||||
Log::error('db.query_exception', [
|
||||
'message' => $e->getMessage(),
|
||||
'sql' => $e->getSql(),
|
||||
'path' => $request->path(),
|
||||
]);
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => 'Не удалось сохранить. Проверьте данные или попробуйте ещё раз.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return null; // default render for non-JSON
|
||||
});
|
||||
})->create();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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');
|
||||
}
|
||||
};
|
||||
@@ -258,6 +258,90 @@ parameters:
|
||||
count: 1
|
||||
path: app/Services/Supplier/SupplierProjectGrouping.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenDefineFunctions not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenFinalClasses not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenNormalClasses not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenPrivateMethods not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenTraits not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\SyntaxCheck not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Metrics\\Architecture\\Classes not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\Commenting\\UselessFunctionDocCommentSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\Namespaces\\AlphabeticallySortedUsesSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DeclareStrictTypesSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DisallowMixedTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ParameterTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\PropertyTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ReturnTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\BalanceTransactionFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\BalanceTransaction, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\BalanceTransaction\>\:\:definition\(\)$#'
|
||||
identifier: method.childReturnType
|
||||
@@ -1572,6 +1656,12 @@ parameters:
|
||||
count: 14
|
||||
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/QueryExceptionRenderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1848,6 +1938,12 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/DeleteSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -2015,3 +2111,21 @@ parameters:
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 6
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:fail\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Symfony\\Component\\HttpFoundation\\Response\:\:getData\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
@@ -29,11 +29,11 @@
|
||||
|
||||
<v-btn
|
||||
color="error"
|
||||
prepend-icon="mdi-archive"
|
||||
data-testid="bulk-archive"
|
||||
@click="confirmAndRun('archive')"
|
||||
prepend-icon="mdi-delete"
|
||||
data-testid="bulk-delete"
|
||||
@click="confirmAndRun('delete')"
|
||||
>
|
||||
Архивировать
|
||||
Удалить
|
||||
</v-btn>
|
||||
|
||||
<v-spacer />
|
||||
@@ -92,11 +92,10 @@ const skipToastText = ref('');
|
||||
const messages: Record<string, string> = {
|
||||
pause: 'Приостановить выбранные проекты?',
|
||||
resume: 'Возобновить выбранные проекты?',
|
||||
archive:
|
||||
'Архивировать выбранные проекты?\nДействие необратимо в Plan 5 (восстановление потребует ручного запроса).',
|
||||
delete: 'Удалить выбранные проекты? Действие необратимо. Проекты со сделками будут пропущены.',
|
||||
};
|
||||
|
||||
async function confirmAndRun(action: 'pause' | 'resume' | 'archive') {
|
||||
async function confirmAndRun(action: 'pause' | 'resume' | 'delete') {
|
||||
if (!window.confirm(messages[action])) return;
|
||||
await runBulk({ action });
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ const base = {
|
||||
daily_limit_target: 50,
|
||||
delivered_today: 32,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@
|
||||
<template #prepend><v-icon>mdi-refresh</v-icon></template>
|
||||
<v-list-item-title>Синхронизировать</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="$emit('archive', project)">
|
||||
<template #prepend><v-icon>mdi-archive</v-icon></template>
|
||||
<v-list-item-title>Архивировать</v-list-item-title>
|
||||
<v-list-item @click="$emit('delete', project)">
|
||||
<template #prepend><v-icon>mdi-delete</v-icon></template>
|
||||
<v-list-item-title>Удалить</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
@@ -97,7 +97,7 @@ defineEmits<{
|
||||
edit: [project: Project];
|
||||
'toggle-active': [project: Project];
|
||||
'sync-now': [project: Project];
|
||||
archive: [project: Project];
|
||||
delete: [project: Project];
|
||||
}>();
|
||||
|
||||
const typeLabel = computed(() => ({ site: 'Сайт', call: 'Звонок', sms: 'СМС' })[props.project.signal_type]);
|
||||
|
||||
@@ -63,10 +63,10 @@ async function onPause(): Promise<void> {
|
||||
async function onDelete(): Promise<void> {
|
||||
if (!props.project) return;
|
||||
const ok = window.confirm(
|
||||
'Архивировать проект? Действие необратимо в Plan 5 (восстановление потребует ручного запроса).',
|
||||
'Удалить проект? Действие необратимо. Если по проекту есть сделки — удаление будет заблокировано.',
|
||||
);
|
||||
if (!ok) return;
|
||||
await store.archive(props.project.id);
|
||||
await store.del(props.project.id);
|
||||
emit('close');
|
||||
}
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ const lucideMap: Record<string, Component> = {
|
||||
'mdi-content-save-outline': Save,
|
||||
'mdi-credit-card-outline': CreditCard,
|
||||
'mdi-currency-rub': RussianRuble,
|
||||
'mdi-delete': Trash2,
|
||||
'mdi-delete-outline': Trash2,
|
||||
'mdi-dots-vertical': MoreVertical,
|
||||
'mdi-download': Download,
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface Project {
|
||||
delivered_today: number;
|
||||
delivered_in_month?: number;
|
||||
is_active: boolean;
|
||||
archived_at: string | null;
|
||||
region_mask?: number;
|
||||
region_mode?: string;
|
||||
regions?: number[]; // Plan 6 — subject codes 1..89; пустой массив = вся РФ
|
||||
@@ -65,7 +64,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async function archive(id: number) {
|
||||
async function del(id: number) {
|
||||
await axios.delete(`/api/projects/${id}`);
|
||||
await fetch();
|
||||
}
|
||||
@@ -94,7 +93,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
selectedIds.value.clear();
|
||||
}
|
||||
|
||||
async function bulkAction(action: 'pause' | 'resume' | 'archive') {
|
||||
async function bulkAction(action: 'pause' | 'resume' | 'delete') {
|
||||
const ids = Array.from(selectedIds.value);
|
||||
if (!ids.length) return;
|
||||
await axios.post('/api/projects/bulk', { action, ids });
|
||||
@@ -103,7 +102,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
}
|
||||
|
||||
interface BulkPayload {
|
||||
action: 'pause' | 'resume' | 'archive' | 'update_regions' | 'update_days' | 'update_limit';
|
||||
action: 'pause' | 'resume' | 'delete' | 'update_regions' | 'update_days' | 'update_limit';
|
||||
add?: number;
|
||||
remove?: number;
|
||||
// Plan 6.5 — update_regions оперирует кодами субъектов (1..89), не bitmask ФО.
|
||||
@@ -200,7 +199,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
fetch,
|
||||
create,
|
||||
update,
|
||||
archive,
|
||||
del,
|
||||
syncNow,
|
||||
toggleActive,
|
||||
toggleSelect,
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
@edit="openEdit"
|
||||
@toggle-active="store.toggleActive"
|
||||
@sync-now="(p: Project) => store.syncNow(p.id)"
|
||||
@archive="(p: Project) => store.archive(p.id)"
|
||||
@delete="(p: Project) => store.del(p.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,6 @@ const typeFilters = [
|
||||
const statusFilters = [
|
||||
{ title: 'Активные', value: 'active' },
|
||||
{ title: 'На паузе', value: 'paused' },
|
||||
{ title: 'Архивные', value: 'archived' },
|
||||
];
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -25,7 +25,6 @@ const sampleProject = {
|
||||
daily_limit_target: 50,
|
||||
delivered_today: 12,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
region_mask: 0,
|
||||
region_mode: 'include' as const,
|
||||
|
||||
@@ -94,12 +94,11 @@ it('conversion = доля статуса won в окне', function () {
|
||||
->assertJsonPath('conversion.value', 25);
|
||||
});
|
||||
|
||||
it('active_projects считает archived_at IS NULL AND is_active=true + limit из limits', function () {
|
||||
it('active_projects считает is_active=true + limit из limits', function () {
|
||||
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now(), 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => false]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]);
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('active_projects.active', 2)
|
||||
|
||||
@@ -6,28 +6,34 @@ use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(fn () => Queue::fake());
|
||||
|
||||
it('destroy archives project (sets archived_at, is_active=false)', function () {
|
||||
it('destroy hard-deletes a project with no deals', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
|
||||
$this->actingAs($user)->deleteJson("/api/projects/{$project->id}")->assertNoContent();
|
||||
|
||||
$project->refresh();
|
||||
expect($project->is_active)->toBeFalse();
|
||||
expect($project->archived_at)->not->toBeNull();
|
||||
expect(Project::find($project->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('destroy returns 409 if already archived', function () {
|
||||
it('destroy returns 422 if project has deals', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
DB::table('deals')->insert([
|
||||
'tenant_id' => $tenant->id, 'project_id' => $project->id,
|
||||
'phone' => '79990001100', 'status' => 'new',
|
||||
'received_at' => now(), 'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->deleteJson("/api/projects/{$project->id}")->assertStatus(409);
|
||||
$this->actingAs($user)->deleteJson("/api/projects/{$project->id}")->assertStatus(422);
|
||||
|
||||
expect(Project::find($project->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('sync re-dispatches SyncSupplierProjectJob', function () {
|
||||
@@ -81,16 +87,16 @@ it('bulk filters out cross-tenant ids silently', function () {
|
||||
expect($pB->fresh()->is_active)->toBeTrue();
|
||||
});
|
||||
|
||||
it('bulk archive sets archived_at on multiple', function () {
|
||||
it('bulk delete removes project with no deals', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$p1 = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$this->actingAs($user)->postJson('/api/projects/bulk', [
|
||||
'action' => 'archive', 'ids' => [$p1->id],
|
||||
])->assertOk();
|
||||
'action' => 'delete', 'ids' => [$p1->id],
|
||||
])->assertOk()->assertJsonPath('updated', 1);
|
||||
|
||||
expect($p1->fresh()->archived_at)->not->toBeNull();
|
||||
expect(Project::find($p1->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('bulk rejects > 500 ids', function () {
|
||||
|
||||
@@ -16,7 +16,7 @@ it('returns paginated list of active projects for current tenant', function () {
|
||||
$response->assertOk();
|
||||
$response->assertJsonStructure([
|
||||
'data' => [['id', 'name', 'signal_type', 'signal_identifier', 'daily_limit_target',
|
||||
'delivered_today', 'is_active', 'archived_at', 'sync_status']],
|
||||
'delivered_today', 'is_active', 'sync_status']],
|
||||
'meta' => ['current_page', 'per_page', 'total'],
|
||||
]);
|
||||
expect($response->json('meta.total'))->toBe(3);
|
||||
@@ -45,23 +45,24 @@ it('isolates projects per tenant (RLS)', function () {
|
||||
expect($response->json('meta.total'))->toBe(2);
|
||||
});
|
||||
|
||||
it('excludes archived projects by default', function () {
|
||||
it('returns all projects by default (archive feature removed in v8.27)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/projects');
|
||||
|
||||
expect($response->json('meta.total'))->toBe(1);
|
||||
expect($response->json('meta.total'))->toBe(2);
|
||||
});
|
||||
|
||||
it('returns archived when status=archived requested', function () {
|
||||
it('status=active returns only is_active=true projects', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/projects?status=archived');
|
||||
$response = $this->actingAs($user)->getJson('/api/projects?status=active');
|
||||
|
||||
expect($response->json('meta.total'))->toBe(1);
|
||||
});
|
||||
@@ -140,19 +141,18 @@ it('search is case-insensitive for Cyrillic substrings', function () {
|
||||
expect($partial->json('meta.total'))->toBe(1);
|
||||
});
|
||||
|
||||
it('show returns 200 for archived project (read access preserved)', function () {
|
||||
it('show returns 200 for any project by id', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'archived_at' => now(),
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'archived.ru',
|
||||
'signal_identifier' => 'myproject.ru',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson("/api/projects/{$project->id}");
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.id'))->toBe($project->id);
|
||||
expect($response->json('data.archived_at'))->not->toBeNull();
|
||||
expect($response->json('data'))->not->toHaveKey('archived_at');
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
@@ -11,15 +10,6 @@ use Illuminate\Support\Facades\Schema;
|
||||
// DatabaseTransactions — изоляция; also ensures DB connection is bootstrapped.
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('projects table has archived_at column nullable timestamp', function () {
|
||||
expect(Schema::hasColumn('projects', 'archived_at'))->toBeTrue();
|
||||
$type = Schema::getColumnType('projects', 'archived_at');
|
||||
// PostgreSQL TIMESTAMPTZ → Doctrine/Laravel reports 'timestamptz' (not 'timestamp').
|
||||
expect($type)->toBe('timestamptz');
|
||||
});
|
||||
|
||||
it('Project model has archived_at in fillable and casts it to datetime', function () {
|
||||
$project = new Project;
|
||||
expect(in_array('archived_at', $project->getFillable(), true))->toBeTrue();
|
||||
expect($project->getCasts()['archived_at'] ?? null)->toBe('datetime');
|
||||
it('projects table does NOT have archived_at column (feature removed in v8.27)', function () {
|
||||
expect(Schema::hasColumn('projects', 'archived_at'))->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Project\ProjectService;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
$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(ProjectService::class)->create($this->tenant, makeCall());
|
||||
expect(fn () => app(ProjectService::class)
|
||||
->create($this->tenant, makeCall(['name' => 'Проект B'])))
|
||||
->toThrow(HttpResponseException::class);
|
||||
});
|
||||
|
||||
it('allows same source for a different tenant (sharing)', function () {
|
||||
$other = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
app(ProjectService::class)->create($this->tenant, makeCall());
|
||||
$p = app(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(ProjectService::class)->create($this->tenant, makeCall());
|
||||
try {
|
||||
app(ProjectService::class)
|
||||
->create($this->tenant, makeCall(['name' => 'Проект A', 'signal_identifier' => '79992220000']));
|
||||
$this->fail('expected HttpResponseException');
|
||||
} catch (HttpResponseException $e) {
|
||||
$body = $e->getResponse()->getData(true);
|
||||
expect($body['errors']['name'][0] ?? '')->not->toContain('SQLSTATE');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Project\ProjectService;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(fn () => Queue::fake());
|
||||
|
||||
it('hard-deletes an empty project', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = app(ProjectService::class)->create($tenant, [
|
||||
'name' => 'Empty', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
|
||||
'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
|
||||
]);
|
||||
|
||||
app(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(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(ProjectService::class)->delete($project))
|
||||
->toThrow(HttpResponseException::class);
|
||||
expect(Project::find($project->id))->not->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Project\ProjectService;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(fn () => Queue::fake());
|
||||
|
||||
it('blocks update that collides source with another project of same tenant', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$svc = app(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(HttpResponseException::class);
|
||||
});
|
||||
|
||||
it('allows update keeping same source on the same project', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$svc = app(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);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
it('renders QueryException as human JSON message, not SQLSTATE', function () {
|
||||
Route::get('/_test/boom-query', function () {
|
||||
throw new 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('Не удалось');
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
<?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\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('deletes donor at supplier when no consumers remain', function (): void {
|
||||
$sp = SupplierProject::query()->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 (): void {
|
||||
Bus::fake([SyncSupplierProjectsJob::class]);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79991110001',
|
||||
'supplier_external_id' => '556', '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,
|
||||
'platform' => 'B1',
|
||||
'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);
|
||||
});
|
||||
@@ -57,7 +57,6 @@ test('single-group: regions=[82,83] site → merged regions tag=РФ → 3 suppl
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'persubject.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
@@ -116,7 +115,6 @@ test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 suppl
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'rf-pool.example.com',
|
||||
'daily_limit_target' => 6,
|
||||
@@ -167,7 +165,6 @@ test('order: 2 projects same source×subject → computeOrder(limits=[10,20])
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'order-test.example.com',
|
||||
'daily_limit_target' => 10,
|
||||
@@ -178,7 +175,6 @@ test('order: 2 projects same source×subject → computeOrder(limits=[10,20])
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'order-test.example.com',
|
||||
'daily_limit_target' => 20,
|
||||
@@ -229,7 +225,6 @@ test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', functi
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'sms',
|
||||
'signal_identifier' => null,
|
||||
'sms_senders' => ['79001234567'],
|
||||
@@ -271,7 +266,6 @@ test('sms without keyword → platform B3 only (1 supplier_project)', function (
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'sms',
|
||||
'signal_identifier' => null,
|
||||
'sms_senders' => ['79009876543'],
|
||||
@@ -314,7 +308,6 @@ test('idempotent: repeat run with no changes → updateProject not duplicate', f
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'idempotent.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
@@ -375,7 +368,6 @@ test('respects time budget by stopping at 20:55 МСК', function (): void {
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'time-budget.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
@@ -397,7 +389,6 @@ test('sticky auth error throws and sends critical alert email', function (): voi
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'auth-fail.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
@@ -425,7 +416,6 @@ test('aborts after 50 consecutive transient failures and sends alert', function
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => "host{$i}.abort.com",
|
||||
'daily_limit_target' => 9,
|
||||
@@ -449,7 +439,6 @@ test('writes supplier_sync_log row for each successful action', function (): voi
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'audit-log.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('BulkActionsBar — extended', () => {
|
||||
expect((wrapper.vm as unknown as { regionsOpen: boolean }).regionsOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps existing pause/resume/archive buttons', async () => {
|
||||
it('keeps existing pause/resume/delete buttons', async () => {
|
||||
setActivePinia(createPinia());
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: { updated: 1, skipped: [], warnings: [] } });
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
|
||||
@@ -184,6 +184,6 @@ describe('BulkActionsBar — extended', () => {
|
||||
});
|
||||
expect(wrapper.find('[data-testid="bulk-pause"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="bulk-resume"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="bulk-archive"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="bulk-delete"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,6 @@ const sampleProject = {
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
region_mask: 0,
|
||||
region_mode: 'include',
|
||||
|
||||
@@ -12,7 +12,6 @@ const baseProject = {
|
||||
daily_limit_target: 50,
|
||||
delivered_today: 32,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ const sample = {
|
||||
is_active: true,
|
||||
daily_limit_target: 50,
|
||||
delivered_today: 12,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ const sampleProject: Project = {
|
||||
daily_limit_target: 30,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
region_mask: 0,
|
||||
region_mode: 'include',
|
||||
regions: [],
|
||||
@@ -152,10 +151,10 @@ describe('ProjectDetailsDrawer', () => {
|
||||
expect(wrapper.get('[data-testid="pdd-pause"]').text()).toContain('Приостановить');
|
||||
});
|
||||
|
||||
it('Delete: confirm=true → archive + close emit', async () => {
|
||||
it('Delete: confirm=true → del + close emit', async () => {
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
|
||||
const store = useProjectsStore();
|
||||
const spy = vi.spyOn(store, 'archive').mockResolvedValueOnce(undefined);
|
||||
const spy = vi.spyOn(store, 'del').mockResolvedValueOnce(undefined);
|
||||
vi.stubGlobal('confirm', () => true);
|
||||
|
||||
await wrapper.get('[data-testid="pdd-delete"]').trigger('click');
|
||||
@@ -166,10 +165,10 @@ describe('ProjectDetailsDrawer', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('Delete: confirm=false → no archive, no close', async () => {
|
||||
it('Delete: confirm=false → no del, no close', async () => {
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
|
||||
const store = useProjectsStore();
|
||||
const spy = vi.spyOn(store, 'archive').mockResolvedValueOnce(undefined);
|
||||
const spy = vi.spyOn(store, 'del').mockResolvedValueOnce(undefined);
|
||||
vi.stubGlobal('confirm', () => false);
|
||||
|
||||
await wrapper.get('[data-testid="pdd-delete"]').trigger('click');
|
||||
|
||||
@@ -52,7 +52,6 @@ describe('ProjectsView', () => {
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok',
|
||||
},
|
||||
],
|
||||
@@ -82,7 +81,6 @@ describe('ProjectsView', () => {
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok',
|
||||
},
|
||||
{
|
||||
@@ -93,7 +91,6 @@ describe('ProjectsView', () => {
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok',
|
||||
},
|
||||
],
|
||||
@@ -127,7 +124,6 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
const projectB = {
|
||||
@@ -138,7 +134,6 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ const mountView = async () => {
|
||||
daily_limit_target: 100,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
region_mask: 1,
|
||||
region_mode: 'include',
|
||||
delivery_days_mask: 31,
|
||||
@@ -34,7 +33,6 @@ const mountView = async () => {
|
||||
daily_limit_target: 100,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
region_mask: 1,
|
||||
region_mode: 'include',
|
||||
delivery_days_mask: 31,
|
||||
|
||||
@@ -66,6 +66,14 @@ describe('projectsStore (no polling)', () => {
|
||||
expect(store.selectedIds.has(1)).toBe(false);
|
||||
});
|
||||
|
||||
it('del() 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');
|
||||
});
|
||||
|
||||
it('bulkAction sends array of ids and clears selection', async () => {
|
||||
(axios.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({ data: { updated: 2 } });
|
||||
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
@@ -104,7 +112,6 @@ describe('projectsStore (polling)', () => {
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
region_mask: 0,
|
||||
region_mode: 'include',
|
||||
delivery_days_mask: 127,
|
||||
@@ -133,7 +140,6 @@ describe('projectsStore (polling)', () => {
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
region_mask: 0,
|
||||
region_mode: 'include',
|
||||
delivery_days_mask: 127,
|
||||
|
||||
@@ -1579,3 +1579,12 @@ lemed
|
||||
побочек
|
||||
диффы
|
||||
ретрофилл
|
||||
|
||||
# project delete / dedup / errors spec (2026-05-21)
|
||||
шеринг
|
||||
шеринга
|
||||
констрейнт
|
||||
дропается
|
||||
батч
|
||||
ретраит
|
||||
шеринге
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
**История записей:**
|
||||
|
||||
## v8.27 — 2026-05-21 — DROP COLUMN projects.archived_at
|
||||
|
||||
- DROP COLUMN `projects.archived_at` — фича «архив» полностью убрана и заменена настоящим удалением с защитой по сделкам (`ProjectService::delete()`). Миграция `2026_05_21_000000_drop_projects_archived_at.php`.
|
||||
|
||||
## v8.26 — 2026-05-20 — supplier_projects.subject_code (per-субъект экспорт)
|
||||
|
||||
`supplier_projects` +1 колонка `subject_code SMALLINT NULL` (1..89; NULL = пул «Вся РФ»),
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||||
-- Версия: v8.26 (20.05.2026 — project-migration-redesign Plans 1-3: supplier_projects.subject_code (per-субъект экспорт) + project_supplier_links (M:N pivot projects↔supplier_projects) + deals.subject_code + CHECK chk_deals_subject_code + seed system_settings.supplier_export_mode)
|
||||
-- Версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete()))
|
||||
-- Метрики: 65 базовые таблицы (63 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 123 индекса / 40 RLS-политик / 5 функций / 13 триггеров
|
||||
-- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
|
||||
-- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2))
|
||||
@@ -840,7 +840,6 @@ CREATE TABLE projects (
|
||||
CHECK (ttfr_target_minutes BETWEEN 1 AND 1440),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
archived_at TIMESTAMPTZ NULL, -- v8.20 (Plan 5): soft archive flow (отличие от is_active=false который = pause)
|
||||
UNIQUE (tenant_id, name),
|
||||
CONSTRAINT chk_projects_daily_limit_positive
|
||||
CHECK (daily_limit_target > 0),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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`) по решению заказчика.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Удаление проектов вместо архива + дедуп источника + человеческие ошибки
|
||||
|
||||
**Дата:** 2026-05-21
|
||||
**Статус:** утверждён (заказчик «ок делай»)
|
||||
**Ветка исполнения:** worktree от `feat/project-migration-redesign` (origin/main)
|
||||
|
||||
## Контекст и проблема
|
||||
|
||||
Заказчик при создании проекта получил сырой `SQLSTATE[23505] Unique violation ...
|
||||
projects_tenant_id_name_key` в UI. Разбор вскрыл 4 связанных задачи:
|
||||
|
||||
1. **Архивация бессмысленна** — если клиенту проект не нужен, хранить его незачем.
|
||||
Сейчас «удаление» (`DELETE /api/projects/{id}`) = мягкая архивация
|
||||
(`ProjectService::archive` → `archived_at`, `is_active=false`). Нужно настоящее удаление.
|
||||
2. **Шеринг между клиентами** — два разных клиента могут завести проекты с одинаковыми
|
||||
параметрами/источником. Это корректно (модель шеринга: один донор раздаётся ≤3 клиентам
|
||||
через `LeadRouter`). Менять не нужно — подтвердить.
|
||||
3. **Дедуп источника внутри клиента отсутствует** — один клиент может завести 2 проекта на
|
||||
один источник (номер/домен/SMS-отправитель). Нужен запрет.
|
||||
4. **Утечка SQL в UI** — `Project::create()` бьётся о DB-констрейнт без перехвата → сырой
|
||||
`SQLSTATE` рендерится пользователю. Нужны человеческие сообщения.
|
||||
|
||||
## Ключевые факты кодовой базы (разведка)
|
||||
|
||||
- `deals.project_id` — **без FK** (таблица партиционирована, partition-wise FK не
|
||||
поддерживается). Hard-delete проекта НЕ каскадит и НЕ блокируется сделками → они «повиснут».
|
||||
Поэтому удаление проекта со сделками опасно → блокируем (см. Решение 1).
|
||||
- `ON DELETE CASCADE` на `projects(id)` имеют только служебные таблицы:
|
||||
`project_supplier_links`, `supplier_manual_sync_queue`, `project_suppliers`,
|
||||
`project_user_assignments`, `project_limit_adjustments`. Их каскад при удалении — норма.
|
||||
- Уникальность `projects` на DB-уровне: `projects_tenant_id_name_key` = `(tenant_id, name)`.
|
||||
Дедупа по источнику на DB-уровне нет.
|
||||
- Источник проекта: `signal_identifier` (call=телефон `7\d{10}`, site=домен) либо
|
||||
`sms_senders[]` (+ `sms_keyword`) для sms.
|
||||
- Шеринг донора: `project_supplier_links` (M:N projects↔supplier_projects) связывает
|
||||
Лидерра-проекты РАЗНЫХ тенантов с одним `supplier_project` (донором). Outbound-синк
|
||||
(`SyncSupplierProjectsJob`) считает агрегатный лимит по всем клиентам источника
|
||||
(`computeOrder = max(max, ceil(Σ/3))`, cap=3).
|
||||
- Архив-ссылки (под снос): backend — `ProjectController` (destroy/index/bulk),
|
||||
`ProjectService` (archive/bulk/update/resolveBulkScope), `Project` (scopeActive/scopeArchived,
|
||||
fillable+cast `archived_at`), `ProjectResource`, `DashboardController`,
|
||||
`SyncSupplierProjectsJob`, `BulkProjectActionRequest`. Frontend — `BulkActionsBar`,
|
||||
`ProjectCard`, `ProjectDetailsDrawer`, `ProjectsView`, `projectsStore`.
|
||||
|
||||
## Решения (утверждены заказчиком)
|
||||
|
||||
### Решение 1 — удаление вместо архива, с защитой по сделкам
|
||||
|
||||
- `DELETE /api/projects/{id}` → **hard delete** через новый `ProjectService::delete()`.
|
||||
- **Guard:** если по проекту есть хоть одна `deals` (любой статус, включая `deleted_at`-soft)
|
||||
→ удаление блокируется HTTP 422 (`{errors:{...}}`/`message`, формат фронта) с сообщением:
|
||||
*«Нельзя удалить проект: по нему есть сделки. Остановите приём (пауза), чтобы скрыть из
|
||||
работы».* Пустой проект (0 сделок) → удаляется насовсем.
|
||||
- Архивация убирается **полностью**: код-пути `archive`, scope `archived`, фильтр «Архивные»,
|
||||
bulk-action `archive`. Колонка `archived_at` дропается миграцией (schema bump). Пауза
|
||||
(`is_active`) сохраняется — это отдельный механизм.
|
||||
- Bulk: action `archive` → `delete` (с тем же guard'ом per-project; проекты со сделками
|
||||
попадают в `skipped` с причиной, не роняют весь батч).
|
||||
|
||||
### Решение 2 — удаление у поставщика с учётом шеринга
|
||||
|
||||
При удалении Лидерра-проекта P (тенант T) по источнику X (один или несколько доноров
|
||||
B1/B2/B3 через `project_supplier_links`):
|
||||
|
||||
1. Удаляем P локально (его `project_supplier_links` уходят каскадом).
|
||||
2. Для каждого затронутого `supplier_project` S (донора источника X):
|
||||
- Считаем оставшиеся `project_supplier_links` на S (проекты ДРУГИХ тенантов).
|
||||
- **Остались** → донор нужен другим клиентам → **пере-синк** S (агрегатный лимит/регионы/дни
|
||||
без T) через outbound-синк. У поставщика проект НЕ удаляем.
|
||||
- **Не осталось** (T был последним) → у поставщика **удаляем** донора
|
||||
(`SupplierPortalClient::deleteProject(external_id)`) + удаляем локальную запись
|
||||
`supplier_projects` S.
|
||||
3. Внешние вызовы к поставщику — через job (resilience, retry), не inline в HTTP-запросе.
|
||||
|
||||
Граничные сценарии:
|
||||
|
||||
- P не привязан ни к одному донору (например, синк ещё не прошёл) → шаг 2 пропускается.
|
||||
- Несколько доноров (B1/B2/B3) у одного источника → шаг 2 для каждого независимо.
|
||||
- Падение удаления у поставщика → job ретраит; локальное удаление P уже выполнено
|
||||
(eventual consistency; «висячий» донор у поставщика подметёт следующий синк/ретрай).
|
||||
|
||||
### Решение 3 — дедуп источника внутри клиента
|
||||
|
||||
- При создании и при изменении источника: внутри `tenant_id` источник должен быть уникален
|
||||
среди проектов клиента.
|
||||
- «Источник» (source key):
|
||||
- call/site → `signal_identifier`;
|
||||
- sms → нормализованный `sms_senders` (сортировка+lower) + `sms_keyword`.
|
||||
- Enforcement: app-level проверка в `ProjectService::create()`/`update()` → 422 с сообщением
|
||||
*«У вас уже есть проект с этим источником: "<название>"»*. После сноса архива «существующие
|
||||
проекты» = все проекты клиента (soft-deleted проектов нет — мы их hard-delete'им).
|
||||
- DB-уровень: partial unique index как защита-эшелон (опционально, в той же миграции);
|
||||
его нарушение перехватывается общим обработчиком (Решение 4) и не утекает.
|
||||
|
||||
### Решение 4 — человеческие ошибки вместо SQL
|
||||
|
||||
- App-level pre-checks (до DB): уникальность `name` в рамках клиента + уникальность источника
|
||||
(Решение 3) → 422 `{errors: {field: [msg]}}` (формат, который уже понимает фронт).
|
||||
- Глобальный перехват `Illuminate\Database\QueryException` в `bootstrap/app.php`
|
||||
(`withExceptions`): в лог — полный текст; пользователю — generic
|
||||
*«Не удалось сохранить. Проверьте данные или попробуйте ещё раз»* (HTTP 422 для JSON-запросов).
|
||||
Никакой `SQLSTATE` в UI.
|
||||
- Фронт `NewProjectDialog`/`projectsStore` — убедиться, что 422 `errors`/`message`
|
||||
показываются как есть (уже умеет; правок минимум).
|
||||
|
||||
## Затрагиваемые компоненты
|
||||
|
||||
**Backend**
|
||||
|
||||
- `ProjectService`: +`delete()` (guard сделок + оркестрация шеринга), −`archive()`,
|
||||
bulk `archive`→`delete`, чистка `archived_at`/`archived` из update/resolveBulkScope,
|
||||
+дедуп источника в create/update.
|
||||
- `ProjectController`: `destroy()` → `delete()`, index `status=archived` убрать, bulk doc.
|
||||
- `BulkProjectActionRequest`: `archive`→`delete`, status `archived` убрать.
|
||||
- `Project` (модель): −scopeArchived, scopeActive упростить/убрать, −`archived_at` fillable+cast.
|
||||
- `ProjectResource`: −`archived_at`.
|
||||
- `DashboardController`, `SyncSupplierProjectsJob`: убрать `whereNull('archived_at')`.
|
||||
- Новый job `DeleteSupplierProjectJob` (или расширение существующего) — удаление донора у
|
||||
поставщика, когда источник остался без потребителей.
|
||||
- Миграция: `DROP COLUMN projects.archived_at` (+ опц. partial unique index источника) →
|
||||
schema bump v8.27 + `db/CHANGELOG_schema.md`.
|
||||
- `bootstrap/app.php`: глобальный handler `QueryException`.
|
||||
|
||||
**Frontend**
|
||||
|
||||
- `BulkActionsBar`: «Архивировать»→«Удалить» (+подтверждение/иконка).
|
||||
- `ProjectCard`, `ProjectDetailsDrawer`: «Архивировать»→«Удалить».
|
||||
- `ProjectsView`: `@archive`→`@delete`, убрать фильтр «Архивные».
|
||||
- `projectsStore`: `archive()`→`delete()`, bulk `archive`→`delete`, тип `archived_at` убрать.
|
||||
|
||||
## Тестирование (TDD)
|
||||
|
||||
- Backend (Pest): delete пустого проекта → 204 + строки нет; delete со сделками → 409/422,
|
||||
проект жив; шеринг — delete последнего потребителя → донор удалён у поставщика (mock client);
|
||||
delete при оставшихся потребителях → донор НЕ удалён, пере-синк; дедуп источника create/update
|
||||
→ 422; имя-дубль → 422 (не SQL); глобальный QueryException handler → generic message.
|
||||
- Frontend (Vitest): кнопки «Удалить» вместо «Архивировать»; нет фильтра «Архивные»;
|
||||
store.delete дёргает DELETE; ошибка сервера показывается человеческим текстом.
|
||||
- Live («проверь на практике» по каждой задаче, затем чистка тестовых данных):
|
||||
1) удаление пустого проекта + блок на проекте со сделками;
|
||||
2) два тенанта с одинаковым источником — создание проходит;
|
||||
3) попытка дубля источника у одного тенанта — отказ с понятным текстом;
|
||||
4) создание дубля имени — человеческое сообщение, не SQL.
|
||||
|
||||
## Вне scope (YAGNI)
|
||||
|
||||
- Restore/корзина удалённых проектов (удаление окончательное по решению заказчика).
|
||||
- Массовая авто-чистка уже-архивированных проектов dev-БД (разовая ручная операция).
|
||||
- `StatusPill 'archived'` mapping не трогаем (используется и для статусов сделок).
|
||||
@@ -9,7 +9,7 @@
|
||||
**перепроверять реальной командой**, не доверять снимку вслепую.
|
||||
- Обновляется по команде заказчика **«обнови эталон»**.
|
||||
|
||||
**Снимок снят:** 21.05.2026 (ночь, после сквозного чек-листа всего портала + 6 фиксов: 3 stale эпик-теста под схему v8.26 + 3 UI-бага; запушено в main; volatile §1–§4 пересверены).
|
||||
**Снимок снят:** 21.05.2026 (день, после фичи «удаление проектов вместо архива + дедуп источника + человеческие ошибки» — 10 коммитов FF в main; volatile §1–§4 пересверены).
|
||||
|
||||
---
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
- Git-корень репозитория — папка `Документация/` (**не** `app/`).
|
||||
- Remote: `CoralMinister/lidpotok` (приватный).
|
||||
- Текущая локальная ветка: **`feat/project-migration-redesign`**.
|
||||
- Локальный HEAD = origin/main HEAD = **`31b5355`** (style(backend): pint concat_space — tip параллельного эпика A1 backend-tooling; мои 6 фиксов чек-листа ниже на `a0e18a1..b7466eb`; сверять `git log -1 origin/main`).
|
||||
- Push паттерн: `git push origin <ветка>:main`. Мой push 21.05: `a0e18a1..b7466eb` (4 коммита FF: `95ee664` sync stale эпик-тестов + schema header v8.26, `ba49805` dashboard greeting, `17e3c04` topbar title, `b7466eb` admin mock-счётчики; pre-push gitleaks-full 1119/0, lychee 64/0). **Поверх** параллельная сессия влила эпик A1 backend-tooling `b7466eb..31b5355` (8 коммитов: Rector #64 / PHP Insights #65 / laravel-backend-patterns skill #66 / #67 + ADR-013 + нормативка Tooling v2.19 / PSR v3.19 / Pravila v1.35 / CLAUDE v2.22 + карта 137→141 узлов) — детали в её памяти/нормативке.
|
||||
- Pre-push lefthook прошёл чисто (gitleaks-full 1115/0, lychee 64/0) — `--no-verify` не понадобился.
|
||||
- Локальный HEAD = origin/main HEAD = **`22e81cc`** (chore(gitleaks): allowlist Nuclei docs false-positive — tail коммит моего эпика «удаление проектов»; сверять `git log -1 origin/main`).
|
||||
- Push паттерн: `git push origin <ветка>:main`. Мой push 21.05 (день): `3b6992d..22e81cc` (10 коммитов FF — спека+план+7 имплементаций+gitleaks-allowlist); pre-push gitleaks-full 1155/0, lychee 64/0.
|
||||
- Pre-push lefthook прошёл чисто после добавления `.gitleaksignore` (Nuclei docs `-u http://...` ловился rule `curl-auth-user` — false-positive из параллельной ветки `worktree-a8-infosec-tooling`, добавлен fingerprint).
|
||||
- **Незакоммиченного нет** (фикс + тест запушены).
|
||||
- Прочее незакоммиченное: `docs/observer/STATUS.md` + `episodes-2026-05.jsonl` (hook-артефакты brain governance, не мои); untracked artifacts (см. §4).
|
||||
- Остатки от rebase (безопасно): `stash@{0}` с не-моими hook/parallel-артефактами (+5 других parallel-стэшей); `/tmp/plan4-rebase-bak/` — устаревшие untracked-копии 2 observer-файлов (committed-версии origin/main авторитетнее).
|
||||
@@ -74,10 +74,11 @@
|
||||
## 5. Ключевые факты (стабильно)
|
||||
|
||||
- Структура: git-корень `Документация/` · Laravel-приложение `app/` · фронтенд `app/resources/js/`
|
||||
· сборка фронтенда → `app/public/build/` · схема БД `db/schema.sql` (**фактическая v8.26** —
|
||||
Plans 1+3 эпика project-migration-redesign добавили `supplier_projects.subject_code` +
|
||||
· сборка фронтенда → `app/public/build/` · схема БД `db/schema.sql` (**фактическая v8.27** —
|
||||
21.05.2026 дропнута колонка `projects.archived_at` (фича архива заменена настоящим удалением);
|
||||
v8.26 наследие — Plans 1+3 эпика project-migration-redesign: `supplier_projects.subject_code` +
|
||||
`project_supplier_links` pivot + `deals.subject_code` + seed `supplier_export_mode` + CHECK chk_deals_subject_code.
|
||||
Schema header синхронизирован на v8.26 (65 таблиц / 123 индекса, commit `95ee664`); CHANGELOG_schema.md содержит v8.26 entries).
|
||||
Header v8.27 в commit `07d7387`; CHANGELOG_schema.md содержит v8.27 + v8.26 entries).
|
||||
- Стек: Laravel 13 + PHP 8.3 · Vue 3 + Vuetify 3 (не Tailwind/Inertia) · PostgreSQL 16 · Redis.
|
||||
- **Демо-доступ к порталу:** 5 изолированных компаний — `admin@demo.local` (Demo Tenant, 4 проекта) + `manager1@demo.local` (Компания Ивана) + `manager2@demo.local` (Компания Анны) + `manager3@demo.local` (Компания Петра) + `manager4@demo.local` (Компания Марии). Пароль у всех **`password`**. Каждый логин видит только своё. Админка `/admin/*` в local открыта любому залогиненному (`EnsureSaasAdmin` — стаб local/testing).
|
||||
- Поставщик лидов: `crm.bp-gr.ru` (учётка в `.env` `SUPPLIER_*`); портал — Vue 2 + Element UI;
|
||||
@@ -87,6 +88,19 @@
|
||||
|
||||
## 6. Текущие рабочие нити (детали — в памяти Claude)
|
||||
|
||||
- **Удаление проектов вместо архива + дедуп источника + человеческие ошибки — DONE+ЗАПУШЕНО** (21.05.2026 день,
|
||||
`3b6992d..22e81cc`, 10 коммитов FF в main). По заказу: (1) кнопка «архивировать» заменена на настоящее удаление
|
||||
(если по проекту есть сделки — блок с понятным сообщением, иначе hard delete + удаление донора у поставщика
|
||||
с учётом шеринга — пере-синк остатка, если другие клиенты ещё пользуются источником); (2) подтверждена возможность
|
||||
шеринга одинаковых источников между разными клиентами (модель не меняется); (3) добавлен запрет дубля источника
|
||||
внутри одного клиента (раньше не было); (4) сырой `SQLSTATE` больше не уходит в UI — глобальный handler
|
||||
`QueryException` + app-level pre-checks (имя/источник) с понятными сообщениями. Колонка `projects.archived_at`
|
||||
и все `scopeActive/scopeArchived/->archived()` снесены, миграция `2026_05_21_000000`, schema bump v8.26→**v8.27**.
|
||||
Новый job `App\Jobs\Supplier\DeleteSupplierProjectJob` — удаление/пере-синк донора при шеринге.
|
||||
Регрессия: Pest filter=Project **229/228+1sk/0**, Vitest **896/3sk/0**, vue-tsc 0, Vite build OK, Pint/Larastan 0.
|
||||
Живая проверка 5 сценариев (имя/источник дубль / sharing / удаление пустого / блок при сделках) — все ✓ с чисткой.
|
||||
Спека: `docs/superpowers/specs/2026-05-21-project-delete-dedup-errors-design.md`, план:
|
||||
`docs/superpowers/plans/2026-05-21-project-delete-dedup-errors.md`. Память: `project_delete_dedup_errors.md`.
|
||||
- **Сквозной чек-лист всего портала + 6 фиксов — ЗАПУШЕНО** (21.05.2026, `a0e18a1..b7466eb`). Прогон всех ~30 экранов
|
||||
(Playwright) + полная регрессия (Pest 1010/0, Vitest 895/0, pint/larastan/vue-tsc/build чисто). Портал здоров: все
|
||||
экраны рендерятся, консоль чистая. Найдено и исправлено 3 UI-бага: (1) дашборд здоровался захардкоженным «Доброе утро,
|
||||
|
||||
Reference in New Issue
Block a user