From 80275c64179191dad6dccfb7b407faaa484eb548 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: Wed, 20 May 2026 17:33:46 +0300 Subject: [PATCH] fix(supplier): real workdays from delivery_days_mask + resync on limit/days change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Закрывает два бага sync поставщика, обнаруженные при live-проверке создания проекта «мой номер» (call, 79135191264, лимит 15, дни Пн-Пт): 1. SyncSupplierProjectJob хардкодил workdays=[1..7] в 7 местах и в DTO для portal, и в supplier_projects.current_workdays. Заменено на реальную маску через приватный workdaysFromMask() (зеркало bitmaskToList ночного батча). 2. forceFill в update-path online mode не включал current_workdays — после первого create со старыми [1..7] последующий ресинк не подтягивал реальные дни в локальную БД (на portal летели корректные, в нашей таблице оставались stale). 3. ProjectService::update() ресинкал только при смене sms_*/signal_identifier/ regions. Добавлены daily_limit_target и delivery_days_mask — поставщик видит новый лимит и дни сразу, не дожидаясь ночного батча 18:00 МСК. Тесты: - SyncSupplierProjectJobTest: +2 specs (real-workdays create-path, update-path current_workdays refresh). - ProjectsUpdateTest: «without resync» переписан в name-only, +2 specs (daily_limit_target и delivery_days_mask change → resync). - Pest 146/146 (Supplier + Plan5/Projects scope), Pint passed, Larastan 0. Live-ресинк проекта id=5 «мой номер» в dev DB выполнен — current_workdays теперь [1,2,3,4,5], HTTP ушёл к crm.bp-gr.ru с теми же днями. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/app/Jobs/SyncSupplierProjectJob.php | 38 ++++++-- app/app/Services/Project/ProjectService.php | 7 +- app/phpstan-baseline.neon | 4 +- .../Plan5/Projects/ProjectsUpdateTest.php | 37 +++++++- .../Supplier/SyncSupplierProjectJobTest.php | 95 +++++++++++++++++++ 5 files changed, 167 insertions(+), 14 deletions(-) diff --git a/app/app/Jobs/SyncSupplierProjectJob.php b/app/app/Jobs/SyncSupplierProjectJob.php index 105d95dd..7e69dbcd 100644 --- a/app/app/Jobs/SyncSupplierProjectJob.php +++ b/app/app/Jobs/SyncSupplierProjectJob.php @@ -102,6 +102,8 @@ class SyncSupplierProjectJob implements ShouldQueue ? (RussianRegions::CODE_TO_NAME[$allRegions[0]] ?? (string) $allRegions[0]) : 'РФ'; + $workdays = $this->workdaysFromMask((int) $project->delivery_days_mask); + // Idempotency: find existing by identifier regardless of subject_code (any previous run). $existingSps = SupplierProject::query() ->where('unique_key', $identifier) @@ -116,7 +118,7 @@ class SyncSupplierProjectJob implements ShouldQueue signalType: (string) $project->signal_type, uniqueKey: $identifier, limit: (int) $project->daily_limit_target, - workdays: [1, 2, 3, 4, 5, 6, 7], + workdays: $workdays, regions: $allRegions, regionsReverse: false, status: 'active', @@ -153,7 +155,7 @@ class SyncSupplierProjectJob implements ShouldQueue 'subject_code' => null, 'supplier_external_id' => (string) $externalId, 'current_limit' => (int) $project->daily_limit_target, - 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], + 'current_workdays' => $workdays, 'current_regions' => $allRegions, 'sync_status' => 'ok', 'last_synced_at' => now(), @@ -172,7 +174,7 @@ class SyncSupplierProjectJob implements ShouldQueue signalType: (string) $project->signal_type, uniqueKey: $identifier, limit: (int) $project->daily_limit_target, - workdays: [1, 2, 3, 4, 5, 6, 7], + workdays: $workdays, regions: $allRegions, regionsReverse: false, status: 'active', @@ -205,7 +207,7 @@ class SyncSupplierProjectJob implements ShouldQueue 'subject_code' => null, 'supplier_external_id' => (string) $externalId, 'current_limit' => (int) $project->daily_limit_target, - 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], + 'current_workdays' => $workdays, 'current_regions' => $allRegions, 'sync_status' => 'ok', 'last_synced_at' => now(), @@ -224,7 +226,7 @@ class SyncSupplierProjectJob implements ShouldQueue signalType: (string) $project->signal_type, uniqueKey: $identifier, limit: (int) $project->daily_limit_target, - workdays: [1, 2, 3, 4, 5, 6, 7], + workdays: $workdays, regions: $allRegions, regionsReverse: false, status: 'active', @@ -234,6 +236,7 @@ class SyncSupplierProjectJob implements ShouldQueue $channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto); $sp->forceFill([ 'current_limit' => (int) $project->daily_limit_target, + 'current_workdays' => $workdays, 'current_regions' => $allRegions, 'sync_status' => 'ok', 'last_synced_at' => now(), @@ -259,6 +262,7 @@ class SyncSupplierProjectJob implements ShouldQueue private function handleBatch(Project $project, SupplierProjectChannel $channel): void { $platforms = SupplierProjectGrouping::resolvePlatforms($project); + $workdays = $this->workdaysFromMask((int) $project->delivery_days_mask); foreach ($platforms as $platform) { $uniqueKey = SupplierProjectGrouping::buildUniqueKey($project, $platform); @@ -282,7 +286,7 @@ class SyncSupplierProjectJob implements ShouldQueue signalType: (string) $project->signal_type, uniqueKey: $uniqueKey, limit: 0, - workdays: [1, 2, 3, 4, 5, 6, 7], + workdays: $workdays, regions: [], regionsReverse: false, status: 'active', @@ -308,7 +312,7 @@ class SyncSupplierProjectJob implements ShouldQueue 'unique_key' => $uniqueKey, 'supplier_external_id' => (string) $externalId, 'current_limit' => 0, - 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], + 'current_workdays' => $workdays, 'current_regions' => null, 'sync_status' => 'ok', ]); @@ -318,4 +322,24 @@ class SyncSupplierProjectJob implements ShouldQueue $project->save(); } + + /** + * Bitmask → ISO weekday list. bit 0 = Mon (ISO 1) … bit 6 = Sun (ISO 7). + * + * Mirror of SyncSupplierProjectsJob::bitmaskToList(). Kept inline (not + * extracted to a shared helper) to keep this fix surgical. + * + * @return list + */ + private function workdaysFromMask(int $mask): array + { + $out = []; + for ($i = 0; $i < 7; $i++) { + if (($mask & (1 << $i)) !== 0) { + $out[] = $i + 1; + } + } + + return $out; + } } diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index cfc49173..c2e798f7 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -32,11 +32,14 @@ class ProjectService ], 422)); } - // Resync на смену источник-несущих полей и регионов — поставщику нужны актуальные данные. + // Resync на смену источник-несущих полей, регионов, лимита и дней недели — + // поставщик должен видеть актуальные параметры сразу, не дожидаясь ночного батча. $needsResync = array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data) || array_key_exists('signal_identifier', $data) - || array_key_exists('regions', $data); + || array_key_exists('regions', $data) + || array_key_exists('daily_limit_target', $data) + || array_key_exists('delivery_days_mask', $data); $project->update($data); diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 840dbb52..4b541d69 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -1569,7 +1569,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound - count: 12 + count: 14 path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php - @@ -1887,7 +1887,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#' identifier: method.notFound - count: 1 + count: 2 path: tests/Feature/Supplier/SyncSupplierProjectJobTest.php - diff --git a/app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php b/app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php index 04f6dc96..d583d565 100644 --- a/app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php +++ b/app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php @@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Queue; beforeEach(fn () => Queue::fake()); -it('updates name+daily_limit without resync', function () { +it('updates name without resync (name is local-only)', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $project = Project::factory()->create([ @@ -19,14 +19,45 @@ it('updates name+daily_limit without resync', function () { ]); $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ - 'name' => 'New name', 'daily_limit_target' => 50, + 'name' => 'New name', ])->assertOk(); expect($project->fresh()->name)->toBe('New name'); - expect($project->fresh()->daily_limit_target)->toBe(50); Queue::assertNotPushed(SyncSupplierProjectJob::class); }); +it('changing daily_limit_target triggers resync (poster must see new limit immediately)', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, 'signal_type' => 'site', + 'signal_identifier' => 'a.ru', 'daily_limit_target' => 10, + ]); + + $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ + 'daily_limit_target' => 50, + ])->assertOk(); + + expect($project->fresh()->daily_limit_target)->toBe(50); + Queue::assertPushed(SyncSupplierProjectJob::class); +}); + +it('changing delivery_days_mask triggers resync (poster must see new days immediately)', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, 'signal_type' => 'site', + 'signal_identifier' => 'a.ru', 'delivery_days_mask' => 31, + ]); + + $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ + 'delivery_days_mask' => 63, // +Сб + ])->assertOk(); + + expect($project->fresh()->delivery_days_mask)->toBe(63); + Queue::assertPushed(SyncSupplierProjectJob::class); +}); + it('changing sms_senders triggers resync', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); diff --git a/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php b/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php index 349b70f9..6f2347bc 100644 --- a/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php +++ b/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php @@ -80,6 +80,101 @@ it('online mode creates single-group supplier_projects with full regions + pivot expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3); }); +it('online mode passes real workdays from delivery_days_mask (not hardcoded [1..7])', function (): void { + // Regression: до фикса хардкодилось [1,2,3,4,5,6,7] независимо от delivery_days_mask. + // delivery_days_mask=31 = 0b0011111 = Пн-Пт (ISO дни 1-5). Workdays поставщика должны быть [1,2,3,4,5]. + DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']); + + $tenant = Tenant::factory()->create(['balance_leads' => 100]); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'signal_type' => 'call', + 'signal_identifier' => '79135191264', + 'is_active' => true, + 'daily_limit_target' => 15, + 'regions' => [], + 'delivery_days_mask' => 31, // Пн-Пт + ]); + + $capturedWorkdays = null; + Http::fake([ + 'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$capturedWorkdays) { + $body = $request->data(); + if (isset($body['workdays'])) { + $capturedWorkdays = $body['workdays']; + } + + return Http::response(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '2001'], 200); + }, + 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( + ['projects' => [ + ['id' => '2001', 'src' => 'rt', 'name' => '79135191264', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135191264'], + ['id' => '2002', 'src' => 'bl', 'name' => '79135191264', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135191264'], + ['id' => '2003', 'src' => 'mt', 'name' => '79135191264', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135191264'], + ]], + 200, + ), + ]); + + (new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class)); + + // 1) supplier_projects записаны с реальными буднями, не all-7. + $sps = SupplierProject::where('unique_key', '79135191264')->get(); + expect($sps)->toHaveCount(3); + foreach ($sps as $sp) { + expect($sp->current_workdays)->toBe([1, 2, 3, 4, 5]); + } + + // 2) HTTP payload к порталу содержал ["1","2","3","4","5"], не ["1".."7"]. + expect($capturedWorkdays)->toBe(['1', '2', '3', '4', '5']); +}); + +it('online mode update-path: existing supplier_projects.current_workdays is refreshed (not just regions/limit)', function (): void { + // Regression: forceFill ранее не включал current_workdays — после первого create со + // старым хардкод-[1..7] последующий ресинк не подтягивал реальные дни. + DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']); + + $tenant = Tenant::factory()->create(['balance_leads' => 100]); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'signal_type' => 'call', + 'signal_identifier' => '79991234567', + 'is_active' => true, + 'daily_limit_target' => 9, + 'regions' => [], + 'delivery_days_mask' => 31, // Пн-Пт + ]); + + // Pre-seed existing supplier_projects со старыми (хардкод-)workdays. + foreach (['B1', 'B2', 'B3'] as $platform) { + SupplierProject::create([ + 'platform' => $platform, + 'signal_type' => 'call', + 'unique_key' => '79991234567', + 'subject_code' => null, + 'supplier_external_id' => '99'.$platform, + 'current_limit' => 6, + 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], + 'current_regions' => [], + 'sync_status' => 'ok', + 'last_synced_at' => now()->subDay(), + ]); + } + + $this->mock(SupplierProjectChannel::class, function ($mock): void { + $mock->shouldReceive('updateProject')->times(3)->andReturn(true); + }); + + (new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class)); + + $sps = SupplierProject::where('unique_key', '79991234567')->get(); + expect($sps)->toHaveCount(3); + foreach ($sps as $sp) { + expect($sp->current_workdays)->toBe([1, 2, 3, 4, 5]); + expect($sp->current_limit)->toBe(9); + } +}); + it('online mode all-RF (no regions): 1 group subject_code=null, 3 supplier_projects + 3 pivot links', function (): void { DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);