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:
@@ -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'))]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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-сессий.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user