montzar_live_price is the midpoint between the colleague's buy and sell rates, so orders priced straight from it charged the customer less than we pay the provider and the whole spread was lost on every trade, even though the app already showed the correct side. Order base price now follows the traded side (base + spread on buy, base - spread on sell), which also makes price_original the real cost basis so the dashboard stops counting the provider spread as profit. Conditional order triggers compare against the same displayed price. Co-authored-by: Cursor <cursoragent@cursor.com>
968 lines
33 KiB
PHP
968 lines
33 KiB
PHP
<?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 function Config($name)
|
|
{
|
|
return $this->get($name, false);
|
|
}
|
|
|
|
/**
|
|
* مقدار setting؛ اگر نبود از config/price_providers.php یا default پارامتر.
|
|
*/
|
|
public function get(string $key, $default = null)
|
|
{
|
|
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;
|
|
}
|
|
|
|
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
|
|
{
|
|
return strtolower(trim($this->getString('referral_id'))) === 'required';
|
|
}
|
|
|
|
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، service_charge_percentage، config/fee.php */
|
|
public function orderServiceChargePercentage(): float
|
|
{
|
|
foreach (['fee_service_charge_percentage', 'service_charge_percentage'] as $key) {
|
|
$raw = $this->getString($key, '');
|
|
if ($raw !== '') {
|
|
return $this->normalizeServiceChargeRatio((float) $raw);
|
|
}
|
|
}
|
|
|
|
return $this->normalizeServiceChargeRatio((float) config('fee.service_charge_percentage', 0));
|
|
}
|
|
|
|
/**
|
|
* نسبت کارمزد (۰٫۰۱ = ۱٪). اگر ادمین «۱» بهجای «۰٫۰۱» گذاشته باشد نرمال میشود.
|
|
*/
|
|
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) پیشفرض فایل: montzar
|
|
*/
|
|
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';
|
|
}
|
|
|
|
$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;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** @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)
|
|
{
|
|
return Setting::updateOrCreate(
|
|
['key' => $name],
|
|
['value' => $value]
|
|
);
|
|
}
|
|
|
|
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');
|
|
|
|
if ($extremeDate !== $today) {
|
|
$this->UpdateConfig('up_price', $priceInt);
|
|
$this->UpdateConfig('down_price', $priceInt);
|
|
$this->UpdateConfig('price_extreme_date', $today);
|
|
return;
|
|
}
|
|
|
|
$currentUp = (float) ($upSetting->value ?: 0);
|
|
$currentDown = (float) ($downSetting->value ?: 0);
|
|
|
|
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];
|
|
}
|
|
|
|
$storedDown = $this->getInt('down_price', 0);
|
|
if ($down > 0 && $down < $storedDown) {
|
|
$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'),
|
|
];
|
|
}
|
|
|
|
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->tradingHourBounds();
|
|
[$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);
|
|
|
|
return !$now->between($start, $end);
|
|
}
|
|
|
|
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);
|
|
|
|
$this->UpdateConfig('montzar_closed', $staleLive > 0 ? '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
|
|
{
|
|
$bounds = $this->tradingHourBounds();
|
|
$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);
|
|
}
|
|
|
|
/** سوییچ دیپلوی قلک 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;
|
|
}
|
|
}
|