diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index 9497abc2..d1799314 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -278,7 +278,11 @@ class ProjectController extends Controller // #10: pause/resume must reach the supplier. The job's group recompute pushes // status=paused when no active project of the group remains (resume → active). - SyncSupplierProjectJob::dispatch($project->id); + // G (балансовый блок): заблокированный за нехваткой баланса проект не + // возобновляется/синхронизируется у поставщика (зеркалит create-гард). + if ($project->preflight_blocked_at === null) { + SyncSupplierProjectJob::dispatch($project->id); + } return response()->json(['data' => new ProjectResource($project->fresh()->loadCount('supplierProjects'))]); } diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index 404b57c1..2bea9403 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -105,7 +105,9 @@ class ProjectService userAgent: request()->userAgent(), ); - if ($needsResync) { + // G (балансовый блок): заблокированный за нехваткой баланса проект не уходит + // заказом к поставщику — зеркалит create-гард и фильтр ночного sweep. + if ($needsResync && $project->preflight_blocked_at === null) { SyncSupplierProjectJob::dispatch($project->id); } @@ -225,6 +227,10 @@ class ProjectService public function triggerSync(Project $project): void { + // G (балансовый блок): ручная «Синхронизировать» не отправляет заблокированный проект. + if ($project->preflight_blocked_at !== null) { + return; + } SyncSupplierProjectJob::dispatch($project->id); } diff --git a/app/tests/Feature/Billing/ProjectBlockedSyncGuardTest.php b/app/tests/Feature/Billing/ProjectBlockedSyncGuardTest.php new file mode 100644 index 00000000..20ecf45a --- /dev/null +++ b/app/tests/Feature/Billing/ProjectBlockedSyncGuardTest.php @@ -0,0 +1,119 @@ +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'preflight_blocked_at' => now(), + 'regions' => [77], + ]); + + $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ + 'regions' => [78], + ])->assertOk(); + + Queue::assertNotPushed(SyncSupplierProjectJob::class); +}); + +it('syncs unblocked project to supplier on update', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'preflight_blocked_at' => null, + 'regions' => [77], + ]); + + $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ + 'regions' => [78], + ])->assertOk(); + + Queue::assertPushed(SyncSupplierProjectJob::class); +}); + +// --- toggle-active (возобновление) --- + +it('does not sync blocked project to supplier on toggle-active resume', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => false, + 'preflight_blocked_at' => now(), + ]); + + $this->actingAs($user)->patchJson("/api/projects/{$project->id}/toggle-active", [ + 'is_active' => true, + ])->assertOk(); + + Queue::assertNotPushed(SyncSupplierProjectJob::class); +}); + +it('syncs unblocked project to supplier on toggle-active resume', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => false, + 'preflight_blocked_at' => null, + ]); + + $this->actingAs($user)->patchJson("/api/projects/{$project->id}/toggle-active", [ + 'is_active' => true, + ])->assertOk(); + + Queue::assertPushed(SyncSupplierProjectJob::class); +}); + +// --- ручная «Синхронизировать» (triggerSync) --- + +it('does not sync blocked project to supplier on manual sync', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'preflight_blocked_at' => now(), + ]); + + $this->actingAs($user)->postJson("/api/projects/{$project->id}/sync")->assertStatus(202); + + Queue::assertNotPushed(SyncSupplierProjectJob::class); +}); + +it('syncs unblocked project to supplier on manual sync', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'preflight_blocked_at' => null, + ]); + + $this->actingAs($user)->postJson("/api/projects/{$project->id}/sync")->assertStatus(202); + + Queue::assertPushed(SyncSupplierProjectJob::class); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index fdf3ef10..481c393d 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-06-23T04:30:31.952Z +Last updated: 2026-06-23T06:06:59.331Z | Контролёр | Состояние | Детали | |---|---|---| @@ -33,15 +33,17 @@ Last updated: 2026-06-23T04:30:31.952Z | enforce-coverage-verify.mjs | `enforce-coverage-verify.mjs` | 🔴 | | enforce-todowrite-skill-verifier.mjs | `enforce-todowrite-skill-verifier.mjs` | 🔴 | -Недавние escape владельца: 3 · Недавние блоки: 10 +Недавние escape владельца: 5 · Недавние блоки: 10 **Недавние escape владельца (детали):** | Время | Действие | Причина | |---|---|---| +| 2026-06-23T06:04:46.723Z | gate3-arb:accept:02c2d614d56f837bdb84c21eb80ee76a14c378eff8396f74b1db3c1739f15ac8 | escape владельца | +| 2026-06-23T04:39:25.966Z | gate3-arb:accept:<отпечаток> | escape владельца | +| 2026-06-23T04:38:49.844Z | gate3-arb:accept:<отпечаток-из-карточки> | escape владельца | | 2026-06-22T15:17:16.099Z | gate3-arb:accept:0b0aca2de2baa82bb764634b463339cf993fe43c3c7df2171bed23fa9de0b6bd | escape владельца | | 2026-06-22T15:15:32.983Z | gate3-arb:accept:0b0aca2de2baa82bb764634b463339cf993fe43c3c7df2171bed23fa9de0b6bd | escape владельца | -| 2026-06-22T05:21:06.824Z | owner-seal:63cfffa28978c4f017141cab7aeb36132774cd3e753e64fdf5d1c7b6e4b4d92d | escape владельца | **Недавние блоки (детали):** @@ -140,9 +142,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 14232 | msedge | 5.72ч | NaNч | -| 3440 | MsMpEng | 5.03ч | 0.0ч | -| 1212 | svchost | 1.10ч | NaNч | +| 14232 | msedge | 5.72ч | 0.0ч | +| 3440 | MsMpEng | 5.19ч | NaNч | +| 1212 | svchost | 1.14ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.