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:
Дмитрий
2026-05-20 17:33:46 +03:00
parent 36c71ecb1e
commit 80275c6417
5 changed files with 167 additions and 14 deletions
+31 -7
View File
@@ -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;
}
}
+5 -2
View File
@@ -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);
+2 -2
View File
@@ -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']);