From 08cf23893a9d649e430eeae02f375dd5481fa96d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 22 Jun 2026 20:52:18 +0300 Subject: [PATCH] =?UTF-8?q?feat(projects):=20source-lock=20state=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20UI=20(guard.lockState=20+=20ProjectResource=20+?= =?UTF-8?q?=20=D0=B0=D0=BD=D1=82=D0=B8-N+1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SupplierSnapshotGuard::lockState (pure, без DB) + ProjectResource отдаёт source_locked/source_unlock_at/source_unlock_projected; ProjectController withCount(supplierProjects). Логика гарда не изменена. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/Api/ProjectController.php | 8 +-- app/app/Http/Resources/ProjectResource.php | 15 ++++++ .../Project/SupplierSnapshotGuard.php | 34 +++++++++++++ .../ProjectResourceSourceLockTest.php | 44 +++++++++++++++++ .../SupplierSnapshotGuardLockStateTest.php | 49 +++++++++++++++++++ docs/observer/STATUS.md | 26 +++++----- 6 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 app/tests/Feature/Http/Resources/ProjectResourceSourceLockTest.php create mode 100644 app/tests/Unit/Project/SupplierSnapshotGuardLockStateTest.php diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index 3b4f9f27..9497abc2 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -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 */ diff --git a/app/app/Http/Resources/ProjectResource.php b/app/app/Http/Resources/ProjectResource.php index 85c995f8..6ad2828b 100644 --- a/app/app/Http/Resources/ProjectResource.php +++ b/app/app/Http/Resources/ProjectResource.php @@ -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'], ]; } } diff --git a/app/app/Services/Project/SupplierSnapshotGuard.php b/app/app/Services/Project/SupplierSnapshotGuard.php index a433250e..510074f0 100644 --- a/app/app/Services/Project/SupplierSnapshotGuard.php +++ b/app/app/Services/Project/SupplierSnapshotGuard.php @@ -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]; + } } diff --git a/app/tests/Feature/Http/Resources/ProjectResourceSourceLockTest.php b/app/tests/Feature/Http/Resources/ProjectResourceSourceLockTest.php new file mode 100644 index 00000000..790e0bd6 --- /dev/null +++ b/app/tests/Feature/Http/Resources/ProjectResourceSourceLockTest.php @@ -0,0 +1,44 @@ +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(); +}); diff --git a/app/tests/Unit/Project/SupplierSnapshotGuardLockStateTest.php b/app/tests/Unit/Project/SupplierSnapshotGuardLockStateTest.php new file mode 100644 index 00000000..fe4bf85d --- /dev/null +++ b/app/tests/Unit/Project/SupplierSnapshotGuardLockStateTest.php @@ -0,0 +1,49 @@ +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(); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 3de46e78..2b6913b1 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -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-сессий.