feat(slepok): Task 2.9 — SyncSupplierProjectsJob reads from snapshot (race 18:02→18:05 closure)
After Stage 2 запуска, 18:05 МСК sync читает project_routing_snapshots за tomorrow
МСК, не live projects.is_active. Это закрывает race 18:02 (snapshot) → 18:05 (sync):
клиент мог нажать «пауза» в эти 3 минуты, но мы всё равно докатываем зафиксированный
slepok поставщику (slepok-инвариант).
collectEligibleProjects() переписан с Project::on()->where('is_active', true)
на Project::on()->join('project_routing_snapshots AS snap', ...). Snapshot уже
отфильтрован по is_active/preflight_blocked/frozen_tenant; повторно проверяем
frozen-фильтр на случай freeze в эти 3 минуты. daily_limit_target /
delivery_days_mask / regions переопределяются значениями snapshot (slepok-семантика);
downstream syncGroup() работает без изменений.
Spec §4.2.4b. Closes race 18:02→18:05.
Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.9
Tests:
- tests/Feature/Jobs/Supplier/SyncSupplierProjectsJobSnapshotTest.php (4 new tests, PASS).
- tests/Feature/Supplier/SyncSupplierProjectsJobTest.php — 12 existing tests patched
with insertSnapshotForTomorrow($project) helper (12/12 GREEN).
- tests/Feature/Supplier/SyncSupplierPreflightFilterTest.php — 2 existing tests
patched (2/2 GREEN).
- tests/Pest.php — global helper insertSnapshotForTomorrow().
Combined sync regression: 19/20 PASS + 1 skipped (pre-existing).
Patched via 2 parallel Sonnet subagents per Pravila §15.1; controller-verified
combined regression.
This commit is contained in:
@@ -192,18 +192,65 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
*/
|
||||
public function collectEligibleProjects(): Collection
|
||||
{
|
||||
// NB: whereIn-subquery вместо whereHas — whereHas строит relation-query
|
||||
// через default Eloquent connection (pgsql), а наш родительский Project::on
|
||||
// на pgsql_supplier; cross-connection JOIN ломал sync-тесты (8 fails).
|
||||
// FROM 'tenants' внутри subquery наследует connection родителя.
|
||||
return Project::on(self::DB_CONNECTION)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->whereIn('tenant_id', function ($q): void {
|
||||
// Task 2.9 (Spec §4.2.4b): читаем проекты ИЗ snapshot за завтра, не live
|
||||
// projects.is_active. Это закрывает race 18:02 (snapshot) → 18:05 (sync) —
|
||||
// клиент мог paus'нуть проект между двумя cron'ами, но мы должны докатить
|
||||
// зафиксированный slepok поставщику (slepok-инвариант).
|
||||
//
|
||||
// Snapshot уже отфильтрован по is_active=true, preflight_blocked_at IS NULL,
|
||||
// tenants.frozen_by_balance_at IS NULL (см. SnapshotProjectRoutingJob /
|
||||
// SnapshotBackfillCommand WHERE). Здесь повторяем frozen-фильтр на случай
|
||||
// если tenant заморожен между 18:02 и 18:05 (rare safety net).
|
||||
//
|
||||
// Переопределяем live поля проекта значениями snapshot'а: daily_limit_target,
|
||||
// delivery_days_mask, regions. Downstream код syncGroup() читает эти поля как
|
||||
// обычно — без изменений в логике группировки/распределения.
|
||||
$tomorrow = Carbon::tomorrow('Europe/Moscow')->toDateString();
|
||||
|
||||
// Eloquent JOIN — casts (PostgresIntArray для regions) применяются автоматически.
|
||||
// Raw DB::table возвращал regions как PostgreSQL-string '{1,2,3}' и ломал PostgresIntArray cast.
|
||||
$projects = Project::on(self::DB_CONNECTION)
|
||||
->join('project_routing_snapshots AS snap', 'snap.project_id', '=', 'projects.id')
|
||||
->whereIn('snap.tenant_id', function ($q): void {
|
||||
$q->select('id')->from('tenants')->whereNull('frozen_by_balance_at');
|
||||
})
|
||||
->orderBy('id')
|
||||
->where('snap.snapshot_date', $tomorrow)
|
||||
->select(
|
||||
'projects.*',
|
||||
'snap.daily_limit AS snap_daily_limit',
|
||||
'snap.delivery_days_mask AS snap_delivery_days_mask',
|
||||
'snap.regions AS snap_regions',
|
||||
)
|
||||
->orderBy('projects.id')
|
||||
->get();
|
||||
|
||||
// Override live fields with snapshot values — slepok semantic.
|
||||
// snap_regions приходит как PostgreSQL-array string ('{77,99}') через append
|
||||
// (не Eloquent-cast), парсим вручную.
|
||||
foreach ($projects as $project) {
|
||||
$project->daily_limit_target = (int) $project->getAttribute('snap_daily_limit');
|
||||
$project->delivery_days_mask = (int) $project->getAttribute('snap_delivery_days_mask');
|
||||
$project->regions = $this->parsePostgresIntArray((string) $project->getAttribute('snap_regions'));
|
||||
}
|
||||
|
||||
return $projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит PostgreSQL int-array literal `'{1,2,3}'` или `'{}'` в PHP `[1,2,3]` / `[]`.
|
||||
* Используется для snap_regions (через raw select), который не подхватывается
|
||||
* Eloquent PostgresIntArray cast'ом (тот цастит только реальное regions column).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function parsePostgresIntArray(string $literal): array
|
||||
{
|
||||
$trimmed = trim($literal, "{} \t\n\r\0\x0B");
|
||||
if ($trimmed === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_map('intval', explode(',', $trimmed)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Helper: вставка snapshot за tomorrow MSK.
|
||||
*/
|
||||
function insertTomorrowSnapshot(
|
||||
Project $project,
|
||||
string $signalType = 'call',
|
||||
?string $signalIdentifier = '79161234567',
|
||||
int $dailyLimit = 10,
|
||||
int $deliveryDaysMask = 127,
|
||||
string $regions = '{}',
|
||||
): void {
|
||||
$tomorrow = Carbon::tomorrow('Europe/Moscow')->toDateString();
|
||||
DB::table('project_routing_snapshots')->insert([
|
||||
'snapshot_date' => $tomorrow,
|
||||
'project_id' => $project->id,
|
||||
'tenant_id' => $project->tenant_id,
|
||||
'daily_limit' => $dailyLimit,
|
||||
'delivery_days_mask' => $deliveryDaysMask,
|
||||
'regions' => $regions,
|
||||
'signal_type' => $signalType,
|
||||
'signal_identifier' => $signalIdentifier,
|
||||
'sms_senders' => null,
|
||||
'sms_keyword' => null,
|
||||
'expected_volume' => $dailyLimit,
|
||||
'delivered_count' => 0,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('reads from snapshot for tomorrow, picks up live-paused project (race 18:02→18:05)', function (): void {
|
||||
Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
// ↓ Клиент paus'нул проект между 18:02 (snapshot) и 18:05 (sync).
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => false, // live state — paused
|
||||
'daily_limit_target' => 10,
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
insertTomorrowSnapshot($project);
|
||||
|
||||
$projects = (new SyncSupplierProjectsJob)->collectEligibleProjects();
|
||||
|
||||
// Изолируем от leftover dev data в pgsql_supplier (SharesSupplierPdo шарит PDO,
|
||||
// но не транзакции — данные между тестами остаются).
|
||||
$ours = $projects->where('id', $project->id);
|
||||
expect($ours)->toHaveCount(1);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('skips project that has NO snapshot for tomorrow (live is_active=true ignored)', function (): void {
|
||||
Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true, // live state — active
|
||||
'daily_limit_target' => 10,
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
// НЕТ snapshot за tomorrow — sync должен пропустить.
|
||||
|
||||
$projects = (new SyncSupplierProjectsJob)->collectEligibleProjects();
|
||||
|
||||
$ours = $projects->where('id', $project->id);
|
||||
expect($ours)->toHaveCount(0);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('overrides daily_limit_target / regions / delivery_days_mask with snapshot values', function (): void {
|
||||
Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 100, // live = 100
|
||||
'delivery_days_mask' => 127, // live = mon-sun
|
||||
]);
|
||||
// Snapshot имеет другой лимит / маску.
|
||||
insertTomorrowSnapshot(
|
||||
$project,
|
||||
dailyLimit: 7, // snapshot = 7
|
||||
deliveryDaysMask: 31, // mon-fri only
|
||||
regions: '{77}', // только Москва
|
||||
);
|
||||
|
||||
$projects = (new SyncSupplierProjectsJob)->collectEligibleProjects();
|
||||
|
||||
$ours = $projects->where('id', $project->id);
|
||||
expect($ours)->toHaveCount(1);
|
||||
$p = $ours->first();
|
||||
expect((int) $p->daily_limit_target)->toBe(7);
|
||||
expect((int) $p->delivery_days_mask)->toBe(31);
|
||||
expect((array) $p->regions)->toBe([77]);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('skips frozen tenants regardless of snapshot presence', function (): void {
|
||||
Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => now()->subDay()]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
insertTomorrowSnapshot($project);
|
||||
|
||||
$projects = (new SyncSupplierProjectsJob)->collectEligibleProjects();
|
||||
|
||||
$ours = $projects->where('id', $project->id);
|
||||
expect($ours)->toHaveCount(0); // frozen tenant — sync должен пропустить
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
@@ -17,10 +17,12 @@ uses(SharesSupplierPdo::class);
|
||||
|
||||
it('excludes projects of frozen tenants from supplier order', function () {
|
||||
$frozenTenant = Tenant::factory()->create(['frozen_by_balance_at' => now()]);
|
||||
Project::factory()->for($frozenTenant)->create(['is_active' => true, 'daily_limit_target' => 50]);
|
||||
$frozenProject = Project::factory()->for($frozenTenant)->create(['is_active' => true, 'daily_limit_target' => 50]);
|
||||
insertSnapshotForTomorrow($frozenProject);
|
||||
|
||||
$activeTenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($activeTenant)->create(['is_active' => true, 'daily_limit_target' => 30]);
|
||||
$activeProject = Project::factory()->for($activeTenant)->create(['is_active' => true, 'daily_limit_target' => 30]);
|
||||
insertSnapshotForTomorrow($activeProject);
|
||||
|
||||
$eligible = app(SyncSupplierProjectsJob::class)->collectEligibleProjects();
|
||||
|
||||
@@ -32,6 +34,7 @@ it('excludes projects of frozen tenants from supplier order', function () {
|
||||
it('excludes individually preflight-blocked projects', function () {
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
$okProject = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 30, 'preflight_blocked_at' => null]);
|
||||
insertSnapshotForTomorrow($okProject);
|
||||
$blocked = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 20, 'preflight_blocked_at' => now()]);
|
||||
|
||||
$eligible = app(SyncSupplierProjectsJob::class)->collectEligibleProjects();
|
||||
|
||||
@@ -63,6 +63,7 @@ test('single-group: regions=[82,83] site → merged regions tag=РФ → 3 suppl
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [82, 83],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project, regions: '{82,83}');
|
||||
|
||||
// One save (merged regions=[82,83] → tag='РФ') + one listProjects
|
||||
Http::fake([
|
||||
@@ -121,6 +122,7 @@ test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 suppl
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
@@ -162,7 +164,7 @@ test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 suppl
|
||||
test('order: 2 projects same source×subject → computeOrder([10,20])=20 split across B1/B2/B3 = 7/7/6', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
$project1 = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
@@ -171,8 +173,9 @@ test('order: 2 projects same source×subject → computeOrder([10,20])=20 split
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project1);
|
||||
|
||||
Project::factory()->create([
|
||||
$project2 = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
@@ -181,6 +184,7 @@ test('order: 2 projects same source×subject → computeOrder([10,20])=20 split
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project2);
|
||||
|
||||
// saveProjectMultiFlag called once (both projects share same group)
|
||||
Http::fake([
|
||||
@@ -216,7 +220,7 @@ test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner
|
||||
// The owner reported (and we verified live 2026-05-21): call limit 18 → 18/18/18 on the
|
||||
// portal = supplier could deliver up to 54. The portal does NOT divide. Fix splits 18 → 6/6/6.
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'call',
|
||||
@@ -225,6 +229,7 @@ test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '4000'], 200),
|
||||
@@ -252,7 +257,7 @@ test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner
|
||||
test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'sms',
|
||||
@@ -263,6 +268,7 @@ test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', functi
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project, signalType: 'sms', signalIdentifier: null);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
@@ -293,7 +299,7 @@ test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', functi
|
||||
test('sms without keyword → platform B3 only (1 supplier_project)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'sms',
|
||||
@@ -304,6 +310,7 @@ test('sms without keyword → platform B3 only (1 supplier_project)', function (
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project, signalType: 'sms', signalIdentifier: null);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
@@ -335,7 +342,7 @@ test('sms without keyword → platform B3 only (1 supplier_project)', function (
|
||||
test('idempotent: repeat run with no changes → updateProject not duplicate', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
@@ -344,6 +351,7 @@ test('idempotent: repeat run with no changes → updateProject not duplicate', f
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
|
||||
// First run: create
|
||||
Http::fake([
|
||||
@@ -395,7 +403,7 @@ test('respects time budget by stopping at 20:55 МСК', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow'));
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
@@ -404,6 +412,7 @@ test('respects time budget by stopping at 20:55 МСК', function (): void {
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
|
||||
Http::fake();
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
@@ -416,7 +425,7 @@ test('sticky auth error throws and sends critical alert email', function (): voi
|
||||
Bus::fake([RefreshSupplierSessionJob::class]);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
@@ -425,6 +434,7 @@ test('sticky auth error throws and sends critical alert email', function (): voi
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/*' => Http::response('Unauthorized', 401),
|
||||
@@ -443,7 +453,7 @@ test('aborts after 50 consecutive transient failures and sends alert', function
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
for ($i = 1; $i <= 60; $i++) {
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
@@ -452,6 +462,7 @@ test('aborts after 50 consecutive transient failures and sends alert', function
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
}
|
||||
|
||||
Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]);
|
||||
@@ -466,7 +477,7 @@ test('aborts after 50 consecutive transient failures and sends alert', function
|
||||
test('writes supplier_sync_log row for each successful action', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
@@ -475,6 +486,7 @@ test('writes supplier_sync_log row for each successful action', function (): voi
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
@@ -516,7 +528,7 @@ test('nightly: re-creates donor on portal when its external_id no longer exists
|
||||
// external_id in our DB → updateProject is a silent no-op → donor never re-created.
|
||||
// Nightly reconciler must detect missing donors (listProjects) and re-create in-place.
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'call',
|
||||
@@ -525,6 +537,7 @@ test('nightly: re-creates donor on portal when its external_id no longer exists
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
|
||||
foreach (['B1', 'B2', 'B3'] as $platform) {
|
||||
SupplierProject::on('pgsql_supplier')->forceCreate([
|
||||
|
||||
@@ -89,6 +89,41 @@ function linkProjectToSupplier(Project $project, SupplierProject $supplier): voi
|
||||
* `$project->signal_type` — на Windows-native PG факториальный override
|
||||
* этого поля не персистится (см. memory project_slepok_protection.md).
|
||||
*/
|
||||
/**
|
||||
* Pest helper для SyncSupplierProjectsJob тестов (Task 2.9).
|
||||
*
|
||||
* Вставляет snapshot в `project_routing_snapshots` за активную дату слепка
|
||||
* для tomorrow МСК (cron 18:02 МСК ежедневно создаёт slepok на завтра).
|
||||
*
|
||||
* После Task 2.9 sync-job читает snapshot, не live `projects.is_active` —
|
||||
* без снимка проект не попадает в группировку для подачи поставщику.
|
||||
*/
|
||||
function insertSnapshotForTomorrow(
|
||||
Project $project,
|
||||
string $signalType = 'call',
|
||||
?string $signalIdentifier = '79161234567',
|
||||
?int $dailyLimit = null,
|
||||
?int $deliveryDaysMask = null,
|
||||
string $regions = '{}',
|
||||
): void {
|
||||
$tomorrow = \Carbon\Carbon::tomorrow('Europe/Moscow')->toDateString();
|
||||
DB::table('project_routing_snapshots')->insert([
|
||||
'snapshot_date' => $tomorrow,
|
||||
'project_id' => $project->id,
|
||||
'tenant_id' => $project->tenant_id,
|
||||
'daily_limit' => $dailyLimit ?? (int) ($project->daily_limit_target ?? 10),
|
||||
'delivery_days_mask' => $deliveryDaysMask ?? (int) ($project->delivery_days_mask ?? 127),
|
||||
'regions' => $regions,
|
||||
'signal_type' => $signalType,
|
||||
'signal_identifier' => $signalIdentifier,
|
||||
'sms_senders' => null,
|
||||
'sms_keyword' => null,
|
||||
'expected_volume' => $dailyLimit ?? (int) ($project->daily_limit_target ?? 10),
|
||||
'delivered_count' => 0,
|
||||
'created_at' => \Illuminate\Support\Facades\Date::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
function createRoutingSnapshotFromProject(
|
||||
Project $project,
|
||||
?string $date = null,
|
||||
|
||||
Reference in New Issue
Block a user