diff --git a/app/Console/Commands/PriceTickDaemonCommand.php b/app/Console/Commands/PriceTickDaemonCommand.php new file mode 100644 index 0000000..12fa8cf --- /dev/null +++ b/app/Console/Commands/PriceTickDaemonCommand.php @@ -0,0 +1,36 @@ +option('interval')); + + $this->info("Price tick daemon started (every {$interval}s). Ctrl+C to stop."); + + while (true) { + try { + $tickService->tick(); + } catch (\Throwable $e) { + Log::error('prices:tick-daemon failed', [ + 'message' => $e->getMessage(), + ]); + } + + sleep($interval); + } + } +} diff --git a/app/Console/Commands/PriceTickLoopCommand.php b/app/Console/Commands/PriceTickLoopCommand.php new file mode 100644 index 0000000..d37fff5 --- /dev/null +++ b/app/Console/Commands/PriceTickLoopCommand.php @@ -0,0 +1,32 @@ +option('iterations')); + $interval = max(1, (int) $this->option('interval')); + + for ($i = 0; $i < $iterations; $i++) { + $tickService->tick(); + + if ($i < $iterations - 1) { + sleep($interval); + } + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 46f9cd2..bd3037a 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -13,15 +13,12 @@ class Kernel extends ConsoleKernel protected function schedule(Schedule $schedule) { - // تیک قیمت — هر ۱۰ ثانیه (۶ بار در دقیقه) - $schedule->call(function () { - for ($i = 0; $i < 6; $i++) { - \Illuminate\Support\Facades\Artisan::call('prices:tick'); - if ($i < 5) { - sleep(10); - } - } - })->everyMinute()->name('prices_tick_10s')->withoutOverlapping(); + // تیک قیمت — ۶ بار در دقیقه با فاصله ۱۰ ثانیه (montzar + zaarbox + محصولات) + // نیاز: * * * * * php artisan schedule:run + $schedule->command('prices:tick-loop --iterations=6 --interval=10') + ->everyMinute() + ->name('prices_tick_10s') + ->withoutOverlapping(58); $schedule->command('prices:refresh-zaarbox') ->everyThirtyMinutes() diff --git a/app/Http/Controllers/Site/ReportController.php b/app/Http/Controllers/Site/ReportController.php index af5c0e6..4d289f3 100644 --- a/app/Http/Controllers/Site/ReportController.php +++ b/app/Http/Controllers/Site/ReportController.php @@ -122,13 +122,13 @@ class ReportController extends Controller ]; } - protected function appendUpPrices(array $payload): array + protected function appendUpPrices(array $payload, bool $saveFullCache = false): array { $payload['up'] = $this->buildUpDownPrices(); $payload['last_historical'] = app(ProductDisplayPriceService::class)->resolveHistoricalBasePrice(); app(ProductDisplayPriceService::class)->cacheLivePricePayload($payload); - if (request()->boolean('force_refresh')) { + if ($saveFullCache || request()->boolean('force_refresh')) { return app(LivePriceCacheService::class)->put($payload); } @@ -139,8 +139,9 @@ class ReportController extends Controller { $config = app(ConfigService::class); $cache = app(LivePriceCacheService::class); + $forceRefresh = $request->boolean('force_refresh'); - if (! $request->boolean('force_refresh')) { + if (! $forceRefresh) { $cached = $cache->get(); if ($cached !== null) { return $cached; @@ -158,7 +159,7 @@ class ReportController extends Controller 'time' => now(), 'main_product' => 0, 'zaarbox' => 0, - ], $this->storeLastPrice($config))); + ], $this->storeLastPrice($config)), $forceRefresh); } $closed = false; @@ -167,9 +168,6 @@ class ReportController extends Controller $zaarbox_price = 0; $pr_price = 0; - $open_buy = true; - $open_sell = true; - $montzar_open_buy = false; $montzar_open_sell = false; @@ -180,7 +178,7 @@ class ReportController extends Controller return filter_var($value, FILTER_VALIDATE_BOOLEAN); }; - $zeroResponse = function () { + $zeroResponse = function () use ($forceRefresh) { return $this->appendUpPrices([ 'sell' => 0, 'buy' => 0, @@ -191,7 +189,7 @@ class ReportController extends Controller 'zaarbox' => 0, 'closed' => true, 'time' => now(), - ]); + ], $forceRefresh); }; $priceProvider = $config->priceProvider(); @@ -231,20 +229,13 @@ class ReportController extends Controller |-------------------------------------------------------------------------- */ $settings = Setting::whereIn('key', [ - 'fee_single', 'main_product_price', - 'buy_fee', - 'sell_fee', ])->get(); $settingsMap = $settings->pluck('value', 'key'); $show_last_price = $config->getBool('show_last_price', false); $main_product_price = $settingsMap->get('main_product_price'); - $difference = (float) $settingsMap->get('fee_single', 0); - $diff_buy = (float) $settingsMap->get('buy_fee', $difference); - $diff_sell = (float) $settingsMap->get('sell_fee', $difference); - /* |-------------------------------------------------------------------------- | Montzar — فقط وقتی price_provider=montzar @@ -402,7 +393,7 @@ class ReportController extends Controller 'zaarbox' => $zaarbox_price ?? 0, 'closed' => true, 'time' => now(), - ]); + ], $forceRefresh); } } @@ -458,43 +449,17 @@ class ReportController extends Controller | محاسبه sell / buy |-------------------------------------------------------------------------- */ - if ($priceSource === 'montzar') { - $montzar_profit = (float) $config->Config('montzar_diff'); - - $final_buy_diff = $diff_buy + $montzar_profit; - $final_sell_diff = $diff_sell + $montzar_profit; - - $sell = $montzar_open_sell - ? ceil($priceValue - $final_sell_diff) - : 0; - - $buy = $montzar_open_buy - ? ceil($priceValue + $final_buy_diff) - : 0; - - $priceLast = ceil($priceValue + $final_buy_diff); - } else { - $sell = $open_sell - ? ceil($priceValue - $difference) - : 0; - - $buy = $open_buy - ? ceil($priceValue + $difference) - : 0; - - $priceLast = ceil($priceValue + $difference); - } - - /* - | main_product / zaarbox: buy/sell از همان قیمت زنده (نه رکورد قدیمی DB). - */ - if (in_array($priceProvider, ['main_product', 'zaarbox'], true) && !$closed) { - $base = (float) $priceValue; - - $sell = $open_sell ? (int) ceil($base - $diff_sell) : 0; - $buy = $open_buy ? (int) ceil($base + $diff_buy) : 0; - $priceLast = (int) ceil($base + $diff_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); @@ -510,7 +475,7 @@ class ReportController extends Controller 'zaarbox' => $zaarbox_price ?? 0, 'closed' => false, 'time' => now(), - ]); + ], $forceRefresh); } /** diff --git a/app/Services/ConfigService.php b/app/Services/ConfigService.php index d7c4090..9c02725 100644 --- a/app/Services/ConfigService.php +++ b/app/Services/ConfigService.php @@ -102,6 +102,54 @@ class ConfigService return $sellFee > 0 ? $sellFee : $feeSingle; } + /** + * 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->displayBuySpreadRial(); + $diffSell = (float) $this->displaySellSpreadRial(); + $feeSingle = (float) $this->get('fee_single', 0); + + $openBuy = true; + $openSell = true; + + if ($priceSource === 'montzar') { + $montzarProfit = (float) $this->get('montzar_diff', 0); + $finalBuyDiff = $diffBuy + $montzarProfit; + $finalSellDiff = $diffSell + $montzarProfit; + $openBuy = $montzarFlags['montzar_open_buy'] ?? $this->getBool('montzar_open_buy', true); + $openSell = $montzarFlags['montzar_open_sell'] ?? $this->getBool('montzar_open_sell', true); + + $sell = $openSell ? (int) ceil($priceValue - $finalSellDiff) : 0; + $buy = $openBuy ? (int) ceil($priceValue + $finalBuyDiff) : 0; + $priceLast = (int) ceil($priceValue + $finalBuyDiff); + + return compact('sell', 'buy', 'priceLast'); + } + + $sell = $openSell ? (int) ceil($priceValue - $feeSingle) : 0; + $buy = $openBuy ? (int) ceil($priceValue + $feeSingle) : 0; + $priceLast = (int) ceil($priceValue + $feeSingle); + + $provider = $this->priceProvider(); + if (in_array($provider, ['main_product', 'zaarbox'], true)) { + $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 * diff --git a/app/Services/LivePriceCacheService.php b/app/Services/LivePriceCacheService.php index 650c1d4..018ec4b 100644 --- a/app/Services/LivePriceCacheService.php +++ b/app/Services/LivePriceCacheService.php @@ -37,19 +37,15 @@ class LivePriceCacheService return $body; } - /** پاسخ سریع بدون API خارجی — از آخرین رکورد prices + تنظیمات */ + /** پاسخ سریع بدون API خارجی — از آخرین رکورد prices + تنظیمات (همان فرمول lastPrice) */ public function buildFromDatabase(): array { $config = app(ConfigService::class); $display = app(ProductDisplayPriceService::class); $last = Price::orderByDesc('id')->first(); - $base = $last ? (int) $last->price : 0; + $base = $last ? (float) $last->price : 0.0; $provider = $config->priceProvider(); - $montzarPrice = 0; - $zaarboxPrice = 0; - $mainProductPrice = 0; - if ($base <= 0) { $payload = [ 'sell' => 0, @@ -63,35 +59,32 @@ class LivePriceCacheService 'time' => now(), ]; } else { - $diffBuy = (int) $config->displayBuySpreadRial(); - $diffSell = (int) $config->displaySellSpreadRial(); - $openBuy = true; - $openSell = true; + $montzarSource = 0; + $zaarboxSource = 0; + $mainProductSource = 0; + $priceSource = 'database_last'; if ($provider === 'montzar') { - $montzarPrice = $config->getInt('montzar_live_price', 0) ?: $base; - $montzarDiff = (int) $config->getInt('montzar_diff', 0); - $diffBuy += $montzarDiff; - $diffSell += $montzarDiff; - $openBuy = $config->getBool('montzar_open_buy', true); - $openSell = $config->getBool('montzar_open_sell', true); + $liveMontzar = (float) $config->get('montzar_live_price', 0); + $montzarSource = (int) ($liveMontzar > 0 ? $liveMontzar : $base); + // DB از آخرین tick montzar پر شده — همان فرمول نمایش معامله + $priceSource = 'montzar'; } elseif ($provider === 'zaarbox') { - $zaarboxPrice = $base; + $zaarboxSource = (int) $base; } elseif ($provider === 'main_product') { - $mainProductPrice = $base; + $mainProductSource = (int) $base; } - $buy = $openBuy ? (int) ceil($base + $diffBuy) : 0; - $sell = $openSell ? max(0, (int) ceil($base - $diffSell)) : 0; + $trade = $config->buildTradeDisplayPrices($base, $priceSource); $payload = [ - 'sell' => $sell, - 'buy' => $buy, - 'price_last' => $buy, - 'price' => $base, - 'main_product' => $mainProductPrice, - 'montzar' => $montzarPrice, - 'zaarbox' => $zaarboxPrice, + 'sell' => $trade['sell'], + 'buy' => $trade['buy'], + 'price_last' => $trade['priceLast'], + 'price' => (int) $base, + 'main_product' => $mainProductSource, + 'montzar' => $montzarSource, + 'zaarbox' => $zaarboxSource, 'closed' => false, 'time' => now(), ]; @@ -105,8 +98,6 @@ class LivePriceCacheService : ['sell' => $payload['sell'] ?? 0, 'buy' => $payload['buy'] ?? 0]; $payload['last_historical'] = $display->resolveHistoricalBasePrice(); - $display->cacheLivePricePayload($payload); - return $payload; } } diff --git a/app/Services/LivePriceTickService.php b/app/Services/LivePriceTickService.php index d680cb4..88db7e2 100644 --- a/app/Services/LivePriceTickService.php +++ b/app/Services/LivePriceTickService.php @@ -5,44 +5,79 @@ namespace App\Services; use App\Http\Controllers\Site\ReportController; use App\Models\Product; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; class LivePriceTickService { + private const LOCK_SECONDS = 25; + public function tick(): void { - $config = app(ConfigService::class); - $sync = app(ProductPriceSyncService::class); - - if ($config->isStore()) { - $sync->syncStorePrices(true); - $source = 'zaarbox'; - $sourceUrl = app(ZaarboxPriceService::class)->apiUrl(); - } else { - $sync->syncAll(true); - $source = $config->priceProvider() ?: 'legacy'; - $sourceUrl = $source === 'zaarbox' - ? app(ZaarboxPriceService::class)->apiUrl() - : null; + $lock = Cache::lock('prices:tick', self::LOCK_SECONDS); + if (! $lock->get()) { + return; } - $liquidGold = (int) $config->getInt('montzar_live_price', 0); - if ($liquidGold <= 0) { - $liquidGold = app(ProductDisplayPriceService::class)->resolveHistoricalBasePrice(); - } + try { + $config = app(ConfigService::class); + $sync = app(ProductPriceSyncService::class); + $zaarbox = app(ZaarboxPriceService::class); - $activeProducts = Product::query() - ->where('active', 1) - ->whereNotNull('reading_method') - ->count(); + /* + | ۱. montzar / zaarbox زنده + کش ۱۵ثانیه‌ای /api/price/last + | (قبل از sync محصولات تا آبشدهٔ «قیمت اپ» تازه باشد) + */ + $request = Request::create('/api/price/last', 'GET', ['force_refresh' => '1']); + $response = app(ReportController::class)->lastPrice($request); + $payload = json_decode($response->getContent(), true); + if (! is_array($payload)) { + $payload = []; + } - app(PriceSyncLogService::class)->recordTick($source, $sourceUrl, $liquidGold, $activeProducts); + /* + | ۲. فید zaarbox برای محصولات متغیر (reading_method) + */ + if ($zaarbox->isEnabled() || Product::query()->whereNotNull('reading_method')->exists()) { + try { + $zaarbox->cachedPriceList(true); + } catch (\Throwable $e) { + Log::warning('Zaarbox feed refresh in tick failed', ['message' => $e->getMessage()]); + } + } - $request = Request::create('/api/price/last', 'GET', ['force_refresh' => '1']); - $response = app(ReportController::class)->lastPrice($request); - $payload = json_decode($response->getContent(), true); + /* + | ۳. همگام‌سازی قیمت محصولات + */ + if ($config->isStore()) { + $sync->syncStorePrices(true); + $source = 'zaarbox'; + $sourceUrl = $zaarbox->apiUrl(); + } else { + $sync->syncAll(true); + $source = $config->priceProvider() ?: 'legacy'; + $sourceUrl = $source === 'zaarbox' + ? $zaarbox->apiUrl() + : null; + } - if (is_array($payload)) { - app(PriceSnapshotService::class)->recordTick($payload); + $liquidGold = (int) ($payload['price'] ?? $config->getInt('montzar_live_price', 0)); + if ($liquidGold <= 0) { + $liquidGold = app(ProductDisplayPriceService::class)->resolveHistoricalBasePrice(); + } + + $activeProducts = Product::query() + ->where('active', 1) + ->whereNotNull('reading_method') + ->count(); + + app(PriceSyncLogService::class)->recordTick($source, $sourceUrl, $liquidGold, $activeProducts); + + if ($payload !== []) { + app(PriceSnapshotService::class)->recordTick($payload); + } + } finally { + $lock->release(); } } } diff --git a/scripts/crontab-prices.example.sh b/scripts/crontab-prices.example.sh new file mode 100644 index 0000000..3bddda1 --- /dev/null +++ b/scripts/crontab-prices.example.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# نصب کران قیمت — یکی از دو روش را انتخاب کنید. +# مسیر را با مسیر واقعی سرور عوض کنید. +APP_DIR="/opt/rahimigold/server" + +# ─── روش ۱ (پیشنهادی): schedule:run هر دقیقه ─── +# prices:tick-loop داخل Kernel هر ۱۰ ثانیه ۶ بار اجرا می‌شود. +# crontab -e: +# * * * * * cd $APP_DIR && php artisan schedule:run >> /dev/null 2>&1 + +# ─── روش ۲: مستقیم هر ۱۰ ثانیه (بدون schedule:run) ─── +# * * * * * cd $APP_DIR && php artisan prices:tick >> /dev/null 2>&1 +# * * * * * sleep 10 && cd $APP_DIR && php artisan prices:tick >> /dev/null 2>&1 +# * * * * * sleep 20 && cd $APP_DIR && php artisan prices:tick >> /dev/null 2>&1 +# * * * * * sleep 30 && cd $APP_DIR && php artisan prices:tick >> /dev/null 2>&1 +# * * * * * sleep 40 && cd $APP_DIR && php artisan prices:tick >> /dev/null 2>&1 +# * * * * * sleep 50 && cd $APP_DIR && php artisan prices:tick >> /dev/null 2>&1 + +# ─── روش ۳: supervisor (حلقه دائمی) ─── +# php artisan prices:tick-daemon --interval=10 + +echo "See comments in this file for crontab lines."