fa404e98ec
Переиспользование одного DB-билдера в цикле накапливало where-клаузы → updateOrInsert уходил в INSERT существующей строки → SQLSTATE 23505 на проде при повторном сборе. Билдер теперь создаётся внутри цикла. + тест на 2 прогона. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
111 lines
4.3 KiB
PHP
111 lines
4.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Jobs\External;
|
|
|
|
use App\Services\Dashboard\BalanceHealth;
|
|
use App\Services\External\BalanceProvider;
|
|
use App\Services\External\DadataBalanceProvider;
|
|
use App\Services\External\SupplierBalanceProvider;
|
|
use App\Services\External\YandexCloudBalanceProvider;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Ежедневно собирает баланс внешних сервисов и пишет в external_service_balances.
|
|
* Каждый провайдер изолирован: fetch() не бросает; ok=false оставляет ПРОШЛЫЙ баланс
|
|
* + метку ошибки (плитка не падает, показывает «данные от ДАТА»). Пишет под
|
|
* crm_supplier_worker (BYPASSRLS) — таблица системная, как supplier_sync_runs.
|
|
*
|
|
* Spec: docs/superpowers/specs/2026-06-28-external-service-balances-design.md
|
|
*/
|
|
class RefreshExternalBalancesJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public const DB_CONNECTION = 'pgsql_supplier'; // BYPASSRLS для записи системной таблицы
|
|
|
|
/** @return array<int,class-string<BalanceProvider>> */
|
|
private function providers(): array
|
|
{
|
|
return [
|
|
DadataBalanceProvider::class,
|
|
SupplierBalanceProvider::class,
|
|
YandexCloudBalanceProvider::class,
|
|
];
|
|
}
|
|
|
|
public function handle(): void
|
|
{
|
|
foreach ($this->providers() as $cls) {
|
|
/** @var BalanceProvider $p */
|
|
$p = app($cls);
|
|
$key = $p->serviceKey();
|
|
$reading = $p->fetch(); // не бросает
|
|
|
|
// Свежий query-builder на КАЖДУЮ итерацию: переиспользование одного билдера
|
|
// накапливает where-клаузы (service_key=A AND service_key=B…) → updateOrInsert
|
|
// ошибочно идёт в INSERT существующей строки → нарушение PK.
|
|
$table = DB::connection(self::DB_CONNECTION)->table('external_service_balances');
|
|
|
|
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],
|
|
};
|
|
}
|
|
}
|