fix(supplier): real workdays from delivery_days_mask + resync on limit/days change
Закрывает два бага 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<int>
|
||||
*/
|
||||
private function workdaysFromMask(int $mask): array
|
||||
{
|
||||
$out = [];
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
if (($mask & (1 << $i)) !== 0) {
|
||||
$out[] = $i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
-
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user