From f01f9fa289a4f845debb427cc1bee0f542246c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 29 Jul 2026 17:02:44 +0300 Subject: [PATCH] =?UTF-8?q?fix(=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D1=8B)?= =?UTF-8?q?:=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D1=87=D0=B8=D1=82=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D1=81=D0=B2=D1=8F=D0=B7=D0=BA=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B2=D1=89=D0=B8=D0=BA=D0=B0,=20=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=82?= =?UTF-8?q?=D1=80=D0=B8=20=D1=81=D1=82=D0=B0=D1=80=D1=8B=D0=B5=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прод-баг: 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) --- .../Controllers/Api/ProjectController.php | 4 +- app/app/Models/Project.php | 33 ++- .../ProjectSyncStatusFromPivotTest.php | 203 ++++++++++++++++++ 3 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 app/tests/Feature/Project/ProjectSyncStatusFromPivotTest.php diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index ab8e9bcf..87fa089b 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -38,7 +38,7 @@ class ProjectController extends Controller public function index(Request $request): JsonResponse { $query = Project::query() - ->with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 in aggregation helpers + ->with(['supplierProjects', 'supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 in aggregation helpers ->withCount('supplierProjects') // ProjectResource::source_locked — анти-N+1 (hasLinks без per-row запроса) ->where('tenant_id', $request->user()->tenant_id); @@ -185,7 +185,7 @@ class ProjectController extends Controller /** GET /api/projects/{id} */ public function show(Request $request, int $id): JsonResponse { - $project = Project::with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 + $project = Project::with(['supplierProjects', 'supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 ->withCount('supplierProjects') // ProjectResource::source_locked — анти-N+1 ->where('tenant_id', $request->user()->tenant_id) ->findOrFail($id); diff --git a/app/app/Models/Project.php b/app/app/Models/Project.php index e4ec8410..4728bcf5 100644 --- a/app/app/Models/Project.php +++ b/app/app/Models/Project.php @@ -154,20 +154,32 @@ class Project extends Model } /** - * Все связанные SupplierProject из eager-loaded BelongsTo отношений. + * Все связанные SupplierProject: pivot project_supplier_links ПЛЮС три legacy-слота + * supplier_b{1,2,3}_project_id, объединение без дублей. * - * Используется внутри aggregateSyncStatus(), aggregateLastSyncedAt(), - * getSupplierLinks() — устраняет N+1 (каждый из трёх методов вызывал - * SupplierProject::find() независимо; теперь читает из уже загруженных - * $this->supplierB1 / supplierB2 / supplierB3). + * 🔴 Почему pivot обязателен (прод-баг 29.07.2026): ночной SyncSupplierProjectsJob — + * единственный, кто в режиме batch реально заводит заказ у поставщика — пишет ТОЛЬКО + * в pivot и legacy-колонок не касается. Заполняет их лишь SyncSupplierProjectJob, и + * только если заказ УЖЕ существует в момент запуска; при создании проекта заказа ещё + * нет (он появится в 18:00), поэтому колонки остаются пустыми навсегда — пока клиент + * сам не дёрнет проект (пауза / снятие с паузы / «Синхронизировать» / правка). + * Пока статус читался только из колонок, 5 из 9 работающих проектов на бою показывали + * жёлтое «Готовим к запуску» при живом заказе (самый старый — 13 дней). * - * Требует eager-load: Project::with(['supplierB1', 'supplierB2', 'supplierB3']). + * Legacy-слоты продолжаем читать: handleBatch пишет колонку без pivot-строки, такие + * проекты терять нельзя. + * + * Требует eager-load: Project::with(['supplierProjects', 'supplierB1', 'supplierB2', 'supplierB3']). * * @return Collection */ private function resolvedSupplierProjects(): Collection { - return collect([$this->supplierB1, $this->supplierB2, $this->supplierB3])->filter()->values(); + return collect([$this->supplierB1, $this->supplierB2, $this->supplierB3]) + ->filter() + ->merge($this->supplierProjects) + ->unique('id') + ->values(); } /** @@ -223,10 +235,9 @@ class Project extends Model */ public function getSupplierLinks(): array { - return collect(['b1' => $this->supplierB1, 'b2' => $this->supplierB2, 'b3' => $this->supplierB3]) - ->filter() - ->map(fn (SupplierProject $sp, string $platform) => [ - 'platform' => $platform, + return $this->resolvedSupplierProjects() + ->map(fn (SupplierProject $sp) => [ + 'platform' => strtolower((string) $sp->platform), 'supplier_project_id' => $sp->id, 'sync_status' => $sp->sync_status, 'last_synced_at' => $sp->last_synced_at?->toIso8601String(), diff --git a/app/tests/Feature/Project/ProjectSyncStatusFromPivotTest.php b/app/tests/Feature/Project/ProjectSyncStatusFromPivotTest.php new file mode 100644 index 00000000..02a34daa --- /dev/null +++ b/app/tests/Feature/Project/ProjectSyncStatusFromPivotTest.php @@ -0,0 +1,203 @@ +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'); +});