From f0269534e5912cf619c929346cb604a48ecbd2dc 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, 26 May 2026 06:50:28 +0300 Subject: [PATCH] =?UTF-8?q?feat(billing-v2-c):=20online=20supplier=20sync?= =?UTF-8?q?=20=D0=BD=D0=B0=20freeze/unfreeze=20(=D0=BF=D1=80=D0=B8=D0=B2?= =?UTF-8?q?=D1=8F=D0=B7=D0=BA=D0=B0=20=D0=BA=20SupplierExportMode)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При переходе active→frozen или frozen→active BalancePreflightSweepJob теперь дёргает SyncSupplierProjectJob per-project, если admin-переключатель в режиме online. В batch (рабочем для будущего масштаба) — sync отложен до cut-off cron 18:00 MSK через SyncSupplierProjectsJob. Co-Authored-By: Claude Opus 4.7 --- .../Jobs/Billing/BalancePreflightSweepJob.php | 30 +++++++++++ .../Billing/BalancePreflightSweepJobTest.php | 54 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/app/app/Jobs/Billing/BalancePreflightSweepJob.php b/app/app/Jobs/Billing/BalancePreflightSweepJob.php index b5dcde80..d7e9fa9b 100644 --- a/app/app/Jobs/Billing/BalancePreflightSweepJob.php +++ b/app/app/Jobs/Billing/BalancePreflightSweepJob.php @@ -4,12 +4,14 @@ declare(strict_types=1); namespace App\Jobs\Billing; +use App\Jobs\SyncSupplierProjectJob; use App\Mail\BalanceFrozenMail; use App\Mail\BalanceUnfrozenMail; use App\Models\PricingTier; use App\Models\Tenant; use App\Services\Billing\BalancePreflightService; use App\Services\Billing\PreflightResult; +use App\Services\Supplier\SupplierExportMode; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Database\Eloquent\Collection; @@ -73,6 +75,7 @@ final class BalancePreflightSweepJob implements ShouldQueue $tenant->save(); $this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result); Mail::queue(new BalanceFrozenMail($tenant, $result)); + $this->dispatchSupplierSyncIfOnline($tenant); return; } @@ -83,6 +86,7 @@ final class BalancePreflightSweepJob implements ShouldQueue $tenant->save(); $this->logEvent($tenant, 'unfrozen', 'cutoff_18msk', $result); Mail::queue(new BalanceUnfrozenMail($tenant, $result)); + $this->dispatchSupplierSyncIfOnline($tenant); return; } @@ -90,6 +94,32 @@ final class BalancePreflightSweepJob implements ShouldQueue }); } + /** + * Spec C extension (26.05.2026): при переходе freeze ↔ unfreeze в режиме 'online' + * диспатчим точечный sync с поставщиком per-project (group-recalc внутри handleOnline + * сам учтёт шеринг через signal_identifier). В режиме 'batch' изменения уезжают + * cut-off cron'ом @18:00 MSK через SyncSupplierProjectsJob (множественный). + * Привязка к админ-переключателю SupplierExportMode (system_settings.supplier_export_mode). + * + * Вызывается ВНУТРИ DB::transaction обёртки evaluateTenant — app.current_tenant_id выставлен, + * RLS-фильтрация projects работает. + */ + private function dispatchSupplierSyncIfOnline(Tenant $tenant): void + { + if (! SupplierExportMode::isOnline()) { + return; + } + + $projectIds = $tenant->projects() + ->where('is_active', true) + ->whereNull('preflight_blocked_at') + ->pluck('id'); + + foreach ($projectIds as $id) { + SyncSupplierProjectJob::dispatch((int) $id); + } + } + private function logEvent(Tenant $tenant, string $event, string $trigger, PreflightResult $result): void { DB::connection('pgsql_supplier')->table('balance_freeze_log')->insert([ diff --git a/app/tests/Feature/Billing/BalancePreflightSweepJobTest.php b/app/tests/Feature/Billing/BalancePreflightSweepJobTest.php index 0d94bf4a..49e537e6 100644 --- a/app/tests/Feature/Billing/BalancePreflightSweepJobTest.php +++ b/app/tests/Feature/Billing/BalancePreflightSweepJobTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Jobs\Billing\BalancePreflightSweepJob; +use App\Jobs\SyncSupplierProjectJob; use App\Mail\BalanceFrozenMail; use App\Models\PricingTier; use App\Models\Project; @@ -10,6 +11,7 @@ use App\Models\Tenant; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Queue; use Tests\Concerns\SharesSupplierPdo; // Изоляция: liderra_testing persistent (RefreshDatabase off). DatabaseTransactions @@ -62,3 +64,55 @@ it('is idempotent — does not re-freeze already frozen tenant', function () { expect($tenant->fresh()->frozen_by_balance_at->timestamp)->toBe($frozenAt->timestamp); Mail::assertNotQueued(BalanceFrozenMail::class, fn ($mail) => $mail->tenant->id === $tenant->id); }); + +// Spec C extension (26.05.2026): freeze/unfreeze дёргают supplier sync в режиме 'online'. +// Привязка к существующему админ-переключателю SupplierExportMode (system_settings.supplier_export_mode). +// Online нужен сейчас для отладки (моментальный sync с поставщиком); batch будет рабочим режимом +// при росте числа клиентов (накопленные изменения уезжают одним cut-off-cron'ом в 18:00 MSK). + +it('dispatches SyncSupplierProjectJob for each active project on freeze when supplier mode is online', function () { + Mail::fake(); + Queue::fake(); + DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'online']); + + // 500₽ / 50₽ = 10 лидов; 2 проекта по 15 = 30 → заморозка. + $tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]); + $p1 = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]); + $p2 = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]); + + (new BalancePreflightSweepJob)->handle(); + + expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull(); + Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $p1->id); + Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $p2->id); +}); + +it('does NOT dispatch SyncSupplierProjectJob on freeze when supplier mode is batch', function () { + Mail::fake(); + Queue::fake(); + DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'batch']); + + $tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]); + Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]); + + (new BalancePreflightSweepJob)->handle(); + + expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull(); + // batch-режим: sync с поставщиком отложен до cut-off 18:00 MSK через SyncSupplierProjectsJob (множественный). + Queue::assertNotPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === Project::query()->where('tenant_id', $tenant->id)->value('id')); +}); + +it('dispatches SyncSupplierProjectJob on unfreeze when supplier mode is online', function () { + Mail::fake(); + Queue::fake(); + DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'online']); + + // 2000₽ / 50₽ = 40 лидов; хотят 25 → разморозка. + $tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => now()->subDay()]); + $project = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]); + + (new BalancePreflightSweepJob)->handle(); + + expect($tenant->fresh()->frozen_by_balance_at)->toBeNull(); + Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $project->id); +});