f01f9fa289
Прод-баг: 5 из 9 работающих проектов на бою показывали жёлтое «Готовим
к запуску», хотя заказ у поставщика реально стоял и лиды шли. У трёх
клиентов; самый старый врал 13 дней.
Причина. Связь проекта с заказом хранится в двух местах: три legacy-колонки
supplier_b{1,2,3}_project_id и pivot project_supplier_links. Статус читался
ТОЛЬКО из колонок. При этом ночной SyncSupplierProjectsJob — единственный,
кто в режиме batch реально заводит заказ, — пишет ТОЛЬКО в pivot и колонок
не касается вообще. Колонки заполняет лишь SyncSupplierProjectJob и только
если заказ УЖЕ существует в момент запуска; при создании проекта заказа ещё
нет, он появится в 18:00. Итог: колонки остаются пустыми навсегда, пока
клиент сам не дёрнет проект — пауза, снятие с паузы, «Синхронизировать»
или правка настроек.
Правка. resolvedSupplierProjects() возвращает объединение pivot и трёх
legacy-колонок без дублей. Legacy читаем дальше: handleBatch пишет колонку
без pivot-строки, такие проекты терять нельзя. getSupplierLinks() берёт
площадку из самой строки заказа. Списку и карточке добавлен eager-load
supplierProjects — иначе N+1 на каждую карточку.
Данные править не нужно: связки в pivot уже лежат, пятёрка чинится сама
в момент выката.
Тесты: 9 новых, 5 из них падали до правки. Полный прогон 3569 тестов:
3563 зелёных, 2 падения — чужие, в ClientTg\InputLengthFixTest, красные
и без этой правки.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
204 lines
8.1 KiB
PHP
204 lines
8.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\Project;
|
||
use App\Models\SupplierProject;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||
|
||
/**
|
||
* Прод-баг 29.07.2026: карточка проекта показывала «Готовим к запуску» (жёлтое)
|
||
* у проектов, чей заказ у поставщика реально стоит. 5 из 9 работающих проектов
|
||
* на бою (t2 ×3, t9, t10), самый старый врал 13 дней.
|
||
*
|
||
* Причина: aggregateSyncStatus() читал ТОЛЬКО три legacy-колонки
|
||
* supplier_b{1,2,3}_project_id, а ночной SyncSupplierProjectsJob (единственный,
|
||
* кто в режиме batch реально заводит заказ) пишет ТОЛЬКО в pivot
|
||
* project_supplier_links и этих колонок не касается вообще.
|
||
*
|
||
* Здесь фиксируем: источник истины для статуса — pivot; legacy-колонки читаются
|
||
* дополнительно (объединение), чтобы не потерять проекты, у которых заполнены
|
||
* только они (handleBatch пишет колонку без pivot-строки).
|
||
*/
|
||
function spOk(string $platform, string $key, string $status = 'ok', ?string $syncedAt = '2026-07-28 15:05:00'): SupplierProject
|
||
{
|
||
return SupplierProject::query()->create([
|
||
'platform' => $platform,
|
||
'signal_type' => 'call',
|
||
'unique_key' => $key,
|
||
'subject_code' => null,
|
||
'current_limit' => 7,
|
||
'sync_status' => $status,
|
||
'last_synced_at' => $syncedAt,
|
||
]);
|
||
}
|
||
|
||
function linkPivot(Project $project, SupplierProject $sp): void
|
||
{
|
||
DB::table('project_supplier_links')->insertOrIgnore([
|
||
'project_id' => $project->id,
|
||
'supplier_project_id' => $sp->id,
|
||
'platform' => $sp->platform,
|
||
'subject_code' => null,
|
||
]);
|
||
}
|
||
|
||
it('reports ok when the order exists in the pivot and legacy columns are empty', function (): void {
|
||
$project = Project::factory()->create([
|
||
'signal_type' => 'call',
|
||
'signal_identifier' => '79990000001',
|
||
'supplier_b1_project_id' => null,
|
||
'supplier_b2_project_id' => null,
|
||
'supplier_b3_project_id' => null,
|
||
]);
|
||
DB::table('project_supplier_links')->where('project_id', $project->id)->delete();
|
||
|
||
foreach (['B1', 'B2', 'B3'] as $platform) {
|
||
linkPivot($project, spOk($platform, '79990000001'));
|
||
}
|
||
|
||
// Состояние, до которого доехал прод: колонки пустые, связки есть.
|
||
expect($project->fresh()->supplier_b1_project_id)->toBeNull()
|
||
->and(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
|
||
|
||
expect($project->fresh()->aggregateSyncStatus())->toBe('ok');
|
||
});
|
||
|
||
it('reports pending when a pivot-linked order is still pending', function (): void {
|
||
$project = Project::factory()->create([
|
||
'signal_type' => 'call',
|
||
'signal_identifier' => '79001112233',
|
||
'supplier_b1_project_id' => null,
|
||
'supplier_b2_project_id' => null,
|
||
'supplier_b3_project_id' => null,
|
||
]);
|
||
DB::table('project_supplier_links')->where('project_id', $project->id)->delete();
|
||
|
||
linkPivot($project, spOk('B1', '79001112233'));
|
||
linkPivot($project, spOk('B2', '79001112233', 'pending', null));
|
||
|
||
expect($project->fresh()->aggregateSyncStatus())->toBe('pending');
|
||
});
|
||
|
||
it('reports failed when any pivot-linked order failed', function (): void {
|
||
$project = Project::factory()->create([
|
||
'signal_type' => 'call',
|
||
'signal_identifier' => '79002223344',
|
||
'supplier_b1_project_id' => null,
|
||
'supplier_b2_project_id' => null,
|
||
'supplier_b3_project_id' => null,
|
||
]);
|
||
DB::table('project_supplier_links')->where('project_id', $project->id)->delete();
|
||
|
||
linkPivot($project, spOk('B1', '79002223344'));
|
||
linkPivot($project, spOk('B2', '79002223344', 'failed'));
|
||
|
||
expect($project->fresh()->aggregateSyncStatus())->toBe('failed');
|
||
});
|
||
|
||
it('still reports pending when there is no order at all', function (): void {
|
||
$project = Project::factory()->create([
|
||
'signal_type' => 'call',
|
||
'signal_identifier' => '79003334455',
|
||
'supplier_b1_project_id' => null,
|
||
'supplier_b2_project_id' => null,
|
||
'supplier_b3_project_id' => null,
|
||
]);
|
||
DB::table('project_supplier_links')->where('project_id', $project->id)->delete();
|
||
|
||
expect($project->fresh()->aggregateSyncStatus())->toBe('pending');
|
||
});
|
||
|
||
it('still reports ok for legacy projects that only have the old columns filled', function (): void {
|
||
$sp = spOk('B1', '79004445566');
|
||
$project = Project::factory()->create([
|
||
'signal_type' => 'call',
|
||
'signal_identifier' => '79004445566',
|
||
'supplier_b1_project_id' => $sp->id,
|
||
]);
|
||
DB::table('project_supplier_links')->where('project_id', $project->id)->delete();
|
||
|
||
expect($project->fresh()->aggregateSyncStatus())->toBe('ok');
|
||
});
|
||
|
||
it('lists supplier links from the pivot when legacy columns are empty', function (): void {
|
||
$project = Project::factory()->create([
|
||
'signal_type' => 'call',
|
||
'signal_identifier' => '79005556677',
|
||
'supplier_b1_project_id' => null,
|
||
'supplier_b2_project_id' => null,
|
||
'supplier_b3_project_id' => null,
|
||
]);
|
||
DB::table('project_supplier_links')->where('project_id', $project->id)->delete();
|
||
|
||
foreach (['B1', 'B2', 'B3'] as $platform) {
|
||
linkPivot($project, spOk($platform, '79005556677'));
|
||
}
|
||
|
||
$links = $project->fresh()->getSupplierLinks();
|
||
|
||
expect($links)->toHaveCount(3)
|
||
->and(collect($links)->pluck('platform')->sort()->values()->all())->toBe(['b1', 'b2', 'b3'])
|
||
->and(collect($links)->pluck('sync_status')->unique()->all())->toBe(['ok']);
|
||
});
|
||
|
||
it('takes the earliest sync time across pivot-linked orders', function (): void {
|
||
$project = Project::factory()->create([
|
||
'signal_type' => 'call',
|
||
'signal_identifier' => '79006667788',
|
||
'supplier_b1_project_id' => null,
|
||
'supplier_b2_project_id' => null,
|
||
'supplier_b3_project_id' => null,
|
||
]);
|
||
DB::table('project_supplier_links')->where('project_id', $project->id)->delete();
|
||
|
||
linkPivot($project, spOk('B1', '79006667788', 'ok', '2026-07-28 15:05:00'));
|
||
linkPivot($project, spOk('B2', '79006667788', 'ok', '2026-07-26 15:05:00'));
|
||
|
||
expect((string) $project->fresh()->aggregateLastSyncedAt())->toStartWith('2026-07-26');
|
||
});
|
||
|
||
it('does not double-count an order linked both by pivot and by a legacy column', function (): void {
|
||
$sp = spOk('B1', '79007778899');
|
||
$project = Project::factory()->create([
|
||
'signal_type' => 'call',
|
||
'signal_identifier' => '79007778899',
|
||
'supplier_b1_project_id' => $sp->id,
|
||
]);
|
||
DB::table('project_supplier_links')->where('project_id', $project->id)->delete();
|
||
linkPivot($project, $sp);
|
||
|
||
expect($project->fresh()->getSupplierLinks())->toHaveCount(1);
|
||
});
|
||
|
||
it('shows the project as collecting leads over the API when only the pivot is filled', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||
$project = Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'signal_type' => 'call',
|
||
'signal_identifier' => '79008889900',
|
||
'is_active' => true,
|
||
'preflight_blocked_at' => null,
|
||
'supplier_b1_project_id' => null,
|
||
'supplier_b2_project_id' => null,
|
||
'supplier_b3_project_id' => null,
|
||
]);
|
||
DB::table('project_supplier_links')->where('project_id', $project->id)->delete();
|
||
|
||
foreach (['B1', 'B2', 'B3'] as $platform) {
|
||
linkPivot($project, spOk($platform, '79008889900'));
|
||
}
|
||
|
||
$response = $this->actingAs($user)->getJson('/api/projects');
|
||
|
||
$row = collect($response->json('data'))->firstWhere('id', $project->id);
|
||
expect($row['sync_status'])->toBe('ok');
|
||
});
|