diff --git a/app/app/Http/Controllers/Api/AdminDashboardController.php b/app/app/Http/Controllers/Api/AdminDashboardController.php index 9faec3eb..fdc1184a 100644 --- a/app/app/Http/Controllers/Api/AdminDashboardController.php +++ b/app/app/Http/Controllers/Api/AdminDashboardController.php @@ -41,6 +41,7 @@ class AdminDashboardController extends Controller 'health' => $this->healthTile(), 'leads' => $this->leadsTile(), 'supply' => $this->supplyTile(), + 'balances' => $this->balancesTile(), ]); } @@ -343,6 +344,73 @@ class AdminDashboardController extends Controller ]; } + // === Балансы внешних сервисов (28.06) === + + /** Порядок «опасности» светофора: больше = хуже. */ + private const LIGHT_ORDER = ['green' => 0, 'grey' => 1, 'amber' => 2, 'red' => 3]; + + /** + * Прямая ссылка «Пополнить» для сервиса (статика из конфига; в БД не хранится). + * Владелец с планшета: увидел минус → ткнул → попал на страницу оплаты. + */ + private function topupUrl(string $key): ?string + { + return match ($key) { + 'dadata' => (string) config('services.dadata.topup_url') ?: null, + 'supplier' => (string) config('services.supplier.topup_url') ?: null, + 'yandex_cloud' => $this->ycTopupUrl(), + default => null, + }; + } + + private function ycTopupUrl(): ?string + { + $base = (string) config('services.yandex_cloud.console_billing_url'); + $acc = (string) config('services.yandex_cloud.billing_account_id'); + if ($base === '' || $acc === '') { + return null; + } + + return rtrim($base, '/').'/'.$acc.'/payments'; + } + + /** @return array */ + private function balancesTile(): array + { + $rows = DB::table('external_service_balances')->get(); + $light = $rows->isEmpty() ? 'grey' + : $rows->map(fn ($r) => $r->ok ? $r->light : 'grey') + ->sortByDesc(fn ($l) => self::LIGHT_ORDER[$l] ?? 0)->first(); + + return [ + 'light' => $light, + 'count' => $rows->count(), + 'red' => $rows->where('ok', true)->where('light', 'red')->count(), + ]; + } + + /** GET /api/admin/dashboard/balances — балансы внешних сервисов (L2). */ + public function balances(): JsonResponse + { + $rows = DB::table('external_service_balances')->get()->map(fn ($r) => [ + 'service_key' => $r->service_key, + 'balance_amount' => $r->balance_amount, + 'currency' => $r->currency, + 'daily_spend_estimate' => $r->daily_spend_estimate, + 'days_left' => $r->days_left, + 'light' => $r->ok ? $r->light : 'grey', + 'ok' => (bool) $r->ok, + 'error' => $r->error, + 'checked_at' => $r->checked_at, + 'topup_url' => $this->topupUrl($r->service_key), + ])->values(); + + $light = $rows->isEmpty() ? 'grey' + : $rows->sortByDesc(fn ($s) => self::LIGHT_ORDER[$s['light']] ?? 0)->first()['light']; + + return response()->json(['light' => $light, 'services' => $rows]); + } + /** GET /api/admin/dashboard/supply — заказ у поставщика по группам (L2). */ public function supply(): JsonResponse { diff --git a/app/app/Jobs/External/RefreshExternalBalancesJob.php b/app/app/Jobs/External/RefreshExternalBalancesJob.php new file mode 100644 index 00000000..1f72a421 --- /dev/null +++ b/app/app/Jobs/External/RefreshExternalBalancesJob.php @@ -0,0 +1,107 @@ +> */ + private function providers(): array + { + return [ + DadataBalanceProvider::class, + SupplierBalanceProvider::class, + YandexCloudBalanceProvider::class, + ]; + } + + public function handle(): void + { + $table = DB::connection(self::DB_CONNECTION)->table('external_service_balances'); + + foreach ($this->providers() as $cls) { + /** @var BalanceProvider $p */ + $p = app($cls); + $key = $p->serviceKey(); + $reading = $p->fetch(); // не бросает + + if (! $reading->ok) { + // Оставляем прошлый баланс, помечаем ok=false + ошибку. + $table->updateOrInsert( + ['service_key' => $key], + [ + 'ok' => false, + 'error' => $reading->error, + 'checked_at' => $reading->checkedAt, + 'updated_at' => now(), + ], + ); + + continue; + } + + [$red, $amber] = $this->floors($key); + $h = BalanceHealth::evaluate((float) $reading->balance, $reading->dailySpend, $red, $amber); + + $table->updateOrInsert( + ['service_key' => $key], + [ + 'balance_amount' => $reading->balance, + 'currency' => $reading->currency, + 'daily_spend_estimate' => $reading->dailySpend, + 'days_left' => $h['days_left'], + 'light' => $h['light'], + 'ok' => true, + 'error' => null, + 'checked_at' => $reading->checkedAt, + 'updated_at' => now(), + ], + ); + } + } + + /** @return array{0:float,1:float} [red_floor, amber_floor] */ + private function floors(string $key): array + { + return match ($key) { + 'dadata' => [ + (float) config('services.dadata.red_floor_rub', 500), + (float) config('services.dadata.amber_floor_rub', 2000), + ], + 'yandex_cloud' => [ + (float) config('services.yandex_cloud.red_floor_rub', 1000), + (float) config('services.yandex_cloud.amber_floor_rub', 5000), + ], + 'supplier' => [ + (float) config('services.supplier.red_floor_rub', 5000), + (float) config('services.supplier.amber_floor_rub', 15000), + ], + default => [0.0, 0.0], + }; + } +} diff --git a/app/app/Services/Dashboard/BalanceHealth.php b/app/app/Services/Dashboard/BalanceHealth.php new file mode 100644 index 00000000..3994eb8a --- /dev/null +++ b/app/app/Services/Dashboard/BalanceHealth.php @@ -0,0 +1,41 @@ + 0) + ? (int) floor($balance / $dailySpend) + : null; + + $light = 'green'; + if ($balance < $amberFloor || ($days !== null && $days < 7)) { + $light = 'amber'; + } + if ($balance < $redFloor || ($days !== null && $days < 3)) { + $light = 'red'; + } + + return ['days_left' => $days, 'light' => $light]; + } +} diff --git a/app/app/Services/External/BalanceProvider.php b/app/app/Services/External/BalanceProvider.php new file mode 100644 index 00000000..ddaa2b84 --- /dev/null +++ b/app/app/Services/External/BalanceProvider.php @@ -0,0 +1,18 @@ +. + */ +class DadataBalanceProvider implements BalanceProvider +{ + public function serviceKey(): string + { + return 'dadata'; + } + + public function fetch(): BalanceReading + { + try { + $key = (string) config('services.dadata.api_key'); + if ($key === '') { + return BalanceReading::fail('dadata', 'DaData api_key не задан'); + } + $resp = Http::timeout(10) + ->withHeaders(['Authorization' => 'Token '.$key, 'Accept' => 'application/json']) + ->get((string) config('services.dadata.balance_url')); + if (! $resp->ok()) { + return BalanceReading::fail('dadata', 'HTTP '.$resp->status()); + } + $balance = (float) ($resp->json('balance') ?? 0); + + return BalanceReading::ok('dadata', $balance, 'RUB', $this->dailySpend()); + } catch (\Throwable $e) { + return BalanceReading::fail('dadata', $e->getMessage()); + } + } + + /** + * Оценка расхода/день: вызовы резолва за 7д × стоимость вызова. + * Best-effort — любая ошибка подсчёта НЕ должна ронять чтение баланса. + */ + private function dailySpend(): ?float + { + try { + $costRub = ((int) config('services.dadata.call_cost_kopecks', 60)) / 100; + $calls7d = DB::table('supplier_leads') + ->where('region_source', 'dadata') + ->where('received_at', '>=', now()->subDays(7)) + ->count(); + if ($calls7d === 0) { + return null; + } + + return round(($calls7d / 7) * $costRub, 2); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/app/Services/External/SupplierBalanceProvider.php b/app/app/Services/External/SupplierBalanceProvider.php new file mode 100644 index 00000000..3acbe37f --- /dev/null +++ b/app/app/Services/External/SupplierBalanceProvider.php @@ -0,0 +1,77 @@ +bridge->run([ + 'script' => 'supplier-balance.js', + 'login' => $login, + 'password' => $password, + 'url' => (string) config('services.supplier.portal_url'), + ]); + if (! isset($out['balance']) || ! is_numeric($out['balance'])) { + return BalanceReading::fail('supplier', 'Баланс не найден на странице кабинета'); + } + + return BalanceReading::ok( + 'supplier', + (float) $out['balance'], + (string) ($out['currency'] ?? 'RUB'), + $this->dailySpend(), + ); + } catch (\Throwable $e) { + return BalanceReading::fail('supplier', $e->getMessage()); + } + } + + /** + * Оценка расхода/день: лиды за 7д ÷ 7 × средняя цена лида (из конфига). + * Best-effort — нет цены или ошибка подсчёта → null (светофор только по порогам). + */ + private function dailySpend(): ?float + { + try { + $price = (float) config('services.supplier.avg_lead_price_rub', 0); + if ($price <= 0) { + return null; + } + $leads7d = DB::table('supplier_leads') + ->where('received_at', '>=', now()->subDays(7)) + ->count(); + if ($leads7d === 0) { + return null; + } + + return round(($leads7d / 7) * $price, 2); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/app/Services/External/YandexCloudBalanceProvider.php b/app/app/Services/External/YandexCloudBalanceProvider.php new file mode 100644 index 00000000..ef9273ee --- /dev/null +++ b/app/app/Services/External/YandexCloudBalanceProvider.php @@ -0,0 +1,50 @@ +post((string) config('services.yandex_cloud.iam_url'), [ + 'yandexPassportOauthToken' => $oauth, + ]); + if (! $iam->ok() || ! $iam->json('iamToken')) { + return BalanceReading::fail('yandex_cloud', 'IAM exchange: HTTP '.$iam->status()); + } + $resp = Http::timeout(10) + ->withToken((string) $iam->json('iamToken')) + ->get((string) config('services.yandex_cloud.billing_url').'/'.$acc); + if (! $resp->ok()) { + return BalanceReading::fail('yandex_cloud', 'Billing: HTTP '.$resp->status()); + } + $balance = (float) ($resp->json('balance') ?? 0); + $currency = (string) ($resp->json('currency') ?? 'RUB'); + $spend = ((float) config('services.yandex_cloud.daily_spend_rub')) ?: null; + + return BalanceReading::ok('yandex_cloud', $balance, $currency, $spend); + } catch (\Throwable $e) { + return BalanceReading::fail('yandex_cloud', $e->getMessage()); + } + } +} diff --git a/app/config/services.php b/app/config/services.php index c782a211..9cc05f08 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -49,6 +49,13 @@ return [ 'password' => env('SUPPLIER_PASSWORD'), 'portal_url' => env('SUPPLIER_PORTAL_URL', 'https://crm.bp-gr.ru'), 'alert_email' => env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru'), + // Плитка балансов (28.06): оценка расхода/день + пороги светофора + ссылка пополнения. + // avg_lead_price_rub=0 → расход неизвестен (days_left=null). topup_url по умолчанию = + // кабинет (portal_url); уточнить прямую страницу пополнения при разведке. + 'avg_lead_price_rub' => (float) env('SUPPLIER_AVG_LEAD_PRICE_RUB', 0), + 'red_floor_rub' => (int) env('SUPPLIER_RED_FLOOR_RUB', 5000), + 'amber_floor_rub' => (int) env('SUPPLIER_AMBER_FLOOR_RUB', 15000), + 'topup_url' => env('SUPPLIER_TOPUP_URL', env('SUPPLIER_PORTAL_URL', 'https://crm.bp-gr.ru')), ], // DaData phone cleaner — резолв региона лида по телефону (lead region resolution). @@ -65,6 +72,26 @@ return [ // G1/SP2: подтяжка организации по ИНН (suggestions findById/party). Тот же api_key // (Token), secret не нужен. Default false → NullPartyLookup (dev/тесты не ходят в сеть). 'party_enabled' => filter_var(env('DADATA_PARTY_ENABLED', false), FILTER_VALIDATE_BOOL), + // Плитка балансов (28.06): чтение баланса профиля + пороги светофора + ссылка пополнения. + 'balance_url' => env('DADATA_BALANCE_URL', 'https://dadata.ru/api/v2/profile/balance'), + 'red_floor_rub' => (int) env('DADATA_RED_FLOOR_RUB', 500), + 'amber_floor_rub' => (int) env('DADATA_AMBER_FLOOR_RUB', 2000), + 'topup_url' => env('DADATA_TOPUP_URL', 'https://dadata.ru/profile/#billing'), + ], + + // Плитка балансов (28.06): Yandex Cloud биллинг (серверы + Managed PG ~18к/мес). + // OAuth владельца (interim) → IAM-токен → billing API. SA billing-reader создан, + // миграция на него — follow-up (только источник токена сменится). console_billing_url + // + billing_account_id строят прямую ссылку «Пополнить» в дашборде. + 'yandex_cloud' => [ + 'oauth_token' => env('YC_OAUTH_TOKEN'), + 'billing_account_id' => env('YC_BILLING_ACCOUNT_ID'), + 'iam_url' => env('YC_IAM_URL', 'https://iam.api.cloud.yandex.net/iam/v1/tokens'), + 'billing_url' => env('YC_BILLING_URL', 'https://billing.api.cloud.yandex.net/billing/v1/billingAccounts'), + 'console_billing_url' => env('YC_CONSOLE_BILLING_URL', 'https://console.yandex.cloud/billing/accounts'), + 'daily_spend_rub' => (int) env('YC_DAILY_SPEND_RUB', 600), // оценка ~18к/мес; откалибровать + 'red_floor_rub' => (int) env('YC_RED_FLOOR_RUB', 1000), + 'amber_floor_rub' => (int) env('YC_AMBER_FLOOR_RUB', 5000), ], // G7-A: клиентская «Помощь». diff --git a/app/database/migrations/2026_06_28_120000_create_external_service_balances_table.php b/app/database/migrations/2026_06_28_120000_create_external_service_balances_table.php new file mode 100644 index 00000000..749f13c8 --- /dev/null +++ b/app/database/migrations/2026_06_28_120000_create_external_service_balances_table.php @@ -0,0 +1,60 @@ +statement(<<<'SQL' + CREATE TABLE IF NOT EXISTS external_service_balances ( + service_key VARCHAR(32) PRIMARY KEY, + balance_amount NUMERIC(14,2), + currency VARCHAR(8) NOT NULL DEFAULT 'RUB', + daily_spend_estimate NUMERIC(14,2), + days_left INTEGER, + light VARCHAR(8) NOT NULL DEFAULT 'green' + CHECK (light IN ('green','amber','red','grey')), + ok BOOLEAN NOT NULL DEFAULT FALSE, + error TEXT, + checked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + foreach (['crm_supplier_worker'] as $role) { + $supplier->statement(<<statement('DROP TABLE IF EXISTS external_service_balances CASCADE'); + } +}; diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index bee4e7f6..3107a1d7 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -288,6 +288,12 @@ parameters: count: 2 path: tests/Feature/Account/UserSessionsTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Admin/AdminDashboardBalancesTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' identifier: method.notFound @@ -3078,6 +3084,18 @@ parameters: count: 1 path: tests/Unit/Exceptions/InsufficientBalanceExceptionTest.php + - + message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Unit/External/SupplierBalanceProviderTest.php + + - + message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Unit/External/SupplierBalanceProviderTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tiers\.$#' identifier: property.notFound diff --git a/app/playwright/supplier-balance.js b/app/playwright/supplier-balance.js new file mode 100644 index 00000000..8355f239 --- /dev/null +++ b/app/playwright/supplier-balance.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node +/** + * Headless Playwright чтение баланса кабинета поставщика crm.bp-gr.ru. + * Логин-флоу — копия refresh-session.js (Yii2 LoginForm), затем поиск суммы баланса + * на пост-логин странице по нескольким кандидат-паттернам (URL кабинета не отдаёт + * JSON-баланс). Селектор/URL баланса — КАЛИБРУЮТСЯ разведкой на проде (план Task 6 + * Step 1); до калибровки скрипт честно вернёт exit 2 «баланс не найден». + * + * Input (JSON через stdin): {login, password, url} + * Output (JSON через stdout): {balance: , currency: "RUB"} + * + * Exit codes: + * 0 — success (баланс найден) + * 1 — auth failed (логин/пароль отклонены) + * 2 — баланс не найден на доступных страницах (нужна калибровка селектора) + * 3 — timeout (60s) + * 4 — invalid input или другая ошибка + */ +const { chromium } = require('playwright'); + +const TIMEOUT_MS = 60_000; + +/** + * Извлекает первое денежное значение из текста по паттерну «...баланс... N ₽». + * Возвращает number (рубли) или null. RU-формат: пробелы/неразрывные пробелы как + * разделители тысяч, запятая/точка — копейки. Допускает минус (кабинет в минусе). + */ +function parseBalance(text) { + if (!text) return null; + const cleaned = text.replace(/ | /g, ' '); + // Ищем «баланс[:] -?12 345,67 ₽|руб» (без учёта регистра). + const re = /баланс[^-\d]{0,40}(-?\d[\d ]*(?:[.,]\d{1,2})?)\s*(?:₽|руб|rub)/i; + const m = cleaned.match(re); + if (!m) return null; + const num = m[1].replace(/ /g, '').replace(',', '.'); + const val = parseFloat(num); + return Number.isFinite(val) ? val : null; +} + +async function readBalance(args) { + let browser = null; + try { + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS }); + + // DOM-селекторы crm.bp-gr.ru/login (Yii2 LoginForm) — как в refresh-session.js. + const loginSelector = '#loginform-username'; + const passwordSelector = '#loginform-password'; + const submitSelector = 'button[type=submit]'; + + await page.fill(loginSelector, args.login); + await page.fill(passwordSelector, args.password); + await page.click(submitSelector); + await page + .waitForFunction((sel) => !document.querySelector(sel), loginSelector, { timeout: TIMEOUT_MS }) + .catch(() => {}); + await page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }).catch(() => {}); + + if ((await page.locator(loginSelector).count()) > 0) { + process.stderr.write(JSON.stringify({ error: 'login rejected: still on login page after submit' })); + process.exit(1); + } + + // Кандидаты: текст всей пост-логин страницы; если не нашли — пробуем + // явные страницы баланса/биллинга (типичные для кабинета пути). + const candidates = [null, '/balance', '/billing', '/cabinet', '/profile']; + for (const path of candidates) { + if (path) { + try { + await page.goto(new URL(path, args.url).toString(), { waitUntil: 'load', timeout: 15_000 }); + } catch (e) { + continue; + } + } + const body = await page.locator('body').innerText().catch(() => ''); + const balance = parseBalance(body); + if (balance !== null) { + process.stdout.write(JSON.stringify({ balance, currency: 'RUB' })); + process.exit(0); + } + } + + process.stderr.write(JSON.stringify({ error: 'balance value not found (нужна калибровка селектора, план Task 6)' })); + process.exit(2); + } catch (err) { + process.stderr.write(JSON.stringify({ error: err.message })); + process.exit(err.message && err.message.includes('Timeout') ? 3 : 4); + } finally { + if (browser) { + await browser.close(); + } + } +} + +let input = ''; +process.stdin.on('data', (chunk) => { input += chunk; }); +process.stdin.on('end', () => { + let args; + try { + args = JSON.parse(input); + } catch (e) { + process.stderr.write(JSON.stringify({ error: 'invalid JSON on stdin' })); + process.exit(4); + } + if (!args.login || !args.password || !args.url) { + process.stderr.write(JSON.stringify({ error: 'missing required keys: login, password, url' })); + process.exit(4); + } + readBalance(args).catch((err) => { + const message = err && err.message ? err.message : String(err); + process.stderr.write(JSON.stringify({ error: message })); + process.exit(4); + }); +}); diff --git a/app/routes/console.php b/app/routes/console.php index f19ea052..9cc7569f 100644 --- a/app/routes/console.php +++ b/app/routes/console.php @@ -1,9 +1,11 @@ dailyAt('00:05') ->timezone('Europe/Moscow') ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\FlushDeferredOnlineSyncJob', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\FlushDeferredOnlineSyncJob', false, 'Job failed', null)); +// Плитка балансов (28.06): ежедневный сбор баланса внешних сервисов (DaData/Поставщик/ +// Yandex Cloud) в 06:30 МСК. Изоляция провайдеров внутри джобы; падение сети одного +// сервиса не роняет остальных и не валит расписание. +Schedule::job(new RefreshExternalBalancesJob) + ->dailyAt('06:30') + ->timezone('Europe/Moscow') + ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\External\RefreshExternalBalancesJob', true, null, null)) + ->onFailure(fn () => $hb->recordRunResult('App\Jobs\External\RefreshExternalBalancesJob', false, 'Job failed', null)); + // Plan 4: monthly reset 1-го числа в 00:00 МСК для tier-lookup в LedgerService. Schedule::command('projects:reset-monthly') ->monthlyOn(1, '00:00') diff --git a/app/routes/web.php b/app/routes/web.php index fa860754..347dc1c7 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -115,6 +115,7 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () { Route::get('/api/admin/dashboard/health', 'App\Http\Controllers\Api\AdminDashboardController@health'); Route::get('/api/admin/dashboard/leads', 'App\Http\Controllers\Api\AdminDashboardController@leads'); Route::get('/api/admin/dashboard/supply', 'App\Http\Controllers\Api\AdminDashboardController@supply'); + Route::get('/api/admin/dashboard/balances', 'App\Http\Controllers\Api\AdminDashboardController@balances'); // SaaS-admin impersonation flow (Ю-1). Авторизация — через гейт группы (EnsureSaasAdmin). Route::prefix('/api/admin/impersonation')->group(function () { diff --git a/app/tests/Feature/Admin/AdminDashboardBalancesTest.php b/app/tests/Feature/Admin/AdminDashboardBalancesTest.php new file mode 100644 index 00000000..9d7b2aed --- /dev/null +++ b/app/tests/Feature/Admin/AdminDashboardBalancesTest.php @@ -0,0 +1,55 @@ +delete(); +}); + +it('GET /api/admin/dashboard/balances возвращает строки сервисов + topup_url', function () { + config()->set('services.yandex_cloud.console_billing_url', 'https://console.yandex.cloud/billing/accounts'); + config()->set('services.yandex_cloud.billing_account_id', 'dn2w7fcvynjxe6elljct'); + + DB::table('external_service_balances')->insert([ + ['service_key' => 'dadata', 'balance_amount' => 4500, 'currency' => 'RUB', 'daily_spend_estimate' => 100, + 'days_left' => 9, 'light' => 'green', 'ok' => true, 'checked_at' => now(), 'created_at' => now(), 'updated_at' => now()], + ['service_key' => 'yandex_cloud', 'balance_amount' => -540.48, 'currency' => 'RUB', 'daily_spend_estimate' => 600, + 'days_left' => 0, 'light' => 'red', 'ok' => true, 'checked_at' => now(), 'created_at' => now(), 'updated_at' => now()], + ]); + + $res = $this->getJson('/api/admin/dashboard/balances'); + + $res->assertOk(); + $res->assertJsonStructure([ + 'light', + 'services' => [['service_key', 'balance_amount', 'currency', 'days_left', 'light', 'ok', 'checked_at', 'topup_url']], + ]); + expect($res->json('light'))->toBe('red'); // худший из сервисов + $yc = collect($res->json('services'))->firstWhere('service_key', 'yandex_cloud'); + expect($yc['topup_url'])->toBe('https://console.yandex.cloud/billing/accounts/dn2w7fcvynjxe6elljct/payments'); +}); + +it('неуспешный сервис показывается серым (grey), не красным', function () { + DB::table('external_service_balances')->insert([ + 'service_key' => 'supplier', 'balance_amount' => 12000, 'currency' => 'RUB', + 'light' => 'red', 'ok' => false, 'error' => 'кабинет недоступен', + 'checked_at' => now(), 'created_at' => now(), 'updated_at' => now(), + ]); + + $res = $this->getJson('/api/admin/dashboard/balances'); + $res->assertOk(); + $svc = collect($res->json('services'))->firstWhere('service_key', 'supplier'); + expect($svc['light'])->toBe('grey'); + expect($svc['ok'])->toBeFalse(); +}); + +it('summary включает плитку balances', function () { + $res = $this->getJson('/api/admin/dashboard?period=30d'); + $res->assertOk(); + $res->assertJsonStructure(['balances' => ['light', 'count', 'red']]); +}); diff --git a/app/tests/Feature/External/RefreshExternalBalancesJobTest.php b/app/tests/Feature/External/RefreshExternalBalancesJobTest.php new file mode 100644 index 00000000..b0b0753b --- /dev/null +++ b/app/tests/Feature/External/RefreshExternalBalancesJobTest.php @@ -0,0 +1,66 @@ +key; + } + + public function fetch(): BalanceReading + { + return $this->reading; + } + }; +} + +it('пишет балансы трёх сервисов + считает светофор', function () { + config()->set('services.yandex_cloud.red_floor_rub', 1000); + config()->set('services.yandex_cloud.amber_floor_rub', 5000); + + app()->instance(DadataBalanceProvider::class, fakeProvider('dadata', BalanceReading::ok('dadata', 4500, 'RUB', 100))); + app()->instance(SupplierBalanceProvider::class, fakeProvider('supplier', BalanceReading::ok('supplier', 50000, 'RUB', null))); + app()->instance(YandexCloudBalanceProvider::class, fakeProvider('yandex_cloud', BalanceReading::ok('yandex_cloud', -540.48, 'RUB', 600))); + + (new RefreshExternalBalancesJob)->handle(); + + $rows = DB::connection('pgsql_supplier')->table('external_service_balances')->get()->keyBy('service_key'); + expect($rows)->toHaveCount(3); + expect((float) $rows['yandex_cloud']->balance_amount)->toBe(-540.48); + expect($rows['yandex_cloud']->light)->toBe('red'); // минус < red_floor + expect((bool) $rows['yandex_cloud']->ok)->toBeTrue(); + expect($rows['dadata']->ok)->toBeTruthy(); +}); + +it('упавший провайдер не роняет джобу и сохраняет ошибку, остальные пишутся', function () { + app()->instance(DadataBalanceProvider::class, fakeProvider('dadata', BalanceReading::fail('dadata', 'HTTP 403'))); + app()->instance(SupplierBalanceProvider::class, fakeProvider('supplier', BalanceReading::ok('supplier', 50000, 'RUB', null))); + app()->instance(YandexCloudBalanceProvider::class, fakeProvider('yandex_cloud', BalanceReading::ok('yandex_cloud', 42000, 'RUB', 600))); + + (new RefreshExternalBalancesJob)->handle(); + + $rows = DB::connection('pgsql_supplier')->table('external_service_balances')->get()->keyBy('service_key'); + expect((bool) $rows['dadata']->ok)->toBeFalse(); + expect($rows['dadata']->error)->toContain('403'); + expect((bool) $rows['supplier']->ok)->toBeTrue(); + expect((bool) $rows['yandex_cloud']->ok)->toBeTrue(); +}); diff --git a/app/tests/Unit/Dashboard/BalanceHealthTest.php b/app/tests/Unit/Dashboard/BalanceHealthTest.php new file mode 100644 index 00000000..b2f67e3a --- /dev/null +++ b/app/tests/Unit/Dashboard/BalanceHealthTest.php @@ -0,0 +1,23 @@ +toMatchArray(['days_left' => 2, 'light' => 'red']); + // balance 4000 (< red_floor 5000), spend 100 → days 40, но балансtoBe('red'); + // balance 12000 (< amber 15000), days большой → amber + expect(BalanceHealth::evaluate(12000, 100, 5000, 15000)['light'])->toBe('amber'); + // balance 100000, spend 100 → days 1000, выше порогов → green + expect(BalanceHealth::evaluate(100000, 100, 5000, 15000)['light'])->toBe('green'); + // нет данных о расходе → days_left null, светофор только по порогам + expect(BalanceHealth::evaluate(100000, null, 5000, 15000))->toMatchArray(['days_left' => null, 'light' => 'green']); + // граница: ровно 7 дней — НЕ amber (порог «< 7»); 6 дней — amber + expect(BalanceHealth::evaluate(700, 100, 0, 0)['light'])->toBe('green'); + expect(BalanceHealth::evaluate(600, 100, 0, 0)['light'])->toBe('amber'); + // отрицательный баланс (YC в минусе) → red + expect(BalanceHealth::evaluate(-540, null, 1000, 5000))->toMatchArray(['days_left' => null, 'light' => 'red']); +}); diff --git a/app/tests/Unit/External/DadataBalanceProviderTest.php b/app/tests/Unit/External/DadataBalanceProviderTest.php new file mode 100644 index 00000000..6179ba0f --- /dev/null +++ b/app/tests/Unit/External/DadataBalanceProviderTest.php @@ -0,0 +1,34 @@ +set('services.dadata.api_key', 'test-token'); + Http::fake(['dadata.ru/*' => Http::response(['balance' => 4500.0], 200)]); + $r = app(DadataBalanceProvider::class)->fetch(); + expect($r->ok)->toBeTrue(); + expect($r->balance)->toBe(4500.0); + expect($r->serviceKey)->toBe('dadata'); +}); + +it('ошибка API → fail, не бросает', function () { + config()->set('services.dadata.api_key', 'test-token'); + Http::fake(['dadata.ru/*' => Http::response('forbidden', 403)]); + $r = app(DadataBalanceProvider::class)->fetch(); + expect($r->ok)->toBeFalse(); + expect($r->error)->not->toBeNull(); +}); + +it('нет ключа → fail без сетевого вызова', function () { + config()->set('services.dadata.api_key', ''); + Http::fake(); + $r = app(DadataBalanceProvider::class)->fetch(); + expect($r->ok)->toBeFalse(); + Http::assertNothingSent(); +}); diff --git a/app/tests/Unit/External/SupplierBalanceProviderTest.php b/app/tests/Unit/External/SupplierBalanceProviderTest.php new file mode 100644 index 00000000..4339c6c9 --- /dev/null +++ b/app/tests/Unit/External/SupplierBalanceProviderTest.php @@ -0,0 +1,46 @@ +set('services.supplier.login', 'u'); + config()->set('services.supplier.password', 'p'); + config()->set('services.supplier.portal_url', 'https://crm.bp-gr.ru'); +}); + +it('читает баланс кабинета через Playwright-мост', function () { + $bridge = Mockery::mock(PlaywrightBridge::class); + $bridge->shouldReceive('run')->once()->andReturn(['balance' => 12400, 'currency' => 'RUB']); + app()->instance(PlaywrightBridge::class, $bridge); + + $r = app(SupplierBalanceProvider::class)->fetch(); + expect($r->ok)->toBeTrue(); + expect($r->balance)->toBe(12400.0); + expect($r->serviceKey)->toBe('supplier'); +}); + +it('мост бросил → fail, не пробрасывает исключение', function () { + $bridge = Mockery::mock(PlaywrightBridge::class); + $bridge->shouldReceive('run')->andThrow(new RuntimeException('exit 2: balance not found')); + app()->instance(PlaywrightBridge::class, $bridge); + + $r = app(SupplierBalanceProvider::class)->fetch(); + expect($r->ok)->toBeFalse(); + expect($r->error)->toContain('exit 2'); +}); + +it('нет логина → fail без запуска моста', function () { + config()->set('services.supplier.login', ''); + $bridge = Mockery::mock(PlaywrightBridge::class); + $bridge->shouldNotReceive('run'); + app()->instance(PlaywrightBridge::class, $bridge); + + $r = app(SupplierBalanceProvider::class)->fetch(); + expect($r->ok)->toBeFalse(); +}); diff --git a/app/tests/Unit/External/YandexCloudBalanceProviderTest.php b/app/tests/Unit/External/YandexCloudBalanceProviderTest.php new file mode 100644 index 00000000..5e41789b --- /dev/null +++ b/app/tests/Unit/External/YandexCloudBalanceProviderTest.php @@ -0,0 +1,41 @@ +set('services.yandex_cloud.oauth_token', 'oauth-x'); + config()->set('services.yandex_cloud.billing_account_id', 'dn-test'); + config()->set('services.yandex_cloud.daily_spend_rub', 600); + Http::fake([ + 'iam.api.cloud.yandex.net/*' => Http::response(['iamToken' => 'iam-x'], 200), + 'billing.api.cloud.yandex.net/*' => Http::response(['balance' => '-540.48', 'currency' => 'RUB'], 200), + ]); + $r = app(YandexCloudBalanceProvider::class)->fetch(); + expect($r->ok)->toBeTrue(); + expect($r->balance)->toBe(-540.48); + expect($r->currency)->toBe('RUB'); + expect($r->dailySpend)->toBe(600.0); +}); + +it('нет токена → fail без сетевого вызова', function () { + config()->set('services.yandex_cloud.oauth_token', null); + Http::fake(); + $r = app(YandexCloudBalanceProvider::class)->fetch(); + expect($r->ok)->toBeFalse(); + Http::assertNothingSent(); +}); + +it('IAM не отдал токен → fail', function () { + config()->set('services.yandex_cloud.oauth_token', 'oauth-x'); + config()->set('services.yandex_cloud.billing_account_id', 'dn-test'); + Http::fake(['iam.api.cloud.yandex.net/*' => Http::response('nope', 401)]); + $r = app(YandexCloudBalanceProvider::class)->fetch(); + expect($r->ok)->toBeFalse(); + expect($r->error)->toContain('IAM'); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 430ea0e1..6687f419 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-06-27T09:49:38.210Z +Last updated: 2026-06-28T04:11:22.774Z | Контролёр | Состояние | Детали | |---|---|---| @@ -33,28 +33,25 @@ Last updated: 2026-06-27T09:49:38.210Z | enforce-coverage-verify.mjs | `enforce-coverage-verify.mjs` | 🔴 | | enforce-todowrite-skill-verifier.mjs | `enforce-todowrite-skill-verifier.mjs` | 🔴 | -Недавние escape владельца: 0 · Недавние блоки: 10 +Недавние escape владельца: 0 · Недавние блоки: 7 **Недавние блоки (детали):** | Время | Действие | Причина | |---|---|---| +| 2026-06-27T11:50:42.480Z | bash:cd "c:/моя/проекты/claude-brain" && git add -- "docs/superpowers/specs/2026-06-27-secretary-closing-doors-design.md | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:cd "c:/моя/проекты/cla | +| 2026-06-27T10:01:08.010Z | bash:git restore --staged docs/observer/STATUS.md 2>/dev/null; git diff --staged --name-only | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:git restore --staged d | | 2026-06-27T09:25:54.127Z | bash:node -e "for (const d of ['протокол-наставника','проблема-закрытия-вопросов-протокола','содержит']) { try { const p | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:node -e "for (const d | | 2026-06-27T07:03:56.852Z | bash:node -e "1" 2>/dev/null; for f in docs/secretary/*/protocol.md; do printf '%6s %s\n' "$(wc -l < "$f")" "$f"; done | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:node -e "1" 2>/dev/nul | | 2026-06-27T05:45:19.915Z | bash:rm ~/.claude/runtime/secretary-mode-*.json | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:rm ~/.claude/runtime/s | | 2026-06-27T04:18:53.490Z | powershell:Stop-Process -Id 4072 -Force | floor: опасная PowerShell-команда без аварийного выхода — блок (правило 8, V1-PS); FLOOR-ESCAPE: powershell:Stop-Process | | 2026-06-27T04:18:44.603Z | powershell:Stop-Process -Id 4072 -Force; if ($?) { "killed 4072" }; Start-Sleep -Milliseconds 300; Get-CimInstance Win32 | floor: опасная PowerShell-команда без аварийного выхода — блок (правило 8, V1-PS); FLOOR-ESCAPE: powershell:Stop-Process | -| 2026-06-27T04:08:56.319Z | bash:cd "/c/Users/Administrator/.claude/runtime/" && for f in secretary-mode-*.json; do printf '%s => ' "$f"; cat "$f"; | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:cd "/c/Users/Administr | -| 2026-06-27T04:07:22.527Z | bash:cd "c:/моя/проекты/claude-brain" && ls tools/ 2>&1 \| grep -i secret; echo "---grep config---"; grep -ril "secretar | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:cd "c:/моя/проекты/cla | -| 2026-06-27T03:41:52.937Z | bash:node -e "const fs=require('fs'),os=require('os'),p=require('path');const dir=p.join(os.homedir(),'.claude','runtime | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:node -e "const fs=requ | -| 2026-06-27T03:14:37.381Z | bash:find "c:\моя\проекты\claude-brain" -name "*sessionstart*" -type f 2>/dev/null | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:find "c:\моя\проекты\c | -| 2026-06-27T03:10:25.034Z | bash:grep -n "globals\\|include\\|exclude" vitest.config.tools.mjs 2>/dev/null \|\| echo "NO tools config at root" | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:grep -n "globals\\|inc | ## Метрики (информационные, не алерты) - Observer evidence: 2354 episodes this month, 0 observer_error markers, 8 PII matches before filter - Legacy v1 episodes (not in factor analysis): 2354 -- Last /brain-retro: 31 day(s) ago +- Last /brain-retro: 32 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). ## Метрики дисциплины @@ -132,9 +129,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3440 | MsMpEng | 16.03ч | NaNч | -| 21928 | Code | 6.25ч | NaNч | -| 1212 | svchost | 4.04ч | 0.0ч | +| 3440 | MsMpEng | 17.00ч | 0.0ч | +| 21928 | Code | 7.31ч | 0.0ч | +| 1212 | svchost | 4.38ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.