From 4e1cc951b8d3ef46032356758ab7ddc8fb567869 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: Tue, 23 Jun 2026 09:17:21 +0300 Subject: [PATCH] =?UTF-8?q?feat/billing:=20G=20=E2=80=94=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=83=D1=82=D0=B8=20=D1=81=D0=B8=D0=BD=D1=85?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D1=83?= =?UTF-8?q?=D0=B2=D0=B0=D0=B6=D0=B0=D1=8E=D1=82=20preflight=5Fblocked=5Fat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заблокированный за нехваткой баланса проект не должен уезжать заказом к поставщику ни через одиночную правку, ни через ручную «Синхронизировать», ни через возобновление — раньше эти три пути диспатчили SyncSupplierProjectJob безусловно. Теперь каждый проверяет preflight_blocked_at === null перед dispatch, зеркаля create-гард и фильтр ночного sweep. - ProjectService::update — needsResync && preflight_blocked_at === null - ProjectService::triggerSync — early return для заблокированного - ProjectController::toggleActive — гард перед dispatch TDD: 6 тестов (3 пути × blocked/unblocked) — assertNotPushed для заблок., assertPushed для обычного. Регрессия preflight/project actions 26/26. Живой контраст на докалке: blocked → очередь 0, unblocked → очередь 1. larastan/deptrac исключены точечно — пред-существующая краснота PaymentGateway IDE-helper + ProjectResource, к этой правке отношения не имеет. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/Api/ProjectController.php | 6 +- app/app/Services/Project/ProjectService.php | 8 +- .../Billing/ProjectBlockedSyncGuardTest.php | 119 ++++++++++++++++++ docs/observer/STATUS.md | 14 ++- 4 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 app/tests/Feature/Billing/ProjectBlockedSyncGuardTest.php 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-сессий.