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:
Дмитрий
2026-06-22 20:52:18 +03:00
parent 9901e74f89
commit 08cf23893a
6 changed files with 160 additions and 16 deletions
@@ -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
View File
@@ -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-сессий.