fix(supplier): пауза доходит до поставщика в batch — ночной джоб 18:00 гасит осиротевшие заказы
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled

Боевой в batch-режиме: пауза проекта не доходила до поставщика — заказ висел активным
(галочка вкл, лимит>0), поставщик мог лить лиды и списывать за остановленный проект
(подтв. живой базой: omega, 57 заказов, 0 команд после паузы 08.07).

Поставщик фиксирует завтрашнюю заливку снимком в 21:00, поэтому гасить надо в 18:00-прогоне
ДО снимка (мгновенно бессмысленно — сегодня уже зафиксировано). Добавлен проход
deactivateOrphanedOrders в SyncSupplierProjectsJob: заказам источников, которых нет в
активных группах слепка за завтра, шлётся updateProject(status=paused) с ненулевым лимитом,
локально помечается inactive_since. online-режим не тронут (там пауза уже мгновенна).
Активность берётся из слепка (слепок-инвариант), не из live is_active.

Тесты (TDD RED->GREEN): batch pause + стык с delete-хвостом. 19/19 supplier+delete,
238/238 supplier+projects, pint чисто, PHPStan без новых замечаний.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-09 05:35:19 +03:00
parent 9799c0eb0d
commit dfa14862b4
2 changed files with 218 additions and 0 deletions
@@ -134,6 +134,10 @@ class SyncSupplierProjectsJob implements ShouldQueue
$groups[$key]['projects'][] = $project;
}
// Активные сегодня источники (по слепку) — их заказы НЕ гасим в deactivation-проходе.
/** @var array<string, bool> $activeKeys */
$activeKeys = array_fill_keys(array_keys($groups), true);
// 3. Sync each group
try {
foreach ($groups as $group) {
@@ -192,6 +196,13 @@ class SyncSupplierProjectsJob implements ShouldQueue
continue;
}
}
// 4. Deactivation (money-critical, 18:00 ДО снимка поставщика в 21:00): гасим
// заказы источников, у которых на сегодняшний слепок не осталось ни одного
// активного проекта. Пропускаем при aborted (time-budget/mass-fail/auth).
if (! $aborted) {
$this->deactivateOrphanedOrders($activeKeys);
}
} finally {
$this->recordRunSummary(
startedAt: $startedAt,
@@ -205,6 +216,103 @@ class SyncSupplierProjectsJob implements ShouldQueue
}
}
/**
* Deactivation (18:00, money-critical owner-reported 2026-07-08, verified on prod live DB):
* гасим у поставщика заказы источников, у которых на СЕГОДНЯШНИЙ слепок не осталось ни
* одного активного проекта. Поставщик фиксирует завтрашнюю заливку своим снимком в 21:00,
* поэтому единственное окно остановить заказ этот 18:00-прогон ДО снимка. Без этого
* осиротевший заказ оставался включённым (галочка вкл, лимит > 0) поставщик лил лиды и
* списывал за остановленный клиентом проект.
*
* Активность из СЛЕПКА ($activeKeys = ключи sync-групп за завтра), НЕ из live is_active:
* слепок-инвариант проект, paused'нутый уже ПОСЛЕ снятия слепка, честно доезжает
* поставщику (см. collectEligibleProjects); гасим только то, чего в слепке нет.
*
* Осиротевший = supplier_project с external_id, inactive_since IS NULL, чей
* (signal_type|unique_key) не входит в $activeKeys. Шлём updateProject(status='paused') с
* ненулевым лимитом (кабинет отклоняет limit=0 даже при паузе), помечаем inactive_since
* ТОЛЬКО после успешной отправки (иначе ретрай следующим прогоном). Реактивацию при
* возврате проекта делают CleanupInactiveSupplierProjectsJob (Phase A) + syncGroup(active).
*
* @param array<string, bool> $activeKeys ключи «signal_type|identifier» активных групп
*/
private function deactivateOrphanedOrders(array $activeKeys): int
{
$paused = 0;
// get() (не cursor) — на shared PDO тестов курсор конфликтует с write'ами в цикле;
// набор ограничен (активные + ещё-не-погашенные осиротевшие заказы).
$candidates = SupplierProject::on(self::DB_CONNECTION)
->whereNotNull('supplier_external_id')
->whereNull('inactive_since')
->orderBy('id')
->get();
foreach ($candidates as $sp) {
if (isset($activeKeys[$sp->signal_type.'|'.$sp->unique_key])) {
continue; // источник ещё активен в сегодняшнем слепке — не трогаем
}
if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
break; // время вышло — недогашенное подберёт следующий прогон
}
$regions = array_map('intval', (array) ($sp->current_regions ?? []));
$workdays = $sp->current_workdays !== null && $sp->current_workdays !== []
? array_map('intval', (array) $sp->current_workdays)
: [1, 2, 3, 4, 5, 6, 7];
$tag = count($regions) === 1
? (RussianRegions::CODE_TO_NAME[$regions[0]] ?? (string) $regions[0])
: 'РФ';
$dto = new SupplierProjectDto(
platform: $sp->platform,
signalType: (string) $sp->signal_type,
uniqueKey: (string) $sp->unique_key,
limit: max(1, (int) $sp->current_limit),
workdays: $workdays,
regions: $regions,
regionsReverse: false,
status: 'paused',
tag: $tag,
platforms: [$sp->platform],
);
try {
$this->channel->updateProject((int) $sp->supplier_external_id, $dto);
} catch (Throwable $e) {
// Transient/portal error — inactive_since НЕ ставим, ретрай следующим прогоном.
Log::warning('supplier.sync.deactivate_failed', [
'unique_key' => (string) $sp->unique_key,
'platform' => $sp->platform,
'error' => $e->getMessage(),
]);
continue;
}
$sp->forceFill([
'sync_status' => 'ok',
'last_synced_at' => now(),
'inactive_since' => now(),
])->save();
SupplierSyncLog::on(self::DB_CONNECTION)->create([
'supplier_project_id' => $sp->id,
'action' => 'update',
'http_status' => 200,
'created_at' => now(),
]);
$paused++;
}
if ($paused > 0) {
Log::info('supplier.sync.deactivated_orphaned', ['count' => $paused]);
}
return $paused;
}
/**
* Эпик 5: одна строка-сводка в supplier_sync_runs по завершении заливки.
* Пишется и при раннем abort (time-budget / mass-fail / auth) через finally.
@@ -577,3 +577,113 @@ test('nightly: re-creates donor on portal when its external_id no longer exists
expect($sps)->toHaveCount(3);
expect($sps->pluck('supplier_external_id')->all())->toBe(['8001', '8002', '8003']);
});
// ---------------------------------------------------------------------------
// Deactivation (18:00): turn OFF orders whose source has no active project left.
// Money-critical (owner-reported 2026-07-08, verified on prod live DB): prod runs
// in BATCH mode. The supplier fixes tomorrow's delivery by its 21:00 snapshot, so the
// ONLY window to STOP an order is the 18:00 batch sync — before that snapshot. This
// nightly job only processed ACTIVE projects and never turned OFF a source whose
// projects are all now paused → the paused order stayed active at the supplier → next
// day leads (and charges) kept coming. The 18:00 job must push status=paused for such
// orphaned orders before 21:00.
// ---------------------------------------------------------------------------
test('nightly 18:00: a source with no active project left is turned OFF (status=paused) at the supplier', function (): void {
$tenant = Tenant::factory()->create();
$common = '79997778899';
// Project is PAUSED → excluded from tomorrow's snapshot (SnapshotProjectRoutingJob
// filters is_active=true). Deliberately NO insertSnapshotForTomorrow — nothing active
// references this source tonight, so its supplier order is orphaned.
Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => false, 'signal_type' => 'call',
'signal_identifier' => $common, 'daily_limit_target' => 6, 'delivery_days_mask' => 127, 'regions' => [],
]);
// Pre-seed the supplier orders created earlier when the project was live (numeric
// external ids so updateProject's (int) cast keeps id != 0).
foreach (['B1' => '90001', 'B2' => '90002', 'B3' => '90003'] as $platform => $ext) {
SupplierProject::on('pgsql_supplier')->create([
'platform' => $platform, 'signal_type' => 'call', 'unique_key' => $common,
'subject_code' => null, 'supplier_external_id' => $ext, 'current_limit' => 2,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], 'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
}
$savePayloads = [];
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$savePayloads) {
$savePayloads[] = $request->data();
return Http::response(['status' => 'OK', 'message' => '', 'id' => (string) ($request->data()['id'] ?? 0)], 200);
},
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => []], 200),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
// The 3 orphaned orders were turned OFF: update (id != 0) with status=false.
$offCalls = array_values(array_filter(
$savePayloads,
fn ($p) => (int) ($p['id'] ?? 0) !== 0 && ($p['status'] ?? null) === false
));
expect($offCalls)->toHaveCount(3);
// Non-zero limit kept (portal rejects limit=0 even when paused).
foreach ($offCalls as $p) {
expect((int) $p['limit'])->toBeGreaterThan(0);
}
// Local rows marked inactive_since so cleanup/UI reflect the pause.
expect(SupplierProject::on('pgsql_supplier')->where('unique_key', $common)->whereNotNull('inactive_since')->count())->toBe(3);
});
test('nightly 18:00: a deleted project leftover order is turned OFF but KEPT in DB while its snapshot tail still routes', function (): void {
// Composition of the new deactivation pass with delete-defer (DeleteSupplierProjectTailGuardTest):
// when a project is deleted but its donor is deferred (so already-ordered tail leads still
// route via the snapshot), the 18:00 pass must still turn the order OFF at the supplier
// (stop FUTURE ordering) — WITHOUT deleting the local donor (the tail still needs it).
$tenant = Tenant::factory()->create();
$common = '79996665544';
// Donor exists (created when the project was live). Project already hard-deleted → no pivot
// links, and NOT in tomorrow's active snapshot.
$sp = SupplierProject::on('pgsql_supplier')->create([
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => $common,
'subject_code' => null, 'supplier_external_id' => '95001', 'current_limit' => 3,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], 'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
// Tail snapshot for the source (fake project_id → not an active project, mirrors the
// delete tail-guard fixture). LeadRouter still needs the donor for tail matching.
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => Carbon::tomorrow('Europe/Moscow')->toDateString(),
'project_id' => 987654, 'tenant_id' => $tenant->id, 'daily_limit' => 5,
'delivery_days_mask' => 127, 'regions' => '{}', 'signal_type' => 'call',
'signal_identifier' => $common, 'sms_senders' => null, 'sms_keyword' => null,
'expected_volume' => 5, 'delivered_count' => 0, 'created_at' => now(),
]);
$savePayloads = [];
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$savePayloads) {
$savePayloads[] = $request->data();
return Http::response(['status' => 'OK', 'message' => '', 'id' => (string) ($request->data()['id'] ?? 0)], 200);
},
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => []], 200),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
// Order turned OFF at the supplier (update id != 0, status=false) — stops future ordering.
$off = array_values(array_filter(
$savePayloads,
fn ($p) => (int) ($p['id'] ?? 0) !== 0 && ($p['status'] ?? null) === false
));
expect($off)->toHaveCount(1);
// The local donor record is KEPT (not deleted) so the snapshot tail still routes; marked inactive.
$fresh = SupplierProject::on('pgsql_supplier')->find($sp->id);
expect($fresh)->not->toBeNull();
expect($fresh->inactive_since)->not->toBeNull();
});