fix(settings): push trading-hours changes to live user prices immediately

Recompute home price display on save, broadcast settings over SSE, and keep last-price status in sync without a user refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 02:29:00 +03:30
co-authored by Cursor
parent 4562d00bd3
commit 8984899042
10 changed files with 300 additions and 44 deletions
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use App\Services\ConfigService;
use App\Services\PriceChannelBroadcastService;
use App\Services\SuperAdminProgramSettingsOverlay;
use Illuminate\Http\Request;
@@ -167,6 +168,14 @@ class SettingsController extends Controller
})->afterResponse();
}
if (app(ConfigService::class)->isTradingHoursSettingKey($key)) {
try {
app(ConfigService::class)->notifyTradingHoursChanged();
} catch (\Throwable $e) {
report($e);
}
}
if ($isNew) {
$projectName = Setting::where('key', 'name_fa')->value('value') ?: 'پروژه';
$settingTitle = trim($request->title_fa ?? '') ?: $request->key;
@@ -61,6 +61,11 @@ class PriceStreamController extends Controller
$send('last', app(ZaarboxPriceService::class)->attachGoldOunceToPayload($last));
}
$hoursSettings = app(\App\Services\ConfigService::class)->clientTradingHoursSettings();
if ($hoursSettings !== []) {
$send('settings', $hoursSettings);
}
$list = app(PriceBroadcastService::class)->buildListPayload(false);
if (is_array($list) && $list !== []) {
$send('list', $list);
@@ -98,15 +98,8 @@ class ReportController extends Controller
protected function appendUpPrices(array $payload, bool $saveFullCache = false): array
{
// status: open | closed (خارج از ساعت کاری) | no_quote (مظنه در دسترس نیست)
if (!isset($payload['status'])) {
if (!empty($payload['closed'])) {
$payload['status'] = 'closed';
} elseif ((int) ($payload['price'] ?? 0) <= 0 && (int) ($payload['buy'] ?? 0) <= 0) {
$payload['status'] = 'no_quote';
} else {
$payload['status'] = 'open';
}
}
$payload = app(ConfigService::class)->applyHomePriceDisplayState($payload);
$payload = app(ConfigService::class)->attachTradingHoursSettingsPatch($payload);
$payload['up'] = $this->buildUpDownPrices();
$payload['last_historical'] = app(ProductDisplayPriceService::class)->resolveHistoricalBasePrice();
+158 -19
View File
@@ -14,6 +14,17 @@ class ConfigService
{
public const PRICE_PROVIDERS = ['montzar', 'main_product', 'zaarbox'];
/** کلیدهای صفحه «ساعت کاری» پنل — هر تغییر باید بلافاصله روی قیمت کاربر پخش شود */
public const TRADING_HOURS_CLIENT_KEYS = [
'close_price',
'startDay',
'endDay',
'show_last_price',
'show_home_price_when_closed',
'show_gold_ounce_price',
'trading_hours_by_day',
];
/**
* حداکثر انحراف مجاز مظنه نسبت به آخرین قیمت معتبر DB.
* پرش اشتباه provider (مثل ۱۶۸M ماندهٔ montzar روی مظنهٔ ~۲۲۰M) را رد می‌کند.
@@ -751,40 +762,168 @@ class ConfigService
$start = $now->copy()->startOfDay()->setTime($startHour, $startMinute);
$end = $now->copy()->startOfDay()->setTime($endHour, $endMinute);
// بازهٔ شبانه (مثلاً ۲۲:۰۰ تا ۰۶:۰۰)
if ($end->lte($start)) {
return $now->lt($start) && $now->gt($end);
}
return !$now->between($start, $end);
}
public function shouldShowLastSavedPrice(): bool
{
return $this->getBool('show_last_price', false);
}
/** @param array<string, mixed> $payload */
public function payloadHasDisplayablePrice(array $payload): bool
{
return (int) ($payload['buy'] ?? 0) > 0
|| (int) ($payload['sell'] ?? 0) > 0
|| (int) ($payload['price_last'] ?? 0) > 0
|| (int) ($payload['price'] ?? 0) > 0;
}
/**
* گیت ساعت کاری روی payload کش/SSE — وقتی close_price روشن است.
* آخرین قیمت جدول prices را روی payload می‌گذارد — وقتی مظنه زنده نیست و show_last_price روشن است.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function fillPayloadFromLastStoredPrice(array $payload): array
{
if ($this->payloadHasDisplayablePrice($payload)) {
return $payload;
}
$stored = Price::query()->orderByDesc('id')->value('price');
$priceValue = $stored !== null ? (float) $stored : 0.0;
if ($priceValue <= 0) {
return $payload;
}
$trade = $this->buildTradeDisplayPrices($priceValue, 'database_last');
$payload['sell'] = $trade['sell'];
$payload['buy'] = $trade['buy'];
$payload['price_last'] = $trade['priceLast'];
$payload['price'] = (int) $priceValue;
$payload['price_source'] = $payload['price_source'] ?? 'database_last';
return $payload;
}
/**
* وضعیت نمایش صفحه اصلی + پرچم معامله:
* - close_price خاموش / داخل ساعت: قیمت هست → open ؛ وگرنه با show_last_price آخرین قیمت؛ وگرنه no_quote
* - close_price روشن و خارج ساعت: show_home_price_when_closed روشن → آخرین قیمت (open) ؛ خاموش → closed
* closed=true یعنی خرید/فروش بسته است؛ status فقط برای نمایش خانه است.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function applyHomePriceDisplayState(array $payload, ?Carbon $now = null): array
{
$outside = $this->isOutsideTradingHours($now);
$showWhenClosed = $this->shouldShowHomePriceWhenClosed();
$showLast = $this->shouldShowLastSavedPrice();
if ($outside) {
$payload['closed'] = true;
if ($showWhenClosed) {
$payload = $this->fillPayloadFromLastStoredPrice($payload);
$payload['status'] = $this->payloadHasDisplayablePrice($payload) ? 'open' : 'no_quote';
return $payload;
}
$payload['status'] = 'closed';
return $payload;
}
$payload['closed'] = false;
if ($showLast) {
$payload = $this->fillPayloadFromLastStoredPrice($payload);
}
$payload['status'] = $this->payloadHasDisplayablePrice($payload) ? 'open' : 'no_quote';
return $payload;
}
/**
* گیت ساعت کاری روی payload کش/SSE.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function applyGlobalTradingHoursToPayload(array $payload): array
{
if (!$this->isClosePriceEnabled() || !$this->isOutsideTradingHours()) {
return $payload;
return $this->applyHomePriceDisplayState($payload);
}
public function isTradingHoursSettingKey(string $key): bool
{
return in_array($key, self::TRADING_HOURS_CLIENT_KEYS, true);
}
/**
* تنظیمات ساعت کاری برای اپ کاربر (بدون فیلتر show_to_user — secret نیستند).
*
* @return list<array{key: string, value: mixed, is_active: mixed}>
*/
public function clientTradingHoursSettings(): array
{
$rows = Setting::query()
->whereIn('key', self::TRADING_HOURS_CLIENT_KEYS)
->get()
->keyBy('key');
$out = [];
foreach (self::TRADING_HOURS_CLIENT_KEYS as $key) {
$row = $rows->get($key);
$out[] = [
'key' => $key,
'value' => $row->value ?? '',
'is_active' => $row->is_active ?? 1,
];
}
if ($this->shouldShowHomePriceWhenClosed()) {
$payload['closed'] = true;
$payload['status'] = 'closed';
return $out;
}
return $payload;
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function attachTradingHoursSettingsPatch(array $payload): array
{
$payload['settings_patch'] = $this->clientTradingHoursSettings();
return $payload;
}
/**
* بعد از ذخیره ساعت کاری در پنل: کش last، SSE تنظیمات، و قیمت را همان لحظه برای کاربر بفرست.
*/
public function notifyTradingHoursChanged(): void
{
$this->syncAppClosedFlag();
$patch = $this->clientTradingHoursSettings();
$cache = app(LivePriceCacheService::class);
$payload = $cache->get() ?? $cache->buildFromDatabase();
$payload = $this->applyHomePriceDisplayState($payload);
$payload = app(ZaarboxPriceService::class)->attachGoldOunceToPayload($payload);
$payload['settings_patch'] = $patch;
$cache->replaceBody($payload);
$stream = app(PriceStreamService::class);
if ($patch !== []) {
$stream->publish('settings', $patch);
}
return [
'sell' => 0,
'buy' => 0,
'price_last' => 0,
'price' => 0,
'main_product' => (int) ($payload['main_product'] ?? 0),
'montzar' => (int) ($payload['montzar'] ?? 0),
'zaarbox' => (int) ($payload['zaarbox'] ?? 0),
'closed' => true,
'status' => 'closed',
'time' => now(),
];
app(PriceBroadcastService::class)->broadcastAfterPriceUpdate($payload, true);
}
public function syncAppClosedFlag(?Carbon $now = null): bool
+27 -6
View File
@@ -45,12 +45,7 @@ class LivePriceCacheService
return $cachedBody;
}
Cache::put(self::FULL_RESPONSE_KEY, [
'body' => $body,
'stored_at' => now(),
], now()->addSeconds(self::TTL_SECONDS + 5));
app(ProductDisplayPriceService::class)->cacheLivePricePayload($body);
$this->storeBody($body);
app(PriceBroadcastService::class)->broadcastLastIfChanged($body);
@@ -59,6 +54,32 @@ class LivePriceCacheService
return $body;
}
/**
* بازنویسی کش last بدون گیت هویت — برای اعمال فوری ساعت کاری.
*
* @param array<string, mixed> $body
* @return array<string, mixed>
*/
public function replaceBody(array $body): array
{
$this->storeBody($body);
return $body;
}
/**
* @param array<string, mixed> $body
*/
private function storeBody(array $body): void
{
Cache::put(self::FULL_RESPONSE_KEY, [
'body' => $body,
'stored_at' => now(),
], now()->addSeconds(self::TTL_SECONDS + 5));
app(ProductDisplayPriceService::class)->cacheLivePricePayload($body);
}
public function markTick(): void
{
Cache::put(self::LAST_TICK_KEY, now(), now()->addMinutes(10));
+4 -1
View File
@@ -34,6 +34,7 @@ class PriceBroadcastService
}
if (is_array($lastPayload) && $lastPayload !== []) {
$lastPayload = app(ConfigService::class)->applyHomePriceDisplayState($lastPayload);
$lastPayload = app(ZaarboxPriceService::class)->attachGoldOunceToPayload($lastPayload);
}
@@ -63,7 +64,7 @@ class PriceBroadcastService
/**
* @param array<string, mixed> $payload
* @return array<string, int|bool>
* @return array<string, int|bool|string>
*/
public static function lastPriceIdentity(array $payload): array
{
@@ -75,6 +76,7 @@ class PriceBroadcastService
'price_last' => (int) ($payload['price_last'] ?? 0),
'price' => (int) ($payload['price'] ?? 0),
'closed' => (bool) ($payload['closed'] ?? false),
'status' => (string) ($payload['status'] ?? ''),
'main_product' => (int) ($payload['main_product'] ?? 0),
'montzar' => (int) ($payload['montzar'] ?? 0),
'zaarbox' => (int) ($payload['zaarbox'] ?? 0),
@@ -93,6 +95,7 @@ class PriceBroadcastService
return;
}
$lastPayload = app(ConfigService::class)->applyHomePriceDisplayState($lastPayload);
$lastPayload = app(ZaarboxPriceService::class)->attachGoldOunceToPayload($lastPayload);
if (! $this->rememberHashIfChanged(self::LAST_HASH_KEY, self::lastPriceIdentity($lastPayload))) {