Files
boursecu_server/app/Http/Controllers/Site/ReportController.php
T
mohmdsdgandCursor b21b136711 fix(settings): honor last-price switch and overlay only SuperAdmin access flags
Stored DB prices were treated as live quotes, and SuperAdmin overlay overwrote tenant keys like trading hours. Keep overlay to the 14 access flags and hide fallback prices when the panel says so.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 03:01:07 +03:30

708 lines
27 KiB
PHP

<?php
namespace App\Http\Controllers\Site;
use App\Http\Controllers\Admin\PriceController;
use App\Http\Controllers\Controller;
use App\Models\Cnt;
use App\Models\ConfigStatic;
use App\Services\MontzarService;
use App\Services\ZaarboxPriceService;
use App\Models\Price;
use App\Models\PriceCandle;
use App\Models\Product;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Morilog\Jalali\Jalalian;
use App\Services\ConfigService;
use App\Services\LivePriceCacheService;
use App\Services\PriceSnapshotService;
use App\Services\PriceSyncLogService;
use App\Services\ProductDisplayPriceService;
class ReportController extends Controller
{
public function candleChart()
{
$candle = PriceCandle::select(["start",
"high",
"low",
"end",
"date_at"])->whereDate('date_at', '<=', date("Y-m-d"))
->whereDate('date_at', '>', Carbon::now()->subDays(30)->format("Y-m-d"))
->get()->toArray();
$a = [];
foreach ($candle as $c) {
$create['date'] = $c["date_at"];
unset($c["date_at"]);
$create['candle'] = array_values($c);
$a[] = array_values($create);
}
return response()->json($a);
}
public function updatePrise()
{
app(\App\Services\PriceTriggerService::class)->evaluateActive();
try {
app(\App\Services\Automation\AutomationEngine::class)->evaluatePriceMove();
} catch (\Throwable $e) {
report($e);
}
return response()->json();
}
public function upDownPrice()
{
return $this->buildUpDownPrices();
}
protected function buildUpDownPrices(): array
{
$config = app(ConfigService::class);
$diffBuy = $config->displayBuyPriceDiffRial();
$diffSell = $config->displaySellPriceDiffRial();
$extremes = $config->resolvedDailyBaseExtremes();
$up = $extremes['up'];
$down = $extremes['down'];
if ($up > 0 && $down > 0) {
return [
'sell' => max(0, (int) ceil($down - $diffSell)),
'buy' => (int) ceil($up + $diffBuy),
];
}
if (!$config->getBool('show_last_price', false)) {
return ['sell' => 0, 'buy' => 0];
}
$price = Price::orderByDesc('id')->first();
$priceValue = $price ? (float) $price->price : 0.0;
if ($priceValue <= 0) {
return ['sell' => 0, 'buy' => 0];
}
return [
'sell' => max(0, (int) ceil($priceValue - $diffSell)),
'buy' => (int) ceil($priceValue + $diffBuy),
];
}
protected function appendUpPrices(array $payload, bool $saveFullCache = false): array
{
// status: open | closed (خارج از ساعت کاری) | no_quote (مظنه در دسترس نیست)
$payload = app(ConfigService::class)->applyHomePriceDisplayState($payload);
// سقف/کف روز هم وقتی قیمت نباید نمایش داده شود صفر می‌ماند
$payload['up'] = ($payload['status'] ?? 'open') === 'open'
? $this->buildUpDownPrices()
: ['sell' => 0, 'buy' => 0];
$payload['last_historical'] = app(ProductDisplayPriceService::class)->resolveHistoricalBasePrice();
$payload['gold_ounce'] = $this->resolveGoldOuncePayload();
app(ProductDisplayPriceService::class)->cacheLivePricePayload($payload);
$isFreshPrice = $saveFullCache || request()->boolean('force_refresh');
// ارسال قیمت به کانال فقط روی «لحظهٔ تغییر قیمت» = مسیر force_refresh (همان چیزی که سرورِ
// حساس به قیمت صدا می‌زند و مبنای برادکست SSE است) — نه هر pulling عادی که کش برمی‌گرداند.
// بعد از پاسخ اجرا می‌شود تا تاخیری برای فراخوان نباشد. در کنسول صدا نمی‌زنیم چون tick()
// خودش مستقیماً broadcastIfEligible را اجرا می‌کند و afterResponse در دیمن اجرا نمی‌شود.
// خودِ سرویس با price_channel_min_seconds و تشخیص تغییر قیمت گیت می‌کند.
if ($isFreshPrice && (int) ($payload['price'] ?? 0) > 0 && ! app()->runningInConsole()) {
dispatch(function () {
app(\App\Services\PriceChannelBroadcastService::class)->broadcastIfEligible();
})->afterResponse();
}
if ($isFreshPrice && (int) ($payload['price'] ?? 0) > 0) {
$pricePayload = $payload;
if (app()->runningInConsole()) {
app(\App\Services\ConditionalOrderService::class)->evaluateFromLastPrice($pricePayload);
app(\App\Services\PriceTriggerService::class)->evaluateActive($pricePayload);
try {
app(\App\Services\Automation\AutomationEngine::class)->evaluatePriceMove($pricePayload);
} catch (\Throwable $e) {
report($e);
}
} else {
dispatch(function () use ($pricePayload) {
app(\App\Services\ConditionalOrderService::class)->evaluateFromLastPrice($pricePayload);
app(\App\Services\PriceTriggerService::class)->evaluateActive($pricePayload);
try {
app(\App\Services\Automation\AutomationEngine::class)->evaluatePriceMove($pricePayload);
} catch (\Throwable $e) {
report($e);
}
})->afterResponse();
}
}
if ($isFreshPrice) {
$cache = app(LivePriceCacheService::class);
$cache->markTick();
return $cache->put($payload);
}
return $payload;
}
protected function resolveGoldOuncePayload(): ?array
{
return app(ZaarboxPriceService::class)->clientGoldOuncePayload();
}
public function lastPrice(Request $request)
{
$config = app(ConfigService::class);
$cache = app(LivePriceCacheService::class);
$forceRefresh = $request->boolean('force_refresh');
if (! $forceRefresh) {
$cached = $cache->get();
if ($cached !== null) {
$cached['gold_ounce'] = $this->resolveGoldOuncePayload();
$cached = $config->applyGlobalTradingHoursToPayload($cached);
return $this->personalizeLastPriceForCustomer($cached);
}
// فقط وقتی کران اخیراً tick زده — وگرنه API زنده (جلوگیری از قفل قیمت)
if ($cache->hasRecentTick()) {
$dbBuilt = $cache->buildFromDatabase();
if (($dbBuilt['price'] ?? 0) > 0) {
return $this->personalizeLastPriceForCustomer($this->appendUpPrices($dbBuilt, false));
}
}
}
if ($config->isStore()) {
$storePayload = array_merge([
'closed' => false,
'time' => now(),
'main_product' => 0,
'zaarbox' => 0,
], $this->storeLastPrice($config));
if ($forceRefresh) {
$this->recordHalfHourPriceSnapshot(
(int) ($storePayload['buy'] ?? 0),
(string) ($storePayload['price_source'] ?? 'store'),
null,
(int) ($storePayload['price'] ?? $storePayload['buy'] ?? 0)
);
}
return $this->personalizeLastPriceForCustomer($this->appendUpPrices($storePayload, $forceRefresh));
}
$closed = false;
$montzarLivePrice = 0;
$zaarbox_price = 0;
$pr_price = 0;
$montzar_open_buy = false;
$montzar_open_sell = false;
$priceSource = null;
$priceValue = 0;
$toBool = function ($value) {
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
};
$zeroResponse = function () use ($forceRefresh) {
return $this->personalizeLastPriceForCustomer($this->appendUpPrices([
'sell' => 0,
'buy' => 0,
'price_last' => 0,
'price' => 0,
'main_product' => 0,
'montzar' => 0,
'zaarbox' => 0,
'closed' => true,
'time' => now(),
], $forceRefresh));
};
$priceProvider = $config->priceProvider();
/*
|--------------------------------------------------------------------------
| بستن قیمت طبق منطق قبلی
|--------------------------------------------------------------------------
*/
if ($toBool($config->Config('close_price'))) {
if ($config->isOutsideTradingHours()) {
$config->syncAppClosedFlag();
// نمایش قیمت صفحه اصلی حتی وقتی فروشگاه بسته است — معامله همچنان از سمت اپ بسته می‌ماند
if (! $config->shouldShowHomePriceWhenClosed()) {
return $zeroResponse();
}
} else {
Cnt::where('key', 'app_closed')->update(['value' => 'false']);
}
}
/*
|--------------------------------------------------------------------------
| Settings
|--------------------------------------------------------------------------
*/
$show_last_price = $config->getBool('show_last_price', false);
// main_product_price کلید سوپرادمین است — از ConfigService خوانده شود، نه جدول لوکال
$main_product_price = $config->getBool('main_product_price', false);
/*
|--------------------------------------------------------------------------
| Montzar — فقط وقتی price_provider=montzar
|--------------------------------------------------------------------------
*/
if ($priceProvider === 'montzar' && !$closed) {
try {
$montzarBaseUrl = rtrim($config->Config('montzar_base_url') ?: 'https://montpar.ir', '/');
$montzarProductId = (int) ($config->Config('montzar_product_id') ?: 25);
$montzar = app(MontzarService::class)->productList();
if (isset($montzar['message'])) {
throw new \Exception('Provider message: ' . $montzar['message']);
}
$collection = collect($montzar);
$montzarProduct = $collection->firstWhere('id', $montzarProductId);
if (!$montzarProduct) {
throw new \Exception("Provider product not found. product_id={$montzarProductId}");
}
if (!isset($montzarProduct['sub_agent'])) {
throw new \Exception("Provider product has no sub_agent. product_id={$montzarProductId}");
}
$buy = (float) ($montzarProduct['sub_agent']['rate_buy_gram_price'] ?? 0);
$sell = (float) ($montzarProduct['sub_agent']['rate_sell_gram_price'] ?? 0);
if ($buy <= 0 || $sell <= 0) {
throw new \Exception("Provider invalid gram price. buy={$buy}, sell={$sell}");
}
$customPrice = [];
$customPrice['price'] = ($buy + $sell) / 2;
$customPrice['diff'] = ($buy - $sell) / 2;
$config->UpdateConfig('montzar_live_price', (float) $customPrice['price']);
$config->UpdateConfig('montzar_diff', (float) $customPrice['diff']);
$config->UpdateConfig('montzar_mazaneh', ((float) $customPrice['price'] * 4.331802));
$config->UpdateConfig(
'montzar_truly_buy_mazaneh',
$montzarProduct['sub_agent']['rate_buy_price'] ?? 0
);
$config->UpdateConfig(
'montzar_truly_sell_mazaneh',
$montzarProduct['sub_agent']['rate_sell_price'] ?? 0
);
$config->UpdateConfig(
'montzar_last_product_log',
json_encode($montzarProduct, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
);
$montzarFlags = $config->montzarOpenFlags();
$montzar_open_buy = $montzarFlags['open_buy'];
$montzar_open_sell = $montzarFlags['open_sell'];
$montzarLivePrice = (int) $customPrice['price'];
$config->UpdateConfig('montzar_closed', 'false');
} catch (\Throwable $e) {
$montzarLivePrice = 0;
\Log::error('MONZAR PRICE ERROR', [
'message' => $e->getMessage(),
'montzar_product_id' => $montzarProductId ?? null,
'montzar_response' => $montzar ?? null,
]);
$config->applyMontzarSyncFailure();
if (!$config->getBool('montzar_closed', true)) {
$stale = $config->getInt('montzar_live_price', 0);
$montzarLivePrice = $config->isPlausibleQuotePrice((float) $stale) ? $stale : 0;
}
}
}
/*
|--------------------------------------------------------------------------
| Zaarbox — price_provider=zaarbox + settings.zaarbox_price_key
|--------------------------------------------------------------------------
*/
if ($priceProvider === 'zaarbox' && !$closed) {
$zaarbox = app(ZaarboxPriceService::class)->fetchPrice();
$zaarbox_price = (int) ($zaarbox['order_price'] ?? 0);
}
/*
|--------------------------------------------------------------------------
| Main Product — settings.main_product سپس const
|--------------------------------------------------------------------------
*/
$pr_product = null;
if ($priceProvider === 'main_product' || $main_product_price) {
$pr_product = $config->mainProduct();
}
if ($pr_product && !$closed) {
if ($priceProvider === 'main_product') {
if (Product::hasStoredPriceColumns()) {
app(ZaarboxPriceService::class)->syncProductFromApi($pr_product->fresh());
$pr_product->refresh();
$pr_price = (float) $pr_product->price + (float) ($pr_product->price_profit ?? 0);
} else {
$live = app(ZaarboxPriceService::class)->fetchLiveOrderPrice(
$pr_product->reading_method
);
$pr_price = (float) ($live['order_price'] ?? 0);
}
} else {
$pr_price = Product::hasStoredPriceColumns()
? (float) $pr_product->price + (float) ($pr_product->price_profit ?? 0)
: 0.0;
}
}
/*
|--------------------------------------------------------------------------
| انتخاب قیمت اصلی (همان price_provider)
|--------------------------------------------------------------------------
*/
if ($priceProvider === 'zaarbox' && $zaarbox_price > 0 && !$closed) {
$priceValue = $zaarbox_price;
$priceSource = 'zaarbox';
} elseif (
$priceProvider === 'montzar'
&& ($montzarLivePrice ?? 0) !== 0
&& !$closed
&& $config->isPlausibleQuotePrice((float) $montzarLivePrice)
) {
$priceValue = (int) $montzarLivePrice;
$priceSource = 'montzar';
} elseif ($priceProvider === 'main_product' && ($pr_price ?? 0) !== 0 && !$closed) {
$priceValue = $pr_price;
$priceSource = 'main_product';
} else {
$priceValue = 0;
$priceSource = null;
}
if ($priceValue <= 0) {
$lastPrice = Price::latest()->first();
$storedPrice = $lastPrice ? (float) $lastPrice->price : 0.0;
// fallback فقط با show_last_price تصمیم‌گیری می‌شود؛ force_refresh (تیک کران) نباید آن را override کند.
// آستانهٔ ثابت قبلی (۳۰ میلیون) برای طلا بود و محصولات ارزان‌تر مثل کاتد مس/نقره را اشتباهاً «بدون مظنه» می‌کرد.
if ($storedPrice > 0 && $show_last_price) {
$priceValue = $storedPrice;
$priceSource = 'database_last';
} else {
// مظنه زنده نداریم و show_last_price خاموش است → نه «بسته»، بلکه «مظنه در دسترس نیست»
return $this->personalizeLastPriceForCustomer($this->appendUpPrices([
'sell' => 0,
'buy' => 0,
'price_last' => 0,
'price' => 0,
'main_product' => $pr_price ?? 0,
'montzar' => $montzarLivePrice ?? 0,
'zaarbox' => $zaarbox_price ?? 0,
'closed' => false,
'status' => 'no_quote',
'time' => now(),
], $forceRefresh));
}
}
/*
|--------------------------------------------------------------------------
| محاسبه sell / buy
|--------------------------------------------------------------------------
*/
$display = $config->buildTradeDisplayPrices(
(float) $priceValue,
$priceSource,
[
'montzar_open_buy' => $montzar_open_buy,
'montzar_open_sell' => $montzar_open_sell,
]
);
$sell = $display['sell'];
$buy = $display['buy'];
$priceLast = $display['priceLast'];
if (!$closed && $priceValue > 0) {
$config->trackDailyPriceExtreme((float) $priceValue);
}
if ($forceRefresh) {
$this->recordHalfHourPriceSnapshot(
(int) $buy,
(string) ($priceSource ?: ($priceProvider ?: 'legacy')),
$this->resolvePriceSourceUrl($priceSource, $config),
(int) $priceValue
);
}
return $this->personalizeLastPriceForCustomer($this->appendUpPrices([
'sell' => $sell,
'buy' => $buy,
'price_last' => $priceLast,
'price' => $priceValue,
'price_source' => $priceSource,
'main_product' => $pr_price ?? 0,
'montzar' => $montzarLivePrice ?? 0,
'zaarbox' => $zaarbox_price ?? 0,
'closed' => false,
'time' => now(),
], $forceRefresh));
}
/** اختلاف پکیج ویژه — فقط روی پاسخ مشتری؛ کش مشترک را آلوده نمی‌کند */
protected function personalizeLastPriceForCustomer(array $payload): array
{
// تنظیمات ساعت کاری همیشه تازه از DB — اگر داخل کش قیمت بماند،
// پولینگ بعدی مقدار قبلی را برمی‌گرداند و تغییر پنل عقب برمی‌گردد.
$payload = app(ConfigService::class)->attachTradingHoursSettingsPatch($payload);
$customerId = (int) (auth('customer')->id() ?? 0);
if ($customerId <= 0) {
return $payload;
}
return app(\App\Services\CustomerSpecialPricingService::class)
->applyToLastPayload($payload, $customerId);
}
protected function recordHalfHourPriceSnapshot(
int $buyPrice,
string $source,
?string $sourceUrl,
int $referencePrice
): void {
if ($buyPrice <= 0 && $referencePrice <= 0) {
return;
}
// فقط fallback دیتابیس — مظنهٔ زنده نیست؛ لاگ/رکورد جدید نساز (همان قیمت دیشب تکرار می‌شود).
if ($source === 'database_last') {
return;
}
$reference = $referencePrice > 0 ? $referencePrice : $buyPrice;
app(PriceSyncLogService::class)->recordSnapshotIfDue(
$source,
$sourceUrl,
$reference,
PriceSnapshotService::HALF_HOUR_MINUTES
);
app(PriceSnapshotService::class)->recordHalfHourBuyPrice($buyPrice);
}
protected function resolvePriceSourceUrl(?string $priceSource, ConfigService $config): ?string
{
if ($priceSource === 'zaarbox') {
return app(ZaarboxPriceService::class)->apiUrl();
}
if ($priceSource === 'montzar') {
return rtrim($config->Config('montzar_base_url') ?: 'https://montpar.ir', '/');
}
return null;
}
/**
* فروشگاه — منطق lastPrice بر اساس price_provider
*/
protected function storeLastPrice(ConfigService $config): array
{
$talaIr = 0;
$montzar = 0;
$montzarEnabled = $config->priceProvider() === 'montzar';
if ($montzarEnabled) {
try {
$montzarProductId = (int) ($config->Config('montzar_product_id') ?: 25);
$montzarProducts = app(MontzarService::class)->productList();
if (isset($montzarProducts['message'])) {
throw new \Exception('Montzar message: ' . $montzarProducts['message']);
}
$montzarProduct = collect($montzarProducts)->firstWhere('id', $montzarProductId);
if (! $montzarProduct || ! isset($montzarProduct['sub_agent'])) {
throw new \Exception("Montzar product not found. product_id={$montzarProductId}");
}
$buy = (float) ($montzarProduct['sub_agent']['rate_buy_gram_price'] ?? 0);
$sell = (float) ($montzarProduct['sub_agent']['rate_sell_gram_price'] ?? 0);
if ($buy <= 0 || $sell <= 0) {
throw new \Exception("Montzar invalid gram price. buy={$buy}, sell={$sell}");
}
$customPrice = [
'price' => ($buy + $sell) / 2,
'diff' => ($buy - $sell) / 2,
];
$config->UpdateConfig('montzar_live_price', (float) $customPrice['price']);
$config->UpdateConfig('montzar_diff', (float) $customPrice['diff']);
$config->UpdateConfig('montzar_mazaneh', ((float) $customPrice['price'] * 4.331802));
$config->UpdateConfig('montzar_truly_buy_mazaneh', $montzarProduct['sub_agent']['rate_buy_price'] ?? 0);
$config->UpdateConfig('montzar_truly_sell_mazaneh', $montzarProduct['sub_agent']['rate_sell_price'] ?? 0);
$config->UpdateConfig(
'montzar_last_product_log',
json_encode($montzarProduct, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
);
$montzarFlags = $config->montzarOpenFlags();
$openBuy = $montzarFlags['open_buy'];
$openSell = $montzarFlags['open_sell'];
$montzar = (int) $customPrice['price'];
$config->UpdateConfig('montzar_closed', 'false');
} catch (\Throwable $e) {
$montzar = 0;
$config->applyMontzarSyncFailure();
if (!$config->getBool('montzar_closed', true)) {
$stale = $config->getInt('montzar_live_price', 0);
$montzar = $config->isPlausibleQuotePrice((float) $stale) ? $stale : 0;
}
\Log::error('STORE MONZAR PRICE ERROR', ['message' => $e->getMessage()]);
}
}
if ($montzarEnabled && $montzar !== 0 && $config->isPlausibleQuotePrice((float) $montzar)) {
$priceValue = $montzar;
$priceSource = 'montzar';
} else {
$priceValue = $talaIr;
$priceSource = 'tala_ir';
}
$lastStored = Price::latest()->first();
if ($priceValue <= 0 && $lastStored && (float) $lastStored->price > 0) {
$priceValue = (float) $lastStored->price;
$priceSource = 'database_last';
}
if ($priceValue <= 0) {
return [];
}
$display = $config->buildTradeDisplayPrices(
(float) $priceValue,
$priceSource
);
$response = [
'sell' => $display['sell'],
'buy' => $display['buy'],
'price_last' => $display['priceLast'],
'price' => $priceValue,
'price_source' => $priceSource,
'tala_ir' => $talaIr,
];
if ($montzarEnabled) {
$response['montzar'] = $montzar;
}
return $response;
}
public function check()
{
$service = Http::
withHeaders([
'accept' => 'application/json',
'Authorization' => 'Bearer ohxsrbjagqvbt0glaygt'
])->get('https://studio.persianapi.com/index.php/web-service/common/gold-currency-coin?format=json&limit=30&page=1')
->json();
$list = [];
if (isset($service['result'])) {
foreach ($service['result'] as $item) {
if ($item['key'] == 137120 || $item['key'] == 137119) {
$list[] = $item;
if ($item['key'] == 137120) {
(new PriceController())->store(new Request(["price" => $item['price']]));
}
}
}
Cnt::where('key', 'price')->update(['value_json' => $list]);
}
}
public function test()
{
$a = '{{12, 23, {4, 0, {1}, {1}}}, 0, {1}}';
$b = str_split($a);
$c = [];
$str = "";
foreach ($b as $item) {
if ($item != " ") {
print_r($item);
print_r('++');
if ($item == ',' || $item == '}') {
if (!in_array($c[count($c) - 1], ['{', '}'])) {
$c[count($c) - 1] += (int)$str;
print_r((int)$str);
print_r('^^');
} else {
$c[] = $str;
print_r($c);
print_r('&&');
}
$str = "";
} elseif (in_array($item, ['{', '}'])) {
$c[] = $item;
print_r($c);
print_r('**');
} else {
$str .= $item;
print_r($str);
print_r('@@');
}
}
}
dd($c);
$d = [];
$e = [];
foreach ($c as $item) {
if ($item == '}') {
print_r($d[count($d) - 1]);
print_r('***');
$e[] = $d[count($d) - 1];
} else {
$d[] = $item;
print_r($d);
print_r('-----');
}
}
dd($e);
}
}