feat/billing: G — все пути синхронизации уважают preflight_blocked_at

Заблокированный за нехваткой баланса проект не должен уезжать заказом к
поставщику ни через одиночную правку, ни через ручную «Синхронизировать»,
ни через возобновление — раньше эти три пути диспатчили 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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-23 09:17:21 +03:00
parent 2deaf2075a
commit 4e1cc951b8
4 changed files with 139 additions and 8 deletions
@@ -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'))]);
}
+7 -1
View File
@@ -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);
}
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function () {
Queue::fake();
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
// G: ВСЕ пути синхронизации должны уважать preflight_blocked_at — заблокированный
// (за нехваткой баланса) проект НЕ должен уезжать заказом к поставщику ни через
// одиночную правку, ни через ручную «Синхронизировать», ни через возобновление.
// Зеркалит create-гард (ProjectService::create) и фильтр ночного sweep.
// --- update ---
it('does not sync blocked 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' => 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);
});
+8 -6
View File
@@ -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-сессий.