Files
portal/app/tests/Feature/Project/ProjectSyncStatusFromPivotTest.php
T
Дмитрий f01f9fa289 fix(проекты): статус карточки читает связки поставщика, а не только три старые колонки
Прод-баг: 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>
2026-07-29 17:04:38 +03:00

204 lines
8.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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');
});