From 1bd33c09cf5f73edd5ec2445775b519bb94ef4d1 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: Sat, 1 Aug 2026 14:21:21 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0?= =?UTF-8?q?=D1=82=20=D1=87=D1=83=D0=B6=D0=BE=D0=B9=20=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D1=8B,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=83?= =?UTF-8?q?=D1=8E=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82=D0=B8=D0=BB=D0=BE=20?= =?UTF-8?q?=D0=BC=D0=BE=D1=91=20=D1=81=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перед слиянием с main я откатил 4 файла к своей старой версии, чтобы сдвинуть с места застрявшее слияние — и слияние закрепило этот откат. Пострадала починка от 29.07: чтение связок поставщика из pivot (без неё 5 из 9 работающих проектов показывали жёлтое «Готовим к запуску» при живом заказе) и два набора тестов вебхука/сверки CSV. Файлы возвращены к состоянию main 7361d1ae3. На боевой откат НЕ уезжал — выкат был точечный, только 6 файлов воронки продаж; проверено на живом сервере: починка там на месте. --- .../Controllers/Api/ProjectController.php | 4 +- app/app/Models/Project.php | 33 ++- .../Http/Webhook/SupplierWebhookTest.php | 41 +++- .../Feature/Supplier/CsvReconcileJobTest.php | 193 ++++++++++++++++++ docs/observer/STATUS.md | 22 +- 5 files changed, 265 insertions(+), 28 deletions(-) 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/Http/Webhook/SupplierWebhookTest.php b/app/tests/Feature/Http/Webhook/SupplierWebhookTest.php index 52fc55df..5e631d0a 100644 --- a/app/tests/Feature/Http/Webhook/SupplierWebhookTest.php +++ b/app/tests/Feature/Http/Webhook/SupplierWebhookTest.php @@ -7,8 +7,10 @@ use App\Models\SupplierLead; use App\Models\SystemSetting; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\DB; +use Tests\Concerns\SharesSupplierPdo; -uses(DatabaseTransactions::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); beforeEach(function () { SystemSetting::query()->where('key', 'supplier_webhook_secret')->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']); @@ -159,3 +161,40 @@ it('accepts timestamp within ±24h window (Plan 2.6 fix #iii — partition guard $response->assertStatus(202); }); + +// --------------------------------------------------------------------------- +// Журнал вебхука поставщика (31.07.2026). Прежний logSupplierWebhook() писал в +// таблицу webhook_log, снесённую ещё 24.05 вместе с legacy-каналом, и молча +// выходил по Schema::hasTable → отказы были невидимы. Итог: 70 отказов 404 за +// 10 дней (поставщик долбился со СТАРЫМ паролем на старый адрес) нашлись только +// в логе nginx, случайно. Отпечаток пароля (первые 8 символов md5) отвечает на +// главный вопрос разбора «это чужой ключ или наш» и сам секретом не является. +// --------------------------------------------------------------------------- + +it('logs a rejected call with the secret fingerprint so a stale endpoint is visible', function () { + $stale = 'stale-secret-32chars-bbbbbbbbbbbbbb'; + + $this->postJson("/api/webhook/supplier/{$stale}", [ + 'vid' => 77001, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(), + ])->assertStatus(404); + + $row = DB::connection('pgsql_supplier')->table('supplier_webhook_log')->latest('id')->first(); + expect($row)->not->toBeNull(); + expect($row->status)->toBe('rejected_secret'); + expect($row->secret_fingerprint)->toBe(substr(md5($stale), 0, 8)); + expect($row->supplier_lead_id)->toBeNull(); +}); + +it('logs an accepted call with the created lead id', function () { + Bus::fake(); + $secret = 'test-secret-32chars-aaaaaaaaaaaaaa'; + + $this->postJson("/api/webhook/supplier/{$secret}", [ + 'vid' => 77002, 'project' => 'B1_test.ru', 'phone' => '79991234568', 'time' => time(), + ])->assertStatus(202); + + $row = DB::connection('pgsql_supplier')->table('supplier_webhook_log')->latest('id')->first(); + expect($row->status)->toBe('received'); + expect($row->secret_fingerprint)->toBe(substr(md5($secret), 0, 8)); + expect((int) $row->supplier_lead_id)->toBe((int) SupplierLead::where('vid', 77002)->value('id')); +}); diff --git a/app/tests/Feature/Supplier/CsvReconcileJobTest.php b/app/tests/Feature/Supplier/CsvReconcileJobTest.php index b0114195..d7be7a59 100644 --- a/app/tests/Feature/Supplier/CsvReconcileJobTest.php +++ b/app/tests/Feature/Supplier/CsvReconcileJobTest.php @@ -11,6 +11,7 @@ use App\Mail\TenantBusinessDriftAlertMail; use App\Models\Project; use App\Models\SupplierLead; use App\Models\Tenant; +use App\Services\LeadRegionResolver; use App\Services\Supplier\SupplierPortalClient; use Carbon\Carbon; use Illuminate\Contracts\Mail\Mailer; @@ -45,6 +46,7 @@ beforeEach(function (): void { } }); Cache::store('redis')->forget('supplier:csv_reconcile'); + Cache::store('redis')->forget('supplier:csv_reconcile:pending'); putSupplierSession(); config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']); config(['services.supplier.alert_email' => 'ops@liderra.ru']); @@ -52,6 +54,7 @@ beforeEach(function (): void { afterEach(function (): void { Cache::store('redis')->forget('supplier:csv_reconcile'); + Cache::store('redis')->forget('supplier:csv_reconcile:pending'); }); /** @@ -142,6 +145,10 @@ it('recovers a delivered lead missing from webhook — with the REAL vid (not nu $delivered[] = ['vid' => $missingVid, 'phone' => $missingPhone, 'project' => 'B1_a.com']; fakeDelivered($delivered); + // Отсрочка 31.07.2026: первый прогон только кладёт недостачу в карантин, добор — со + // второго, когда стало ясно, что вебхук её не несёт (а не просто опаздывает). + runCsvReconcile(); + Carbon::setTestNow(now()->addMinutes(16)); runCsvReconcile(); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); @@ -212,6 +219,10 @@ it('unparseable project in delivered ledger — skipped, counted, excluded from } fakeDelivered($delivered); + // Мусорные строки — тоже недостача, поэтому проходят через отсрочку: считаются + // unparseable только на прогоне, где реально дошло до разбора project. + runCsvReconcile(); + Carbon::setTestNow(now()->addMinutes(16)); runCsvReconcile(); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); @@ -219,6 +230,7 @@ it('unparseable project in delivered ledger — skipped, counted, excluded from expect((int) $log->matched_count)->toBe(5); expect((int) $log->recovered_count)->toBe(0); expect((int) $log->unparseable_count)->toBe(5); + expect((int) $log->pending_count)->toBe(0); expect((float) $log->drift_ratio)->toBe(0.0); // только junk, реального missing нет expect($log->status)->toBe('ok'); }); @@ -395,3 +407,184 @@ it('R-05 business-drift: tenant with shortfall <= 20% → NO TenantBusinessDrift return $mail->tenantId === $tenant->id; }); }); + +// --------------------------------------------------------------------------- +// Отсрочка добора (31.07.2026): вебхук поставщика приходит на 2-8 минут ПОЗЖЕ, +// чем строка появляется в журнале отданного. Сверка каждые 30 мин попадала в это +// окно и «восстанавливала» лид, который уже был в пути → ложная тревога «потеряно N» +// + бедная карточка (в журнале нет tag/time/phones). Инцидент 31.07: 7 добранных, +// вебхук по тем же семи пришёл через 2,5 минуты и получил «уже есть». +// Правило: недостача добирается только если висит дольше GRACE_MINUTES. +// --------------------------------------------------------------------------- + +it('first sighting of a missing vid is quarantined — not recovered, no alert', function (): void { + $vid = 930001; + $phone = '79300000001'; + fakeDelivered([['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']]); + + runCsvReconcile(); + + expect(SupplierLead::where('vid', $vid)->count())->toBe(0); + $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); + expect((int) $log->recovered_count)->toBe(0); + expect((int) $log->pending_count)->toBe(1); + expect($log->status)->toBe('ok'); + Mail::assertNotSent(CsvDriftAlertMail::class); + Bus::assertNothingDispatched(); +}); + +it('late webhook arrives during quarantine — nothing is recovered, no phantom, no alert', function (): void { + // Точный сценарий инцидента 31.07.2026: в 05:30 сверка увидела 7 номеров, которых у нас + // нет; вебхук по ним пришёл в 05:32-05:33. Со старым поведением портал уже завёл их сам + // (бедные карточки, ложная тревога «потеряно 7»). С отсрочкой — ждём и не трогаем. + $vid = 940001; + $phone = '79400000001'; + fakeDelivered([['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']]); + + runCsvReconcile(); // 05:30 — увидели недостачу, в карантин + webhookLead($vid, $phone, 'B1_a.com'); // 05:33 — вебхук донёс, как в жизни + Carbon::setTestNow(now()->addMinutes(16)); + runCsvReconcile(); // 06:00 — недостачи больше нет + + expect(SupplierLead::where('source', 'csv_recovery')->count())->toBe(0); + expect(SupplierLead::where('vid', $vid)->count())->toBe(1); + + $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); + expect((int) $log->recovered_count)->toBe(0); + expect((int) $log->pending_count)->toBe(0); + expect((int) $log->matched_count)->toBe(1); + expect($log->status)->toBe('ok'); + Mail::assertNotSent(CsvDriftAlertMail::class); + Bus::assertNothingDispatched(); +}); + +it('alert counts only overdue as lost and reports in-flight separately', function (): void { + // Один номер висит с прошлого прогона (реальная потеря), второй увиден только сейчас (в пути). + $lostVid = 950001; + $lostPhone = '79500000001'; + fakeDelivered([['vid' => $lostVid, 'phone' => $lostPhone, 'project' => 'B1_a.com']]); + runCsvReconcile(); + + Carbon::setTestNow(now()->addMinutes(16)); + $freshVid = 950002; + $freshPhone = '79500000002'; + fakeDelivered([ + ['vid' => $lostVid, 'phone' => $lostPhone, 'project' => 'B1_a.com'], + ['vid' => $freshVid, 'phone' => $freshPhone, 'project' => 'B1_a.com'], + ]); + runCsvReconcile(); + + $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); + expect((int) $log->recovered_count)->toBe(1); + expect((int) $log->pending_count)->toBe(1); + + Mail::assertSent(CsvDriftAlertMail::class, function (CsvDriftAlertMail $mail) { + return $mail->missingCount === 1 // потерей считаем только просроченный + && $mail->pendingCount === 1 // «в пути» показываем отдельно + && $mail->recoveredCount === 1; + }); + expect(SupplierLead::where('vid', $freshVid)->count())->toBe(0); +}); + +it('drift alert email renders and separates loss from in-flight', function (): void { + // Смоук шаблона: битый blade иначе всплыл бы только на бою, письмом-пустышкой. + $html = (new CsvDriftAlertMail( + reconcileLogId: 1417, + totalCsvRows: 21, + missingCount: 7, + pendingCount: 3, + recoveredCount: 7, + driftRatio: 0.3333, + windowStart: Carbon::parse('2026-07-30 00:00:00'), + windowEnd: Carbon::parse('2026-07-31 02:30:00'), + ))->render(); + + expect($html)->toContain('Ещё в пути'); + expect($html)->toContain('считаем потерей'); + expect($html)->toContain('1417'); + expect($html)->not->toContain('Пропущено webhook'); +}); + +/** + * Разметка строки журнала «Мои сделки» — СПИСАНА С ЖИВОГО КАБИНЕТА (31.07.2026), + * а не придумана: проект лежит в td.crm-domain-column, следом отдельная ячейка с ТЕГОМ + * (регион или «РФ»), затем td.users__td_phones с телефоном звонившего. + * + * @param list $leads + */ +function deliveredHtmlCabinet(array $leads): string +{ + $rows = ''; + foreach ($leads as $l) { + $vid = $l['vid']; + $rows .= '' + .'' + .' '.$l['project'].' ' + .''.$l['tag'].'' + .'' + .'' + .''; + } + + return ''.$rows.'
'; +} + +it('fetchDeliveredLeads picks up the region tag from the cabinet row', function (): void { + $html = deliveredHtmlCabinet([ + ['vid' => 6001, 'phone' => '79990000001', 'project' => 'B1_79089202427', 'tag' => 'Свердловская область'], + ['vid' => 6002, 'phone' => '79990000002', 'project' => 'B1_79537885612', 'tag' => 'РФ'], + ['vid' => 6003, 'phone' => '79990000003', 'project' => 'B1_a.com', 'tag' => '-'], + ]); + Http::fake(['crm.bp-gr.ru/admin/visit/index-visit*' => Http::response($html, 200)]); + + $result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now()); + + expect($result[6001]['tag'])->toBe('Свердловская область'); + expect($result[6002]['tag'])->toBe('РФ'); + expect($result[6003]['tag'])->toBeNull(); // прочерк — это не тег + // телефон и проект по-прежнему берутся верно + expect($result[6001]['phone'])->toBe('79990000001'); + expect($result[6001]['project'])->toBe('B1_79089202427'); +}); + +it('recovered lead carries the supplier tag — region has a chance when DaData is silent', function (): void { + // Повод (31.07.2026): у добранного лида в карточке было только vid+phone+project, поэтому + // резолверу нечем было подстраховаться, когда ДаData не знает номер. Тег поставщика — + // ровно эта подстраховка (RegionTagResolver), и он в журнале ЕСТЬ, мы его выбрасывали. + $vid = 960001; + fakeDelivered([['vid' => $vid, 'phone' => '79600000001', 'project' => 'B1_a.com', 'tag' => 'Красноярский край']]); + + runCsvReconcile(); + Carbon::setTestNow(now()->addMinutes(16)); + runCsvReconcile(); + + $lead = SupplierLead::where('vid', $vid)->first(); + expect($lead)->not->toBeNull(); + expect($lead->raw_payload['tag'])->toBe('Красноярский край'); +}); + +it('tag from the journal actually yields a region when DaData is silent', function (): void { + // Смысл правки — не «положить строчку в карточку», а вернуть региону опору. + // ДаData выключена (как будто не знает номер) → резолвер обязан взять регион из тега. + config(['services.dadata.enabled' => false]); + $vid = 970001; + fakeDelivered([['vid' => $vid, 'phone' => '79700000001', 'project' => 'B1_a.com', 'tag' => 'Красноярский край']]); + + runCsvReconcile(); + Carbon::setTestNow(now()->addMinutes(16)); + runCsvReconcile(); + + $lead = SupplierLead::where('vid', $vid)->first(); + $resolution = app(LeadRegionResolver::class)->resolve($lead); + + expect($resolution->source)->toBe('tag'); + expect($resolution->subjectCode)->not->toBeNull(); + + // Контроль вырезанием: без тега тот же лид даёт «регион неизвестен». + $lead->raw_payload = ['project' => 'B1_a.com', 'phone' => '79700000001', 'vid' => $vid]; + $lead->save(); + $without = app(LeadRegionResolver::class)->resolve($lead->fresh()); + expect($without->source)->toBe('unknown'); + expect($without->subjectCode)->toBeNull(); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index bb8fb852..791c7fd7 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-31T07:17:46.606Z +Last updated: 2026-08-01T10:43:22.039Z | Контролёр | Состояние | Детали | |---|---|---| @@ -8,14 +8,14 @@ Last updated: 2026-07-31T07:17:46.606Z | C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files | | C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 9 week(s) ago | | C4 Сигнальный статус | ✅ | This file (self-reference) | -| C5 Observer-coverage | ⚠️ | 0 episode(s) this month · observer-stop-hook NOT registered in .claude/settings.json Stop hook; .git/hooks/post-commit not installed (run: npx lefthook install --force) | +| C5 Observer-coverage | ⚠️ | 0 episode(s) this month · observer-stop-hook NOT registered in .claude/settings.json Stop hook | | C6 Chain map sync | ✅ | [chain-map-checker] OK — 17 chains in sync | ## Кто на посту (оборона М1–М6) ⚠️ **ПОСТ ПУСТОЙ** — не зарегистрированы: enforce-floor.mjs, enforce-supreme-gate.mjs, enforce-normative-content-rules.mjs, enforce-read-path-deny.mjs, enforce-mcp-classification.mjs, enforce-judge-gate.mjs, enforce-snapshot.mjs, enforce-floor-escape-consume.mjs, enforce-skill-journaler.mjs, enforce-verify-gate.mjs, enforce-criterion-gate.mjs, enforce-coverage-verify.mjs, enforce-todowrite-skill-verifier.mjs (оборона НЕ подтверждена; SE-B/Δ8) -Судья М4: **live-block** (inert $0 / shadow / floor-only / live-block) +Судья М4: **inert** (inert $0 / shadow / floor-only / live-block) | Машина / страж | Хук | Зарегистрирован | |---|---|---| @@ -39,7 +39,7 @@ Last updated: 2026-07-31T07:17:46.606Z - Observer evidence: 0 episodes this month, 0 observer_error markers, 0 PII matches before filter - Legacy v1 episodes (not in factor analysis): 0 -- Last /brain-retro: 65 day(s) ago +- Last /brain-retro: 66 day(s) ago - Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). ## Метрики дисциплины @@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3544 | MsMpEng | 16.61ч | NaNч | -| 23936 | Code | 4.76ч | 0.0ч | -| 4 | System | 2.51ч | NaNч | +| 3544 | MsMpEng | 30.24ч | 12286018.7ч | +| 23936 | Code | 10.81ч | 0.0ч | +| 4 | System | 4.49ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. @@ -130,13 +130,7 @@ Episodes since last run: 542 / threshold: 10 ## Целостность журналов действий -🔴 Битые цепочки (3 из 120): - -| session | broken at seq | -|---|---| -| `03437265-6d58-4622-aeed-c0eeac0f2c32` | 1 | -| `54594686-843c-4ea8-bcd3-5ae6a7244e30` | 14 | -| `9c02276d-dabb-40e4-9c04-44c18d47485a` | 14 | +Ключ подписанта не provisioned — проверка цепи недоступна (ключ — owner-шаг A3). ## Алерт-индикаторы