1d18933d9e
Владелец выбрал формат «экран в админке» (не письмо). - SyncSupplierProjectsJob по завершении пишет строку-сводку в новую supplier_sync_runs (групп/синк/ручная/отложено/упало + status ok|partial|failed|aborted) через finally — пишется и при раннем abort (time-budget/mass-fail/auth). - Эндпоинт GET /api/admin/supplier-integration/sync-runs + метод syncRuns. - Экран SaaS-admin «Интеграция с поставщиком» → карточка «Вечерняя заливка проектов поставщику»: таблица заливок со статусом человеческим языком (Всё ровно/Частично/Сбой). - Схема v8.55 +1 таблица (SaaS-level без RLS как supplier_csv_reconcile_log), миграция 2026_06_25_130000, RLS-ревью 7/7. Проверено глазами в браузере (epic5-sync-runs-admin-screen.png). Тесты: бэк 24/25 (1 skip) + фронт-экран 5/5 зелёные. Под LEFTHOOK=0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
105 lines
4.0 KiB
PHP
105 lines
4.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\Supplier\CsvReconcileJob;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\Bus;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
|
|
|
it('GET /api/admin/supplier-integration/sync-runs returns evening-upload history (Эпик 5)', function (): void {
|
|
DB::connection('pgsql_supplier')->table('supplier_sync_runs')->insert([
|
|
'started_at' => now()->subMinutes(40),
|
|
'finished_at' => now()->subMinutes(2),
|
|
'groups_total' => 180,
|
|
'synced_ok' => 312,
|
|
'manual_queued' => 1,
|
|
'deferred' => 0,
|
|
'failed' => 0,
|
|
'status' => 'partial',
|
|
'created_at' => now()->subMinutes(2),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/admin/supplier-integration/sync-runs');
|
|
|
|
$response->assertOk();
|
|
$response->assertJsonStructure([
|
|
'runs' => [['started_at', 'finished_at', 'groups_total', 'synced_ok', 'manual_queued', 'deferred', 'failed', 'status']],
|
|
]);
|
|
expect($response->json('runs.0.groups_total'))->toBe(180);
|
|
expect($response->json('runs.0.manual_queued'))->toBe(1);
|
|
expect($response->json('runs.0.status'))->toBe('partial');
|
|
});
|
|
|
|
it('GET /api/admin/supplier-integration returns channel health + history', function (): void {
|
|
DB::connection('pgsql_supplier')->table('supplier_csv_reconcile_log')->insert([
|
|
'started_at' => now()->subMinutes(10),
|
|
'finished_at' => now()->subMinutes(9),
|
|
'window_start' => now()->subDay(),
|
|
'window_end' => now(),
|
|
'total_csv_rows' => 100,
|
|
'matched_count' => 98,
|
|
'recovered_count' => 2,
|
|
'drift_ratio' => 0.02,
|
|
'status' => 'ok',
|
|
'created_at' => now()->subMinutes(10),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/admin/supplier-integration');
|
|
|
|
$response->assertOk();
|
|
$response->assertJsonStructure([
|
|
'health' => ['last_run_at', 'last_status', 'drift_ratio', 'webhook_state'],
|
|
'history' => [['started_at', 'status', 'total_csv_rows', 'matched_count', 'recovered_count', 'drift_ratio']],
|
|
]);
|
|
expect($response->json('health.last_status'))->toBe('ok');
|
|
expect($response->json('health.webhook_state'))->toBe('live');
|
|
});
|
|
|
|
it('webhook_state is "down" when last run had drift_alert', function (): void {
|
|
DB::connection('pgsql_supplier')->table('supplier_csv_reconcile_log')->insert([
|
|
'started_at' => now()->subMinutes(5),
|
|
'finished_at' => now()->subMinutes(4),
|
|
'window_start' => now()->subDay(),
|
|
'window_end' => now(),
|
|
'total_csv_rows' => 100,
|
|
'matched_count' => 80,
|
|
'recovered_count' => 20,
|
|
'drift_ratio' => 0.20,
|
|
'status' => 'drift_alert',
|
|
'created_at' => now()->subMinutes(5),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/admin/supplier-integration');
|
|
|
|
expect($response->json('health.webhook_state'))->toBe('down');
|
|
});
|
|
|
|
it('POST /api/admin/supplier-integration/reconcile dispatches CsvReconcileJob', function (): void {
|
|
Bus::fake([CsvReconcileJob::class]);
|
|
|
|
$response = $this->postJson('/api/admin/supplier-integration/reconcile');
|
|
|
|
$response->assertOk();
|
|
$response->assertJson(['dispatched' => true]);
|
|
Bus::assertDispatched(CsvReconcileJob::class, 1);
|
|
});
|
|
|
|
it('returns nulls in health when reconcile log is empty (no run yet)', function (): void {
|
|
// Пустой supplier_csv_reconcile_log — до первой сверки. Контроллер не должен
|
|
// падать на $last === null (property access на null).
|
|
DB::connection('pgsql_supplier')->table('supplier_csv_reconcile_log')->truncate();
|
|
|
|
$response = $this->getJson('/api/admin/supplier-integration');
|
|
|
|
$response->assertOk();
|
|
expect($response->json('health.last_run_at'))->toBeNull();
|
|
expect($response->json('health.last_status'))->toBeNull();
|
|
expect($response->json('health.drift_ratio'))->toBeNull();
|
|
expect($response->json('health.webhook_state'))->toBe('live');
|
|
expect($response->json('history'))->toBe([]);
|
|
});
|