feat(projects): source-lock state для UI (guard.lockState + ProjectResource + анти-N+1)
SupplierSnapshotGuard::lockState (pure, без DB) + ProjectResource отдаёт source_locked/source_unlock_at/source_unlock_projected; ProjectController withCount(supplierProjects). Логика гарда не изменена. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ class ProjectController extends Controller
|
||||
{
|
||||
$query = Project::query()
|
||||
->with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 in aggregation helpers
|
||||
->withCount('supplierProjects') // ProjectResource::source_locked — анти-N+1 (hasLinks без per-row запроса)
|
||||
->where('tenant_id', $request->user()->tenant_id);
|
||||
|
||||
// Batch-fetch по ids — возвращает без пагинации (для dropdown'ов и т.п.)
|
||||
@@ -161,7 +162,7 @@ class ProjectController extends Controller
|
||||
|
||||
$project = $this->projects->create($tenant, $validated);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project)], 201);
|
||||
return response()->json(['data' => new ProjectResource($project->loadCount('supplierProjects'))], 201);
|
||||
}
|
||||
|
||||
/** PATCH /api/projects/{id} */
|
||||
@@ -202,7 +203,7 @@ class ProjectController extends Controller
|
||||
|
||||
$updated = $this->projects->update($project, $validated);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($updated)]);
|
||||
return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,6 +237,7 @@ class ProjectController extends Controller
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$project = Project::with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1
|
||||
->withCount('supplierProjects') // ProjectResource::source_locked — анти-N+1
|
||||
->where('tenant_id', $request->user()->tenant_id)
|
||||
->findOrFail($id);
|
||||
|
||||
@@ -278,7 +280,7 @@ class ProjectController extends Controller
|
||||
// status=paused when no active project of the group remains (resume → active).
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project->fresh())]);
|
||||
return response()->json(['data' => new ProjectResource($project->fresh()->loadCount('supplierProjects'))]);
|
||||
}
|
||||
|
||||
/** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */
|
||||
|
||||
@@ -13,6 +13,17 @@ class ProjectResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
// Состояние блокировки источника для UI (read-only). hasLinks — из eager-loaded
|
||||
// supplier_projects_count (анти-N+1); fallback на exists() если count не загружен.
|
||||
$hasLinks = $this->supplier_projects_count !== null
|
||||
? (int) $this->supplier_projects_count > 0
|
||||
: $this->supplierProjects()->exists();
|
||||
$sourceLock = (new \App\Services\Project\SupplierSnapshotGuard)->lockState(
|
||||
hasLinks: $hasLinks,
|
||||
isActive: (bool) $this->is_active,
|
||||
pausedAt: $this->paused_at,
|
||||
);
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
@@ -39,6 +50,10 @@ class ProjectResource extends JsonResource
|
||||
// ProjectService::update() для slepok-sensitive правок. UI показывает
|
||||
// «изменения вступят в силу с DD.MM HH:MM МСК».
|
||||
'applies_from' => $this->applies_from?->toIso8601String(),
|
||||
// Блокировка смены источника (спека 2026-06-22-project-source-edit-lock-ux).
|
||||
'source_locked' => $sourceLock['locked'],
|
||||
'source_unlock_at' => $sourceLock['unlock_at']?->toIso8601String(),
|
||||
'source_unlock_projected' => $sourceLock['projected'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,4 +134,38 @@ class SupplierSnapshotGuard
|
||||
'errors' => ['project' => [$message]],
|
||||
], 422));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-вариант isProtected для presentation-слоя: считает состояние блокировки
|
||||
* источника БЕЗ DB-запроса (hasLinks передаётся снаружи — eager-loaded count).
|
||||
* Логику isProtected/computeGraceUntil не меняет — переиспользует computeGraceUntil.
|
||||
*
|
||||
* @return array{locked: bool, unlock_at: ?CarbonImmutable, projected: bool}
|
||||
* projected=true → дата это прогноз «если поставить паузу сейчас» (проект активен).
|
||||
*/
|
||||
public function lockState(
|
||||
bool $hasLinks,
|
||||
bool $isActive,
|
||||
?CarbonInterface $pausedAt,
|
||||
?CarbonImmutable $now = null,
|
||||
): array {
|
||||
$now ??= CarbonImmutable::now('Europe/Moscow');
|
||||
|
||||
if (! $hasLinks) {
|
||||
return ['locked' => false, 'unlock_at' => null, 'projected' => false];
|
||||
}
|
||||
if ($isActive) {
|
||||
// Паузы ещё нет — дата это прогноз «если поставить паузу сейчас».
|
||||
return ['locked' => true, 'unlock_at' => $this->computeGraceUntil($now), 'projected' => true];
|
||||
}
|
||||
if ($pausedAt === null) {
|
||||
return ['locked' => false, 'unlock_at' => null, 'projected' => false];
|
||||
}
|
||||
$graceUntil = $this->computeGraceUntil($pausedAt);
|
||||
if ($now->lt($graceUntil)) {
|
||||
return ['locked' => true, 'unlock_at' => $graceUntil, 'projected' => false];
|
||||
}
|
||||
|
||||
return ['locked' => false, 'unlock_at' => null, 'projected' => false];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
function linkedProject(bool $active, ?string $pausedAt = null): Project
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$p = Project::factory()->for($tenant)->create(['is_active' => $active, 'paused_at' => $pausedAt]);
|
||||
$sp = SupplierProject::factory()->create();
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $p->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
|
||||
return $p->loadCount('supplierProjects');
|
||||
}
|
||||
|
||||
it('active linked project → source_locked true + projected', function (): void {
|
||||
$res = (new ProjectResource(linkedProject(true)))->toArray(request());
|
||||
expect($res)->toHaveKeys(['source_locked', 'source_unlock_at', 'source_unlock_projected']);
|
||||
expect($res['source_locked'])->toBeTrue();
|
||||
expect($res['source_unlock_projected'])->toBeTrue();
|
||||
expect($res['source_unlock_at'])->not->toBeNull();
|
||||
});
|
||||
|
||||
it('project with no supplier links → source_locked false', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$p = Project::factory()->for($tenant)->create(['is_active' => true])->loadCount('supplierProjects');
|
||||
$res = (new ProjectResource($p))->toArray(request());
|
||||
expect($res['source_locked'])->toBeFalse();
|
||||
expect($res['source_unlock_at'])->toBeNull();
|
||||
expect($res['source_unlock_projected'])->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Project\SupplierSnapshotGuard;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
function ls(): SupplierSnapshotGuard
|
||||
{
|
||||
return new SupplierSnapshotGuard;
|
||||
}
|
||||
|
||||
it('no supplier links → not locked', function (): void {
|
||||
$r = ls()->lockState(hasLinks: false, isActive: true, pausedAt: null);
|
||||
expect($r['locked'])->toBeFalse();
|
||||
expect($r['unlock_at'])->toBeNull();
|
||||
expect($r['projected'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('active + links → locked, unlock_at projected from now', function (): void {
|
||||
$now = CarbonImmutable::parse('2026-06-22 16:00:00', 'Europe/Moscow'); // до 21:00
|
||||
$r = ls()->lockState(hasLinks: true, isActive: true, pausedAt: null, now: $now);
|
||||
expect($r['locked'])->toBeTrue();
|
||||
expect($r['projected'])->toBeTrue();
|
||||
// pause-now(16:00) → next21=22.06 21:00 → +24h = 23.06 21:00
|
||||
expect($r['unlock_at']->toIso8601String())->toBe('2026-06-23T21:00:00+03:00');
|
||||
});
|
||||
|
||||
it('paused within grace → locked, firm unlock_at from paused_at', function (): void {
|
||||
$paused = CarbonImmutable::parse('2026-06-22 16:00:00', 'Europe/Moscow');
|
||||
$now = CarbonImmutable::parse('2026-06-22 18:00:00', 'Europe/Moscow');
|
||||
$r = ls()->lockState(hasLinks: true, isActive: false, pausedAt: $paused, now: $now);
|
||||
expect($r['locked'])->toBeTrue();
|
||||
expect($r['projected'])->toBeFalse();
|
||||
expect($r['unlock_at']->toIso8601String())->toBe('2026-06-23T21:00:00+03:00');
|
||||
});
|
||||
|
||||
it('paused after grace → not locked', function (): void {
|
||||
$paused = CarbonImmutable::parse('2026-06-20 16:00:00', 'Europe/Moscow');
|
||||
$now = CarbonImmutable::parse('2026-06-22 18:00:00', 'Europe/Moscow'); // > grace(21.06 21:00)
|
||||
$r = ls()->lockState(hasLinks: true, isActive: false, pausedAt: $paused, now: $now);
|
||||
expect($r['locked'])->toBeFalse();
|
||||
expect($r['unlock_at'])->toBeNull();
|
||||
});
|
||||
|
||||
it('paused but paused_at null → not locked', function (): void {
|
||||
$r = ls()->lockState(hasLinks: true, isActive: false, pausedAt: null);
|
||||
expect($r['locked'])->toBeFalse();
|
||||
});
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-06-22T16:33:18.329Z
|
||||
Last updated: 2026-06-22T16:39:38.602Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -47,16 +47,16 @@ Last updated: 2026-06-22T16:33:18.329Z
|
||||
|
||||
| Время | Действие | Причина |
|
||||
|---|---|---|
|
||||
| 2026-06-22T16:38:06.732Z | bash:ls $TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json 2>/dev/null && cat $TEMP/claude-economy-ae7348fc- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom |
|
||||
| 2026-06-22T16:35:14.943Z | bash:cat "$TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json" 2>/dev/null \|\| echo "FILE_NOT_FOUND" | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:cat "$TEMP/claude-econ |
|
||||
| 2026-06-22T16:35:10.967Z | write:c:/users/administrator/.claude/projects/c--------------claude-brain/a07a82ef-789c-4efd-8a3a-92b542bcd80b.jsonl | path «C:/Users/Administrator/.claude/projects/c--------------claude-brain/a07a82ef-789c-4efd-8a3a-92b542bcd80b.jsonl» pr |
|
||||
| 2026-06-22T16:35:04.784Z | bash:cat "$TEMP/claude-economy-a07a82ef-789c-4efd-8a3a-92b542bcd80b.json" 2>/dev/null \|\| echo "FILE_NOT_FOUND" | разговорный режим: только думать/спрашивать (реализация — после печати артефакта и плана) |
|
||||
| 2026-06-22T16:35:04.693Z | bash:cat "$TEMP/claude-economy-a07a82ef-789c-4efd-8a3a-92b542bcd80b.json" 2>/dev/null \|\| echo "FILE_NOT_FOUND" | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:cat "$TEMP/claude-econ |
|
||||
| 2026-06-22T16:34:49.658Z | write:c:/моя/проекты/claude-brain | разговорный режим: только думать/спрашивать (реализация — после печати артефакта и плана) |
|
||||
| 2026-06-22T16:34:42.127Z | write:c:/users/administrator/.claude/projects/c--------------claude-brain/a07a82ef-789c-4efd-8a3a-92b542bcd80b.jsonl | path «C:/Users/Administrator/.claude/projects/c--------------claude-brain/a07a82ef-789c-4efd-8a3a-92b542bcd80b.jsonl» pr |
|
||||
| 2026-06-22T16:34:37.632Z | bash:ls $TEMP/claude-economy-a07a82ef-789c-4efd-8a3a-92b542bcd80b.json 2>/dev/null && cat $TEMP/claude-economy-a07a82ef- | разговорный режим: только думать/спрашивать (реализация — после печати артефакта и плана) |
|
||||
| 2026-06-22T16:34:37.459Z | bash:ls $TEMP/claude-economy-a07a82ef-789c-4efd-8a3a-92b542bcd80b.json 2>/dev/null && cat $TEMP/claude-economy-a07a82ef- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom |
|
||||
| 2026-06-22T16:32:08.562Z | bash:cd "c:/моя/проекты/claude-brain" && echo "=== линза/_reconcile.log ===" && cat "docs/secretary/линза/_reconcile.log | разговорный режим: только думать/спрашивать (реализация — после печати артефакта и плана) |
|
||||
| 2026-06-22T16:32:08.452Z | bash:cd "c:/моя/проекты/claude-brain" && echo "=== линза/_reconcile.log ===" && cat "docs/secretary/линза/_reconcile.log | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:cd "c:/моя/проекты/cla |
|
||||
| 2026-06-22T16:29:22.905Z | bash:ls $TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json 2>/dev/null && cat $TEMP/claude-economy-ae7348fc- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom |
|
||||
| 2026-06-22T16:26:32.629Z | bash:ls $TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json 2>/dev/null && cat $TEMP/claude-economy-ae7348fc- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom |
|
||||
| 2026-06-22T16:24:44.007Z | bash:ls $TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json 2>/dev/null && cat $TEMP/claude-economy-ae7348fc- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom |
|
||||
| 2026-06-22T16:22:00.158Z | bash:ls $TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json 2>/dev/null && cat $TEMP/claude-economy-ae7348fc- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom |
|
||||
| 2026-06-22T16:20:44.486Z | bash:ls $TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json 2>/dev/null && cat $TEMP/claude-economy-ae7348fc- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom |
|
||||
| 2026-06-22T16:19:05.841Z | bash:ls $TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json 2>/dev/null && cat $TEMP/claude-economy-ae7348fc- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom |
|
||||
| 2026-06-22T16:17:41.809Z | bash:ls $TEMP/claude-economy-ae7348fc-4410-4d81-8546-4b57c2df3ad0.json 2>/dev/null && cat $TEMP/claude-economy-ae7348fc- | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls $TEMP/claude-econom |
|
||||
| 2026-06-22T16:17:18.508Z | bash:cat "$TEMP/claude-economy-c45f5b05-836d-48ba-bf47-ab3477fd20e5.json" 2>/dev/null \|\| echo "FILE_NOT_FOUND" | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:cat "$TEMP/claude-econ |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
@@ -140,9 +140,9 @@ Episodes since last run: 542 / threshold: 10
|
||||
|
||||
| PID | Имя | CPU-время | Возраст |
|
||||
|---|---|---|---|
|
||||
| 3440 | MsMpEng | 2.63ч | NaNч |
|
||||
| 6976 | Code | 2.62ч | 0.0ч |
|
||||
| 14232 | msedge | 2.50ч | 0.0ч |
|
||||
| 3440 | MsMpEng | 2.65ч | 1327792.6ч |
|
||||
| 6976 | Code | 2.62ч | 12285064.7ч |
|
||||
| 14232 | msedge | 2.55ч | 0.0ч |
|
||||
|
||||
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user