Files
boursecu_server/app/Services/ConfigService.php
T
mohmdsdgandCursor 8984899042 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>
2026-08-25 02:29:00 +03:30

1347 lines
45 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Models\Cnt;
use App\Models\Price;
use App\Models\PriceCandle;
use App\Models\Product;
use App\Models\Setting;
use Carbon\Carbon;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
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) را رد می‌کند.
*/
public const MAX_QUOTE_DEVIATION_RATIO = 0.15;
public function Config($name)
{
return $this->get($name, false);
}
/**
* آخرین قیمت ذخیره‌شده در جدول prices — مرجع سلامت مظنهٔ زنده.
*/
public function referenceQuotePrice(): float
{
$last = Price::query()->orderByDesc('id')->value('price');
return $last !== null ? (float) $last : 0.0;
}
/**
* مظنهٔ زنده وقتی خیلی از آخرین قیمت DB فاصله دارد قابل اتکا نیست (ماندهٔ provider اشتباه).
*/
public function isPlausibleQuotePrice(float $candidate, ?float $reference = null): bool
{
if ($candidate <= 0) {
return false;
}
$reference = $reference ?? $this->referenceQuotePrice();
if ($reference <= 0) {
return true;
}
return abs($candidate - $reference) / $reference <= self::MAX_QUOTE_DEVIATION_RATIO;
}
/**
* مقدار setting؛ اول سوپرادمین، بعد DB لوکال، بعد price_providers.php.
* برای کلیدهای can_edit=0 همیشه از این مسیر (یا ConfigService) بخوانید — نه Setting::where مستقیم.
*/
public function get(string $key, $default = null)
{
$fromSa = $this->valueFromSuperAdmin($key, $default);
if ($fromSa !== '__sa_miss__') {
return $fromSa;
}
try {
$row = Setting::where('key', $key)->first();
if ($row !== null && $row->is_active && $row->value !== null && $row->value !== '') {
return $row->value;
}
} catch (\Throwable $e) {
// DB در دسترس نیست (deploy / .env اشتباه) — از config/price_providers.php استفاده شود
}
$fromFile = config("price_providers.{$key}");
return $fromFile !== null ? $fromFile : $default;
}
/**
* مقدار سوپرادمین اگر کلید در overlay باشد؛ وگرنه سنتنل miss.
*
* @return mixed
*/
private function valueFromSuperAdmin(string $key, $default)
{
try {
$row = app(SuperAdminProgramSettingsOverlay::class)->row($key);
} catch (\Throwable $e) {
return '__sa_miss__';
}
if ($row === null) {
return '__sa_miss__';
}
$active = $row['is_active'] ?? 1;
if ($active === 0 || $active === false || $active === '0') {
$fromFile = config("price_providers.{$key}");
return $fromFile !== null ? $fromFile : $default;
}
$value = $row['value'] ?? null;
if ($value === null || $value === '') {
$fromFile = config("price_providers.{$key}");
return $fromFile !== null ? $fromFile : $default;
}
return $value;
}
public function getString(string $key, string $default = ''): string
{
$value = $this->get($key, $default);
return trim((string) ($value === false ? $default : $value));
}
public function getBool(string $key, bool $default = false): bool
{
$value = $this->get($key, $default);
if ($value === false || $value === null || $value === '') {
return $default;
}
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
/**
* سوییچ‌های پیامک اعلان (sms_notif_* / charge_notif).
* کلید نباشد → $default (معمولاً true تا رفتار قبلی حفظ شود).
*/
public function isSmsNotifEnabled(string $key, bool $default = true): bool
{
return $this->getBool($key, $default);
}
/** فروشگاهی (قطره / کشکول) — روت‌ها و رفتار store فقط وقتی true باشد فعال می‌شوند */
public function isStore(): bool
{
return $this->getBool('is_store', false);
}
/** کد معرف در ثبت‌نام اجباری است (referral_id = required) — فقط وقتی فیلد نمایش داده شود */
public function isReferralRequired(): bool
{
if (! $this->getBool('referral_show', true)) {
return false;
}
return strtolower(trim($this->getString('referral_id'))) === 'required';
}
/** فیلد کد معرف در صفحه ثبت‌نام اپ کاربر نمایش داده شود */
public function isReferralFieldVisible(): bool
{
return $this->getBool('referral_show', true);
}
public function getInt(string $key, int $default = 0): int
{
$value = $this->get($key, $default);
return (int) ($value === false || $value === null || $value === '' ? $default : $value);
}
/** X-Client-Id برای API خارجی (HMAC) — اول settings، بعد .env NODE_CLIENT_ID */
public function nodeClientId(): string
{
$fromDb = $this->getString('node_client_id', '');
return $fromDb !== '' ? $fromDb : (string) env('NODE_CLIENT_ID', '');
}
/** کلید امضای HMAC — اول settings، بعد .env NODE_SHARED_SECRET */
public function nodeSharedSecret(): string
{
$fromDb = $this->getString('node_shared_secret', '');
return $fromDb !== '' ? $fromDb : (string) env('NODE_SHARED_SECRET', '');
}
/** اختلاف خرید نمایش (/api/price/last) — فقط buy_fee (صفر یعنی بدون اختلاف) */
public function displayBuyPriceDiffRial(): float
{
$raw = $this->getString('buy_fee', '');
return $raw === '' ? 0.0 : (float) $raw;
}
/** اختلاف فروش نمایش — فقط sell_fee (صفر یعنی بدون اختلاف) */
public function displaySellPriceDiffRial(): float
{
$raw = $this->getString('sell_fee', '');
return $raw === '' ? 0.0 : (float) $raw;
}
/**
* اختلاف ریالی سفارش — فقط buy_fee / sell_fee بر اساس جهت.
* fee_single دیگر منبع نیست.
*
* @param int|null $direction 1=خرید، 2=فروش، null=خرید (برای سازگاری)
*/
public function orderPriceDiffRial(?int $direction = null): float
{
if ($direction === 2) {
return $this->displaySellPriceDiffRial();
}
return $this->displayBuyPriceDiffRial();
}
/**
* اختلاف دوطرفهٔ نرخ همکار (نصف فاصلهٔ نرخ خرید و فروش همکار).
*
* پایهٔ ذخیره‌شده (montzar_live_price) وسط این دو نرخ است، پس نرخ واقعی معاملهٔ ما با همکار
* «پایه + این مقدار» برای خرید و «پایه − این مقدار» برای فروش است.
* فقط برای provider همکار معنا دارد؛ بقیه نرخ دوطرفه نمی‌دهند.
*/
public function providerTradeSpreadRial(): float
{
if ($this->priceProvider() !== 'montzar') {
return 0.0;
}
return max(0.0, (float) $this->get('montzar_diff', 0));
}
/** درصد کارمزد سفارش — فقط fee_service_charge_percentage؛ خالی = ۰ */
public function orderServiceChargePercentage(): float
{
$raw = trim($this->getString('fee_service_charge_percentage', ''));
if ($raw === '') {
return 0.0;
}
return $this->normalizeServiceChargeRatio((float) $raw);
}
/**
* نسبت کارمزد (۰٫۰۱ = ۱٪). اگر ادمین «۱» به‌جای «۰٫۰۱» گذاشته باشد نرمال می‌شود.
*/
private function normalizeServiceChargeRatio(float $value): float
{
if ($value <= 0) {
return 0.0;
}
// مقدار ≥۱ یعنی درصد انسانی وارد شده (۱ → ۱٪)، نه نسبت
if ($value >= 1.0) {
return $value / 100.0;
}
return $value;
}
/**
* روشن: فروش = مبلغ ریالی دقیق (کارمزد از طلا)؛ خرید = وزن دقیق (کارمزد از ریال).
* خاموش: فروش = کارمزد از ریال واریزی؛ خرید = کارمزد از وزن طلای واریزی.
*/
public function feeDeductFromGold(): bool
{
return $this->getBool('fee_deduct_from_gold', false);
}
/**
* sell / buy / price_last — یک منبع حقیقت برای lastPrice و buildFromDatabase.
*
* @param float $priceValue قیمت پایه (ریال/گرم) — از API یا DB
* @param string|null $priceSource montzar | zaarbox | main_product | database_last | null
* @param array{montzar_open_buy?:bool,montzar_open_sell?:bool} $montzarFlags فقط وقتی API تازه خوانده شده
* @return array{sell:int,buy:int,priceLast:int}
*/
public function buildTradeDisplayPrices(
float $priceValue,
?string $priceSource = null,
array $montzarFlags = []
): array {
$diffBuy = (float) $this->displayBuyPriceDiffRial();
$diffSell = (float) $this->displaySellPriceDiffRial();
$openBuy = true;
$openSell = true;
if ($priceSource === 'montzar') {
$montzarProfit = (float) $this->get('montzar_diff', 0);
$finalBuyDiff = $diffBuy + $montzarProfit;
$finalSellDiff = $diffSell + $montzarProfit;
// montzar_open_buy/sell وضعیت همکار است — نمایش قیمت را صفر نکن.
// باز/بسته بودن معامله = product.open_buy/open_sell در اپ محلی.
$sell = (int) ceil($priceValue - $finalSellDiff);
$buy = (int) ceil($priceValue + $finalBuyDiff);
$priceLast = (int) ceil($priceValue + $finalBuyDiff);
return compact('sell', 'buy', 'priceLast');
}
$sell = $openSell ? (int) ceil($priceValue - $diffSell) : 0;
$buy = $openBuy ? (int) ceil($priceValue + $diffBuy) : 0;
$priceLast = (int) ceil($priceValue + $diffBuy);
return compact('sell', 'buy', 'priceLast');
}
/**
* تنها منبع فعال قیمت — montzar | main_product | zaarbox
*
* اولویت:
* 1) price_provider (یا price_primary_source قدیمی) — فقط مقادیر معتبر
* 2) main_product_price=true → main_product (legacy)
* 3) اگر zaarbox_price_key تنظیم شده → zaarbox (نه پیش‌فرض montzar)
* 4) پیش‌فرض فایل
*/
public function priceProvider(): string
{
foreach (['price_provider', 'price_primary_source'] as $key) {
$explicit = strtolower($this->getString($key, ''));
if (in_array($explicit, self::PRICE_PROVIDERS, true)) {
return $explicit;
}
}
if ($this->getBool('main_product_price', false)) {
return 'main_product';
}
if ($this->getString('zaarbox_price_key', '') !== '') {
return 'zaarbox';
}
$default = strtolower((string) config('price_providers.price_provider', 'montzar'));
return in_array($default, self::PRICE_PROVIDERS, true) ? $default : 'montzar';
}
/**
* محصولات متصل به کیف اصلی — setting `main_wallet_products`.
* مقدار: idها با کاما (مثل «13,25») یا آرایه JSON (مثل «[13,25]»).
* موجودی این محصولات = gold کیف اصلی (دارایی جدا ندارند).
* اگر خالی باشد پیش‌فرض [13] است.
*/
public function mainWalletProductIds(): array
{
$raw = trim($this->getString('main_wallet_products', ''));
if ($raw === '') {
return [13];
}
$decoded = json_decode($raw, true);
$parts = is_array($decoded) ? $decoded : explode(',', $raw);
$ids = [];
foreach ($parts as $part) {
$id = (int) trim((string) $part);
if ($id > 0) {
$ids[] = $id;
}
}
$ids = array_values(array_unique($ids));
return $ids !== [] ? $ids : [13];
}
public function isMainWalletProduct(int $productId): bool
{
return in_array($productId, $this->mainWalletProductIds(), true);
}
/** اولین محصول فعال از `main_wallet_products` — نماینده کیف اصلی */
public function mainWalletRepresentativeProduct(): ?Product
{
foreach ($this->mainWalletProductIds() as $productId) {
$product = Product::query()->where('active', 1)->find($productId);
if ($product) {
return $product;
}
}
foreach ($this->mainWalletProductIds() as $productId) {
$product = Product::query()->find($productId);
if ($product) {
return $product;
}
}
return null;
}
/** metal_id محصول نماینده کیف اصلی — پیش‌فرض ۱ (طلا) */
public function mainWalletMetalId(): int
{
$product = $this->mainWalletRepresentativeProduct();
$metalId = $product ? (int) ($product->metal_id ?? 0) : 0;
return $metalId > 0 ? $metalId : 1;
}
/** عنوان محصول(ات) متصل به کیف اصلی — ترتیب همان main_wallet_products */
public function mainWalletProductLabels(): string
{
$titles = [];
foreach ($this->mainWalletProductIds() as $productId) {
$title = Product::query()->whereKey($productId)->value('title');
if ($title !== null && trim((string) $title) !== '') {
$titles[] = trim((string) $title);
}
}
return implode(' و ', $titles);
}
/**
* شناسه محصول آبشده: اول settings، بعد const.
* مقدار settings می‌تواند id عددی (مثل 13) یا reading_method (مثل estjt_...) باشد.
*/
public function mainProductId(): ?int
{
$product = $this->mainProduct();
return $product?->id;
}
public function mainProduct(): ?Product
{
foreach ([$this->getString('main_product', ''), (string) (Cnt::where('key', 'main_product')->value('value') ?? '')] as $raw) {
$raw = trim($raw);
if ($raw === '') {
continue;
}
if (is_numeric($raw)) {
$product = Product::find((int) $raw);
if ($product) {
return $product;
}
}
$byMethod = Product::where('reading_method', $raw)->first();
if ($byMethod) {
return $byMethod;
}
}
// اگر main_product خالی/نامعتبر بود، همان نمایندهٔ main_wallet_products
return $this->mainWalletRepresentativeProduct();
}
/** @deprecated از priceProvider() استفاده کن */
public function pricePrimarySource(): string
{
$provider = $this->priceProvider();
return in_array($provider, ['montzar'], true) ? $provider : 'montzar';
}
public function usesMontzarPricing(): bool
{
return $this->priceProvider() === 'montzar';
}
/** @deprecated منبع etehadie حذف شده */
public function usesEtehadiePricing(): bool
{
return false;
}
public function usesMainProductPricing(): bool
{
return $this->priceProvider() === 'main_product';
}
public function usesZaarboxPricing(): bool
{
return $this->priceProvider() === 'zaarbox';
}
public function UpdateConfig($name, $value)
{
$payload = ['value' => $value];
if (SuperAdminProgramSettingsOverlay::isRuntime((string) $name)) {
$payload['can_edit'] = 1;
}
return Setting::updateOrCreate(
['key' => $name],
$payload
);
}
public function trackDailyPriceExtreme(float $price): void
{
if ($price <= 0) {
return;
}
$upSetting = Setting::where('key', 'up_price')->first();
$downSetting = Setting::where('key', 'down_price')->first();
if (!$upSetting || !$downSetting) {
return;
}
$priceInt = (int) round($price);
$today = now()->toDateString();
$extremeDate = $this->Config('price_extreme_date');
$currentUp = (float) ($upSetting->value ?: 0);
$currentDown = (float) ($downSetting->value ?: 0);
$anchor = $currentUp > 0 ? $currentUp : $this->referenceQuotePrice();
if ($anchor > 0 && ! $this->isPlausibleQuotePrice((float) $priceInt, $anchor)) {
return;
}
if ($extremeDate !== $today) {
$this->UpdateConfig('up_price', $priceInt);
$this->UpdateConfig('down_price', $priceInt);
$this->UpdateConfig('price_extreme_date', $today);
return;
}
if ($currentUp <= 0 || $priceInt > $currentUp) {
$this->UpdateConfig('up_price', $priceInt);
}
if ($currentDown <= 0 || $priceInt < $currentDown) {
$this->UpdateConfig('down_price', $priceInt);
}
}
/**
* بیشترین/کمترین قیمت پایه امروز — settings + candle + جدول prices.
*
* @return array{up: int, down: int}
*/
public function resolvedDailyBaseExtremes(): array
{
$up = $this->getInt('up_price', 0);
$down = $this->getInt('down_price', 0);
$candle = PriceCandle::whereDate('date_at', now()->toDateString())->first();
$candleHigh = $candle ? (int) round((float) $candle->high) : 0;
$candleLow = $candle ? (int) round((float) $candle->low) : 0;
if ($candleHigh > 0) {
$up = $up > 0 ? max($up, $candleHigh) : $candleHigh;
}
if ($candleLow > 0) {
$down = $down > 0 ? min($down, $candleLow) : $candleLow;
}
if ($up <= 0 || $down <= 0) {
$fromTable = $this->filteredTodayMinMax();
if ($up <= 0 && $fromTable['max'] > 0) {
$up = $fromTable['max'];
}
if ($down <= 0 && $fromTable['min'] > 0) {
$down = $fromTable['min'];
}
}
if ($up <= 0 && $candleHigh > 0) {
$up = $candleHigh;
}
if ($down <= 0 && $candleLow > 0) {
$down = $candleLow;
}
if ($up > 0 && $down > 0 && $down > $up) {
[$up, $down] = [$down, $up];
}
// کف روز آلوده به مظنهٔ اشتباه provider (مثلاً ۱۶۸M) را دور بریز
if ($up > 0 && $down > 0 && ! $this->isPlausibleQuotePrice((float) $down, (float) $up)) {
$fromTable = $this->filteredTodayMinMax();
if ($fromTable['min'] > 0 && $this->isPlausibleQuotePrice((float) $fromTable['min'], (float) $up)) {
$down = $fromTable['min'];
} else {
$down = $up;
}
if ($this->getInt('down_price', 0) !== $down) {
$this->UpdateConfig('down_price', $down);
}
}
$storedDown = $this->getInt('down_price', 0);
if ($down > 0 && $down < $storedDown && $this->isPlausibleQuotePrice((float) $down, (float) max($up, 1))) {
$this->UpdateConfig('down_price', $down);
}
return ['up' => $up, 'down' => $down];
}
/**
* @return array{min: int, max: int}
*/
public function filteredTodayMinMax(): array
{
$prices = Price::query()
->whereDate('date_at', now()->toDateString())
->pluck('price')
->map(fn ($price) => (int) round((float) $price))
->filter(fn (int $price) => $price > 0)
->values();
if ($prices->isEmpty()) {
return ['min' => 0, 'max' => 0];
}
return [
'min' => (int) $prices->min(),
'max' => (int) $prices->max(),
];
}
public function isClosePriceEnabled(): bool
{
return $this->getBool('close_price', false);
}
/** خارج از ساعت کاری، قیمت صفحه اصلی را نشان بده (معامله همچنان بسته) */
public function shouldShowHomePriceWhenClosed(): bool
{
return $this->getBool('show_home_price_when_closed', false);
}
/** @return array{start: string, end: string} */
public function tradingHourBounds(): array
{
return [
'start' => $this->normalizeTradingTime($this->getString('startDay', '00:00'), '00:00'),
'end' => $this->normalizeTradingTime($this->getString('endDay', '23:59'), '23:59'),
];
}
/**
* @return array{mode: 'uniform'|'per_day', days: array<string, array{enabled: bool, start: string, end: string}>}|null
*/
public function tradingHoursByDay(): ?array
{
$raw = $this->getString('trading_hours_by_day', '');
if ($raw === '') {
return null;
}
try {
$parsed = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable) {
return null;
}
if (! is_array($parsed) || ! isset($parsed['days']) || ! is_array($parsed['days'])) {
return null;
}
$mode = ($parsed['mode'] ?? 'uniform') === 'per_day' ? 'per_day' : 'uniform';
$defaults = $this->tradingHourBounds();
$dayKeys = ['sat', 'sun', 'mon', 'tue', 'wed', 'thu', 'fri'];
$days = [];
foreach ($dayKeys as $key) {
$row = is_array($parsed['days'][$key] ?? null) ? $parsed['days'][$key] : [];
$days[$key] = [
'enabled' => ($row['enabled'] ?? true) !== false,
'start' => $this->normalizeTradingTime((string) ($row['start'] ?? $defaults['start']), $defaults['start']),
'end' => $this->normalizeTradingTime((string) ($row['end'] ?? $defaults['end']), $defaults['end']),
];
}
return [
'mode' => $mode,
'days' => $days,
];
}
private function weekdayKey(Carbon $now): string
{
return match ($now->dayOfWeek) {
Carbon::SATURDAY => 'sat',
Carbon::SUNDAY => 'sun',
Carbon::MONDAY => 'mon',
Carbon::TUESDAY => 'tue',
Carbon::WEDNESDAY => 'wed',
Carbon::THURSDAY => 'thu',
Carbon::FRIDAY => 'fri',
default => 'sat',
};
}
/**
* بازهٔ مؤثر ساعت کاری برای لحظهٔ فعلی.
*
* @return array{enabled: bool, start: string, end: string}
*/
public function effectiveTradingBounds(?Carbon $now = null): array
{
$now = $now ?? Carbon::now();
$weekly = $this->tradingHoursByDay();
if ($weekly !== null && $weekly['mode'] === 'per_day') {
$day = $weekly['days'][$this->weekdayKey($now)] ?? null;
if ($day !== null) {
return $day;
}
}
$bounds = $this->tradingHourBounds();
return [
'enabled' => true,
'start' => $bounds['start'],
'end' => $bounds['end'],
];
}
private function normalizeTradingTime(string $value, string $default): string
{
$value = trim($value);
if ($value === '') {
$value = $default;
}
if (preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $value, $matches)) {
return sprintf('%02d:%02d', (int) $matches[1], (int) $matches[2]);
}
return $default;
}
public function isOutsideTradingHours(?Carbon $now = null): bool
{
if (!$this->isClosePriceEnabled()) {
return false;
}
$now = $now ?? Carbon::now();
$bounds = $this->effectiveTradingBounds($now);
if (!$bounds['enabled']) {
return true;
}
[$startHour, $startMinute] = array_map('intval', explode(':', $bounds['start']));
[$endHour, $endMinute] = array_map('intval', explode(':', $bounds['end']));
$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;
}
/**
* آخرین قیمت جدول 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
{
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,
];
}
return $out;
}
/**
* @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);
}
app(PriceBroadcastService::class)->broadcastAfterPriceUpdate($payload, true);
}
public function syncAppClosedFlag(?Carbon $now = null): bool
{
$closed = $this->isOutsideTradingHours($now);
Cnt::where('key', 'app_closed')->update(['value' => $closed ? 'true' : 'false']);
return $closed;
}
/**
* montzar_closed فقط وضعیت sync زنده است — نباید به‌تنهایی سفارش را ببندد.
* اگر sync خطا داد ولی montzar_live_price و open_buy/sell معتبرند، معامله باز بماند.
*/
public function applyMontzarSyncFailure(): void
{
$staleLive = $this->getInt('montzar_live_price', 0);
$usable = $staleLive > 0 && $this->isPlausibleQuotePrice((float) $staleLive);
$this->UpdateConfig('montzar_closed', $usable ? 'false' : 'true');
}
/**
* باز/بسته بودن خرید و فروش فقط دست ادمین است — sync همکار آن را بازنویسی نمی‌کند.
* مقدار ذخیره‌شده در تنظیمات همان چیزی است که ادمین روشن/خاموش کرده.
*/
public function montzarOpenFlags(): array
{
return [
'open_buy' => $this->getBool('montzar_open_buy', true),
'open_sell' => $this->getBool('montzar_open_sell', true),
];
}
public function formatTradingTimeForDisplay(string $time): string
{
$normalized = $this->normalizeTradingTime($time, '00:00');
return $normalized . ':00';
}
public function tradingClosedMessage(?Carbon $now = null): string
{
$now = $now ?? Carbon::now();
$bounds = $this->effectiveTradingBounds($now);
if (!$bounds['enabled']) {
return 'فروشگاه امروز تعطیل است.';
}
$start = $this->formatTradingTimeForDisplay($bounds['start']);
$end = $this->formatTradingTimeForDisplay($bounds['end']);
return "فروشگاه در حال حاضر بسته است. ساعت کاری از {$start} تا {$end} می‌باشد.";
}
public function assertTradingOpen(?Carbon $now = null): void
{
if ($this->isOutsideTradingHours($now)) {
throw new UnprocessableEntityHttpException($this->tradingClosedMessage($now));
}
}
public function hasProductTradingWindow(?string $open, ?string $close): bool
{
if ($open === null || $close === null || trim($open) === '' || trim($close) === '') {
return false;
}
return $this->formatProductTime($open) !== $this->formatProductTime($close);
}
public function formatProductTime(?string $time): string
{
if ($time === null || trim($time) === '') {
return '00:00:00';
}
$time = trim($time);
if (preg_match('/(\d{1,2}:\d{2}(?::\d{2})?)/', $time, $matches)) {
$parts = explode(':', $matches[1]);
return sprintf(
'%02d:%02d:%02d',
(int) ($parts[0] ?? 0),
(int) ($parts[1] ?? 0),
(int) ($parts[2] ?? 0)
);
}
return '00:00:00';
}
public function isWithinTimeWindow(?string $open, ?string $close, ?Carbon $now = null): bool
{
if (!$this->hasProductTradingWindow($open, $close)) {
return true;
}
$now = $now ?? Carbon::now();
$current = $now->format('H:i:s');
$openStr = $this->formatProductTime($open);
$closeStr = $this->formatProductTime($close);
return $current >= $openStr && $current <= $closeStr;
}
/**
* آیا محصول بازه زمانی اختصاصی خودش را override کرده؟
* فقط وقتی سوییچ override_trading_hours روشن باشد و بازه معتبری (شروع != پایان) داشته باشد.
*/
public function productOverridesTradingHours(Product $product): bool
{
if (!Product::hasOverrideTradingHoursColumn()) {
return false;
}
if (!($product->override_trading_hours ?? false)) {
return false;
}
return $this->hasProductTradingWindow($product->auto_open_at, $product->auto_close_at);
}
/**
* ساعت معامله یک محصول:
* - اگر سوییچ override محصول روشن باشد → فقط بازه اختصاصی خود محصول حاکم است (سراسری نادیده گرفته می‌شود).
* - اگر خاموش باشد → لایهٔ بیرونیِ ساعت کاری محصول (auto_open_at/auto_close_at) همیشه اعمال می‌شود،
* مستقل از اینکه close_price روشن باشد یا نه.
* - فقط اگر محصول هیچ بازهٔ معتبری نداشته باشد → فیلتر سراسری close_price + startDay/endDay اعمال می‌شود.
*/
public function isProductWithinTradingHours(Product $product, ?Carbon $now = null): bool
{
if ($this->productOverridesTradingHours($product)) {
return $this->isWithinTimeWindow($product->auto_open_at, $product->auto_close_at, $now);
}
if ($this->hasProductTradingWindow($product->auto_open_at, $product->auto_close_at)) {
return $this->isWithinTimeWindow($product->auto_open_at, $product->auto_close_at, $now);
}
if ($this->isClosePriceEnabled() && $this->isOutsideTradingHours($now)) {
return false;
}
return true;
}
public function productTradingClosedMessage(Product $product): string
{
$title = trim((string) ($product->title ?? 'محصول'));
if (
$this->productOverridesTradingHours($product)
|| $this->hasProductTradingWindow($product->auto_open_at, $product->auto_close_at)
) {
$open = $this->formatProductTime($product->auto_open_at);
$close = $this->formatProductTime($product->auto_close_at);
return "محصول «{$title}» در حال حاضر بسته است. ساعت فعال‌سازی از {$open} تا {$close} می‌باشد.";
}
return $this->tradingClosedMessage();
}
public function assertProductTradingOpen(Product $product, ?Carbon $now = null): void
{
if (!$this->isProductWithinTradingHours($product, $now)) {
throw new UnprocessableEntityHttpException($this->productTradingClosedMessage($product));
}
}
/**
* سوییچ require_zero_main_gold_before_other_buy (پیش‌فرض true).
* اگر مانده طلای کیف اصلی صفر یا زیر صفر باشد (منفی)، خرید محصول خارج از کیف اصلی
* تا صفر شدن بدهی مسدود است. معامله خودِ محصولات main_wallet_products همیشه مجاز است.
*
* @param object|null $mainWallet کیف اصلی (product_id=null) با فیلد gold
*/
public function assertMainGoldZeroBeforeOtherProductBuy(?object $mainWallet, Product $product): void
{
if (!$this->getBool('require_zero_main_gold_before_other_buy', true)) {
return;
}
$productId = (int) ($product->id ?? 0);
if ($productId <= 0 || $this->isMainWalletProduct($productId)) {
return;
}
$gold = (float) ($mainWallet->gold ?? 0);
// صفر = از قبل تسویه؛ فقط بدهی (زیر صفر) مسدود می‌کند
if ($gold >= 0) {
return;
}
$label = $this->mainWalletProductLabels() ?: 'آبشده / کیف اصلی';
throw new UnprocessableEntityHttpException(
"مانده طلای کیف اصلی منفی است. ابتدا مانده «{$label}» را صفر کنید، سپس می‌توانید محصول دیگر بخرید."
);
}
public const ACCOUNTING_NONE = 'none';
public const ACCOUNTING_TAHESAB = 'tahesab';
public const ACCOUNTING_PARGAR = 'pargar';
public const ACCOUNTING_ZARIN = 'zarin';
public function isConditionalOrderEnabled(): bool
{
return $this->getBool('enable_conditional_order', false);
}
/** سوییچ سوپرادمین اتوماسیون ادمین (پیش‌فرض خاموش). */
public function isAutomationEnabled(): bool
{
return $this->getBool('enable_automation', false);
}
/** سوییچ دیپلوی قلک per-product (can_edit=0، پیش‌فرض خاموش). */
public function isProductFundsEnabled(): bool
{
return $this->getBool('enable_product_funds', false);
}
/**
* سوییچ اصلی جیبیت (can_edit=0، پیش‌فرض روشن).
* خاموش = هیچ فراخوانی احراز هویت به جیبیت انجام نمی‌شود.
*/
public function isJibitEnabled(): bool
{
return $this->getBool('jibit_enabled', true);
}
/** تطبیق کدملی/موبایل در ثبت‌نام و پروفایل — فقط اگر جیبیت سراسری و این سوییچ روشن باشد */
public function isRegisterJibitVerifyEnabled(): bool
{
return $this->isJibitEnabled() && $this->getBool('register_jibit_verify', false);
}
/** تطبیق کارت بانکی با جیبیت — فقط اگر جیبیت سراسری و این سوییچ روشن باشد */
public function isJibitCardVerificationEnabled(): bool
{
return $this->isJibitEnabled() && $this->getBool('jibit_verification', false);
}
/** کارمزد ریالی هر بار ثبت تریگر سفارش شرطی */
public function conditionalOrderFee(): int
{
return max(0, $this->getInt('fee_conditional_order', 0));
}
/** none | tahesab | pargar | zarin — از setting accounting_provider */
public function accountingProvider(): string
{
$provider = strtolower(trim($this->getString('accounting_provider', '')));
if (in_array($provider, [self::ACCOUNTING_NONE, self::ACCOUNTING_TAHESAB, self::ACCOUNTING_PARGAR, self::ACCOUNTING_ZARIN], true)) {
return $provider;
}
if ($this->getString('send_to_pargar', 'false') === 'true') {
return self::ACCOUNTING_PARGAR;
}
if ($this->getString('send_to_tahesab', 'false') === 'true') {
return self::ACCOUNTING_TAHESAB;
}
return self::ACCOUNTING_NONE;
}
public function isTahesabAccounting(): bool
{
return $this->accountingProvider() === self::ACCOUNTING_TAHESAB;
}
public function isPargarAccounting(): bool
{
return $this->accountingProvider() === self::ACCOUNTING_PARGAR;
}
public function isZarinAccounting(): bool
{
return $this->accountingProvider() === self::ACCOUNTING_ZARIN;
}
/**
* Hostname for WebOTP SMS (@domain #code) and other public links.
* Prefer settings.app_url (دامنهٔ اپ کاربر) over the API request host —
* API often lives on a different subdomain (e.g. *.gholaktala.ir).
*/
public function resolvePublicHost(?\Illuminate\Http\Request $request = null): string
{
foreach ($this->publicHostCandidates($request) as $host) {
if ($this->isUsableHost($host)) {
return $this->normalizeHost($host);
}
}
return '';
}
/** Full user-facing site URL, e.g. https://example.ir — prefers settings.app_url */
public function resolvePublicSiteUrl(?\Illuminate\Http\Request $request = null): string
{
$fromAppUrl = $this->normalizedAppUrl();
if ($fromAppUrl !== '') {
return $fromAppUrl;
}
if ($request !== null) {
$origin = rtrim(trim((string) $request->getSchemeAndHttpHost()), '/');
$host = $this->parseHostFromUrl($origin);
if ($this->isUsableHost($host)) {
return $origin;
}
}
if (! app()->runningInConsole() && request()) {
$origin = rtrim(trim((string) request()->getSchemeAndHttpHost()), '/');
$host = $this->parseHostFromUrl($origin);
if ($this->isUsableHost($host)) {
return $origin;
}
}
$host = $this->resolvePublicHost($request);
if ($host !== '') {
return 'https://' . $host;
}
return '';
}
/** @deprecated Use resolvePublicHost() — kept for backward compatibility */
public function resolveHostForOtp(string $urlSettingKey = 'app_url', ?\Illuminate\Http\Request $request = null): string
{
return $this->resolvePublicHost($request);
}
/** @return list<string> */
private function publicHostCandidates(?\Illuminate\Http\Request $request): array
{
$candidates = [];
$appUrlHost = $this->parseHostFromUrl((string) $this->get('app_url', ''));
if ($appUrlHost !== '') {
$candidates[] = $appUrlHost;
}
if ($request !== null) {
$candidates[] = trim((string) $request->getHost());
}
if (! app()->runningInConsole() && request()) {
$candidates[] = trim((string) request()->getHost());
}
return $candidates;
}
/** settings.app_url as https://host… without trailing slash; empty if unusable */
private function normalizedAppUrl(): string
{
$raw = trim((string) $this->get('app_url', ''));
if ($raw === '') {
return '';
}
if (! preg_match('#^https?://#i', $raw)) {
$raw = 'https://' . ltrim($raw, '/');
}
$host = $this->parseHostFromUrl($raw);
if (! $this->isUsableHost($host)) {
return '';
}
$scheme = parse_url($raw, PHP_URL_SCHEME) ?: 'https';
$port = parse_url($raw, PHP_URL_PORT);
$origin = strtolower((string) $scheme) . '://' . $this->normalizeHost($host);
if ($port !== null && ! in_array((int) $port, [80, 443], true)) {
$origin .= ':' . $port;
}
return $origin;
}
private function isUsableHost(string $host): bool
{
$host = strtolower(trim($host));
return $host !== ''
&& ! in_array($host, ['localhost', '127.0.0.1', '0.0.0.0'], true);
}
private function parseHostFromUrl(string $url): string
{
$url = trim($url);
if ($url === '') {
return '';
}
if (! preg_match('#^https?://#i', $url)) {
$url = 'https://' . ltrim($url, '/');
}
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return '';
}
return $this->normalizeHost($host);
}
private function normalizeHost(string $host): string
{
$host = strtolower(trim($host));
return str_starts_with($host, 'www.') ? substr($host, 4) : $host;
}
}