diff --git a/app/Http/Controllers/Site/ReportController.php b/app/Http/Controllers/Site/ReportController.php index 7ad6c03..837193d 100644 --- a/app/Http/Controllers/Site/ReportController.php +++ b/app/Http/Controllers/Site/ReportController.php @@ -22,6 +22,7 @@ use Illuminate\Support\Facades\Mail; use Morilog\Jalali\Jalalian; use App\Services\ConfigService; use App\Services\LivePriceCacheService; +use App\Services\PriceSnapshotService; use App\Services\ProductDisplayPriceService; @@ -160,12 +161,16 @@ class ReportController extends Controller } if ($config->isStore()) { - return $this->appendUpPrices(array_merge([ + $storePayload = array_merge([ 'closed' => false, 'time' => now(), 'main_product' => 0, 'zaarbox' => 0, - ], $this->storeLastPrice($config)), $forceRefresh); + ], $this->storeLastPrice($config)); + + app(PriceSnapshotService::class)->recordHalfHourBuyPrice((int) ($storePayload['buy'] ?? 0)); + + return $this->appendUpPrices($storePayload, $forceRefresh); } $closed = false; @@ -389,53 +394,6 @@ class ReportController extends Controller } } - /* - |-------------------------------------------------------------------------- - | ذخیره قیمت - |-------------------------------------------------------------------------- - */ - $priceToStore = $priceValue; - - $lastPrice = Price::latest()->first(); - - if ( - ! $closed && - (! $lastPrice || (float) $lastPrice->price != (float) $priceToStore) && - $priceToStore > 30_000_000 - ) { - $price = new Price(); - $price->price = $priceToStore; - $price->time_at = Carbon::now()->toTimeString(); - $price->date_at = Carbon::now()->toDateString(); - $price->save(); - - $this->updatePrise(); - - $candle = PriceCandle::whereDate('date_at', date("Y-m-d"))->first(); - - if (!is_null($candle)) { - $candle->end = $priceToStore; - - if ($priceToStore < $candle->low) { - $candle->low = $priceToStore; - } elseif ($priceToStore > $candle->high) { - $candle->high = $priceToStore; - } - - $candle->save(); - } else { - PriceCandle::create([ - 'start' => $priceToStore, - 'end' => $priceToStore, - 'high' => $priceToStore, - 'low' => $priceToStore, - 'date_at' => date('Y-m-d'), - ]); - } - - $lastPrice = $price; - } - /* |-------------------------------------------------------------------------- | محاسبه sell / buy @@ -457,6 +415,8 @@ class ReportController extends Controller $config->trackDailyPriceExtreme((float) $priceValue); } + app(PriceSnapshotService::class)->recordHalfHourBuyPrice((int) $buy); + return $this->appendUpPrices([ 'sell' => $sell, 'buy' => $buy, @@ -595,53 +555,23 @@ class ReportController extends Controller } $lastStored = Price::latest()->first(); - if ( - (! $lastStored || (float) $lastStored->price != (float) $priceValue) - && $priceValue > 30000000 - ) { - $price = new Price(); - $price->price = $priceValue; - $price->time_at = Carbon::now()->toTimeString(); - $price->date_at = Carbon::now()->toDateString(); - $price->save(); - - $this->updatePrise(); - - $candle = PriceCandle::whereDate('date_at', date('Y-m-d'))->first(); - if ($candle !== null) { - $candle->end = $priceValue; - if ($priceValue < $candle->low) { - $candle->low = $priceValue; - } elseif ($priceValue > $candle->high) { - $candle->high = $priceValue; - } - $candle->save(); - } else { - PriceCandle::create([ - 'start' => $priceValue, - 'end' => $priceValue, - 'high' => $priceValue, - 'low' => $priceValue, - 'date_at' => date('Y-m-d'), - ]); - } + if ($priceValue <= 0 && $lastStored && (float) $lastStored->price > 0) { + $priceValue = (float) $lastStored->price; + $priceSource = 'database_last'; } - $price = Price::orderByDesc('id')->first(); - if (! $price) { + if ($priceValue <= 0) { return []; } - if ($priceSource === 'montzar') { - $difference += (float) $config->Config('montzar_diff'); - } elseif ($priceSource === 'nasiryan') { - $difference += (float) (Cnt::where('key', 'nasiryan_diff')->first()['value'] ?? 0); - } - + $display = $config->buildTradeDisplayPrices( + (float) $priceValue, + $priceSource + ); $response = [ - 'sell' => (int) ceil($price->price - $difference), - 'buy' => (int) ceil($price->price + $difference), - 'price_last' => (int) ceil($price->price + $difference), + 'sell' => $display['sell'], + 'buy' => $display['buy'], + 'price_last' => $display['priceLast'], 'price' => $priceValue, 'tala_ir' => $talaIr, 'nasiryan' => $nasiryan, diff --git a/app/Services/LivePriceTickService.php b/app/Services/LivePriceTickService.php index 54d7edf..f4d54df 100644 --- a/app/Services/LivePriceTickService.php +++ b/app/Services/LivePriceTickService.php @@ -78,10 +78,6 @@ class LivePriceTickService $syncLog = app(PriceSyncLogService::class)->recordTick($source, $sourceUrl, $liquidGold, $activeProducts); - if ($payload !== []) { - app(PriceSnapshotService::class)->recordTick($payload); - } - app(LivePriceCacheService::class)->markTick(); // last از put داخل lastPrice؛ list بعد از sync محصولات diff --git a/app/Services/PriceSnapshotService.php b/app/Services/PriceSnapshotService.php index 9208e71..9851393 100644 --- a/app/Services/PriceSnapshotService.php +++ b/app/Services/PriceSnapshotService.php @@ -8,265 +8,278 @@ use Carbon\Carbon; class PriceSnapshotService { - /** حداقل قیمت معتبر آبشده (ریال) — برای تاریخچه ذخیره‌شده در DB */ - public const MIN_VALID_CHART_PRICE_RIAL = 30_000_000; + public const HALF_HOUR_MINUTES = 30; - /** حداقل پیش‌فرض نمودار Zaarbox — مس و کالاهای ارزان‌تر از آبشده */ - public const MIN_VALID_ZAARBOX_CHART_PRICE_RIAL = 1_000_000; + /** حداقل قیمت معتبر آبشده (ریال) — برای تاریخچه ذخیره‌شده در DB */ + public const MIN_VALID_CHART_PRICE_RIAL = 30_000_000; - /** - * آستانه حداقل قیمت نمودار تابلو — قابل تنظیم با chart_min_valid_price. - * پیش‌فرض Zaarbox پایین‌تر از آبشده است تا مس (~۲۲M) فیلتر نشود. - */ - public function minValidChartPriceRialForExternalFeed(): int - { - $configured = (int) app(ConfigService::class)->getString('chart_min_valid_price', '0'); - if ($configured > 0) { - return $configured; - } + /** حداقل پیش‌فرض نمودار Zaarbox — مس و کالاهای ارزان‌تر از آبشده */ + public const MIN_VALID_ZAARBOX_CHART_PRICE_RIAL = 1_000_000; - if (app(ConfigService::class)->usesZaarboxPricing()) { - return self::MIN_VALID_ZAARBOX_CHART_PRICE_RIAL; - } - - return self::MIN_VALID_CHART_PRICE_RIAL; + /** + * آستانه حداقل قیمت نمودار تابلو — قابل تنظیم با chart_min_valid_price. + * پیش‌فرض Zaarbox پایین‌تر از آبشده است تا مس (~۲۲M) فیلتر نشود. + */ + public function minValidChartPriceRialForExternalFeed(): int + { + $configured = (int) app(ConfigService::class)->getString('chart_min_valid_price', '0'); + if ($configured > 0) { + return $configured; } - /** هر تیک کران — ذخیره در prices برای نمودار (هرگز صفر ثبت نمی‌شود) */ - public function recordTick(array $payload): void - { - $priceValue = (int) ($payload['price'] ?? 0); - if ($priceValue <= self::MIN_VALID_CHART_PRICE_RIAL) { - $last = Price::orderByDesc('id')->first(); - if (! $last || (int) $last->price <= self::MIN_VALID_CHART_PRICE_RIAL) { - return; - } - $priceValue = (int) $last->price; - } - - $lastRecorded = Price::query()->orderByDesc('id')->first(); - if ($lastRecorded && (int) $lastRecorded->price === $priceValue) { - return; - } - - Price::create([ - 'price' => $priceValue, - 'date_at' => Carbon::now()->toDateString(), - 'time_at' => Carbon::now()->toTimeString(), - 'user_id' => null, - ]); - - $this->touchCandle($priceValue); + if (app(ConfigService::class)->usesZaarboxPricing()) { + return self::MIN_VALID_ZAARBOX_CHART_PRICE_RIAL; } - private function touchCandle(int $priceValue): void - { - $candle = PriceCandle::whereDate('date_at', date('Y-m-d'))->first(); + return self::MIN_VALID_CHART_PRICE_RIAL; + } - if ($candle) { - $candle->end = $priceValue; - if ($priceValue < $candle->low) { - $candle->low = $priceValue; - } elseif ($priceValue > $candle->high) { - $candle->high = $priceValue; - } - $candle->save(); - - return; - } - - PriceCandle::create([ - 'start' => $priceValue, - 'end' => $priceValue, - 'high' => $priceValue, - 'low' => $priceValue, - 'date_at' => date('Y-m-d'), - ]); + /** + * ذخیره نمونه قیمت خرید (buy) برای نمودار — حداکثر هر ۳۰ دقیقه یک‌بار. + * + * 1) در ۳۰ دقیقه گذشته رکوردی نباشد + * 2) با آخرین رکورد جدول prices یکی نباشد + * 3) قیمت صفر نباشد + */ + public function recordHalfHourBuyPrice(int $buyPrice): bool + { + if ($buyPrice <= 0) { + return false; } - /** - * پاک‌سازی شبانه: - * - قبل از ۳۱ روز: حذف - * - ۷ تا ۳۱ روز: هر ۱۲ ساعت یک رکورد - * - ۲۴ ساعت تا ۷ روز: هر ۳۰ دقیقه یک رکورد - * - ۲۴ ساعت اخیر: نگه‌داری همه (هر ۱۰ ثانیه) - */ - public function pruneHistory(): array - { - $deletedOld = Price::query() - ->where('created_at', '<', now()->subDays(31)) - ->delete(); + $savedRecently = Price::query() + ->where('created_at', '>=', now()->subMinutes(self::HALF_HOUR_MINUTES)) + ->exists(); - $deleted7to31 = $this->downsampleRange(now()->subDays(31), now()->subDays(7), 12 * 3600); - $deleted1to7 = $this->downsampleRange(now()->subDays(7), now()->subDay(), 30 * 60); + if ($savedRecently) { + return false; + } - return [ - 'deleted_older_than_31d' => $deletedOld, - 'downsampled_7_to_31d' => $deleted7to31, - 'downsampled_1_to_7d' => $deleted1to7, + $lastPrice = Price::query()->orderByDesc('id')->first(); + if ($lastPrice && (int) $lastPrice->price === $buyPrice) { + return false; + } + + Price::create([ + 'price' => $buyPrice, + 'date_at' => Carbon::now()->toDateString(), + 'time_at' => Carbon::now()->toTimeString(), + 'user_id' => null, + ]); + + $this->touchCandle($buyPrice); + + return true; + } + + private function touchCandle(int $priceValue): void + { + $candle = PriceCandle::whereDate('date_at', date('Y-m-d'))->first(); + + if ($candle) { + $candle->end = $priceValue; + if ($priceValue < $candle->low) { + $candle->low = $priceValue; + } elseif ($priceValue > $candle->high) { + $candle->high = $priceValue; + } + $candle->save(); + + return; + } + + PriceCandle::create([ + 'start' => $priceValue, + 'end' => $priceValue, + 'high' => $priceValue, + 'low' => $priceValue, + 'date_at' => date('Y-m-d'), + ]); + } + + /** + * پاک‌سازی شبانه: + * - قبل از ۳۱ روز: حذف + * - ۷ تا ۳۱ روز: هر ۱۲ ساعت یک رکورد + * - ۲۴ ساعت تا ۷ روز: هر ۳۰ دقیقه یک رکورد + * - ۲۴ ساعت اخیر: نگه‌داری همه (هر ۱۰ ثانیه) + */ + public function pruneHistory(): array + { + $deletedOld = Price::query() + ->where('created_at', '<', now()->subDays(31)) + ->delete(); + + $deleted7to31 = $this->downsampleRange(now()->subDays(31), now()->subDays(7), 12 * 3600); + $deleted1to7 = $this->downsampleRange(now()->subDays(7), now()->subDay(), 30 * 60); + + return [ + 'deleted_older_than_31d' => $deletedOld, + 'downsampled_7_to_31d' => $deleted7to31, + 'downsampled_1_to_7d' => $deleted1to7, + ]; + } + + private function downsampleRange(Carbon $from, Carbon $to, int $bucketSeconds): int + { + $rows = Price::query() + ->where('created_at', '>=', $from) + ->where('created_at', '<', $to) + ->orderBy('created_at') + ->get(['id', 'created_at']); + + if ($rows->isEmpty()) { + return 0; + } + + $keepIds = []; + $lastBucket = null; + + foreach ($rows as $row) { + $ts = $row->created_at?->timestamp ?? 0; + $bucket = (int) floor($ts / $bucketSeconds); + if ($bucket !== $lastBucket) { + $keepIds[] = $row->id; + $lastBucket = $bucket; + } else { + $keepIds[count($keepIds) - 1] = $row->id; + } + } + + return Price::query() + ->where('created_at', '>=', $from) + ->where('created_at', '<', $to) + ->whereNotIn('id', $keepIds) + ->delete(); + } + + /** @return array{DAILY: array, WEEKLY: array, MONTHLY: array} */ + public function chartFromDatabase(): array + { + $daily = Price::query() + ->where('created_at', '>=', now()->subDay()) + ->orderBy('created_at') + ->get(['price', 'created_at']); + + $dailyPoints = $this->mapValidPriceRows($daily); + + $weekRows = Price::query() + ->where('created_at', '>=', now()->subDays(7)) + ->orderBy('created_at') + ->get(['id', 'price', 'created_at']); + + $weeklyPoints = $this->bucketRows($weekRows, 30 * 60); + + $monthRows = Price::query() + ->where('created_at', '>=', now()->subDays(31)) + ->orderBy('created_at') + ->get(['id', 'price', 'created_at']); + + $monthlyPoints = $this->bucketRows($monthRows, 12 * 3600); + + return [ + 'DAILY' => $dailyPoints, + 'WEEKLY' => $weeklyPoints, + 'MONTHLY' => $monthlyPoints, + ]; + } + + /** @param \Illuminate\Support\Collection $rows */ + private function bucketRows($rows, int $bucketSeconds): array + { + $points = []; + $lastBucket = null; + + foreach ($rows as $row) { + $price = (int) $row->price; + if ($price <= self::MIN_VALID_CHART_PRICE_RIAL) { + continue; + } + + $ts = $row->created_at?->timestamp ?? 0; + $bucket = (int) floor($ts / $bucketSeconds); + $point = [ + 'timestamp' => $ts * 1000, + 'value' => $price, + ]; + + if ($bucket !== $lastBucket) { + $points[] = $point; + $lastBucket = $bucket; + } else { + $points[count($points) - 1] = $point; + } + } + + return $points; + } + + /** @param \Illuminate\Support\Collection $rows */ + private function mapValidPriceRows($rows): array + { + $points = []; + + foreach ($rows as $row) { + $price = (int) $row->price; + if ($price <= self::MIN_VALID_CHART_PRICE_RIAL) { + continue; + } + + $points[] = [ + 'timestamp' => ($row->created_at ?? now())->timestamp * 1000, + 'value' => $price, + ]; + } + + return $points; + } + + /** + * @param array{DAILY?: array, WEEKLY?: array, MONTHLY?: array} $prices + * @return array{DAILY: array, WEEKLY: array, MONTHLY: array} + */ + public function sanitizeChartPrices(array $prices, ?int $minRial = null): array + { + $minRial ??= self::MIN_VALID_CHART_PRICE_RIAL; + $minScaled = intdiv($minRial, 1000); + + foreach (['DAILY', 'WEEKLY', 'MONTHLY'] as $period) { + if (! isset($prices[$period]) || ! is_array($prices[$period])) { + $prices[$period] = []; + continue; + } + + $lastValid = null; + $sanitized = []; + + foreach ($prices[$period] as $point) { + $value = (int) ($point['value'] ?? 0); + $timestamp = (int) ($point['timestamp'] ?? 0); + + if ($timestamp <= 0) { + continue; + } + + $isValid = $value > $minRial + || ($value > $minScaled && $value <= self::MIN_VALID_CHART_PRICE_RIAL); + + if ($isValid) { + $lastValid = $value; + } elseif ($lastValid !== null) { + $value = $lastValid; + } else { + continue; + } + + $sanitized[] = [ + 'timestamp' => $timestamp, + 'value' => $value, ]; + } + + $prices[$period] = $sanitized; } - private function downsampleRange(Carbon $from, Carbon $to, int $bucketSeconds): int - { - $rows = Price::query() - ->where('created_at', '>=', $from) - ->where('created_at', '<', $to) - ->orderBy('created_at') - ->get(['id', 'created_at']); - - if ($rows->isEmpty()) { - return 0; - } - - $keepIds = []; - $lastBucket = null; - - foreach ($rows as $row) { - $ts = $row->created_at?->timestamp ?? 0; - $bucket = (int) floor($ts / $bucketSeconds); - if ($bucket !== $lastBucket) { - $keepIds[] = $row->id; - $lastBucket = $bucket; - } else { - $keepIds[count($keepIds) - 1] = $row->id; - } - } - - return Price::query() - ->where('created_at', '>=', $from) - ->where('created_at', '<', $to) - ->whereNotIn('id', $keepIds) - ->delete(); - } - - /** @return array{DAILY: array, WEEKLY: array, MONTHLY: array} */ - public function chartFromDatabase(): array - { - $daily = Price::query() - ->where('created_at', '>=', now()->subDay()) - ->orderBy('created_at') - ->get(['price', 'created_at']); - - $dailyPoints = $this->mapValidPriceRows($daily); - - $weekRows = Price::query() - ->where('created_at', '>=', now()->subDays(7)) - ->orderBy('created_at') - ->get(['id', 'price', 'created_at']); - - $weeklyPoints = $this->bucketRows($weekRows, 30 * 60); - - $monthRows = Price::query() - ->where('created_at', '>=', now()->subDays(31)) - ->orderBy('created_at') - ->get(['id', 'price', 'created_at']); - - $monthlyPoints = $this->bucketRows($monthRows, 12 * 3600); - - return [ - 'DAILY' => $dailyPoints, - 'WEEKLY' => $weeklyPoints, - 'MONTHLY' => $monthlyPoints, - ]; - } - - /** @param \Illuminate\Support\Collection $rows */ - private function bucketRows($rows, int $bucketSeconds): array - { - $points = []; - $lastBucket = null; - - foreach ($rows as $row) { - $price = (int) $row->price; - if ($price <= self::MIN_VALID_CHART_PRICE_RIAL) { - continue; - } - - $ts = $row->created_at?->timestamp ?? 0; - $bucket = (int) floor($ts / $bucketSeconds); - $point = [ - 'timestamp' => $ts * 1000, - 'value' => $price, - ]; - - if ($bucket !== $lastBucket) { - $points[] = $point; - $lastBucket = $bucket; - } else { - $points[count($points) - 1] = $point; - } - } - - return $points; - } - - /** @param \Illuminate\Support\Collection $rows */ - private function mapValidPriceRows($rows): array - { - $points = []; - - foreach ($rows as $row) { - $price = (int) $row->price; - if ($price <= self::MIN_VALID_CHART_PRICE_RIAL) { - continue; - } - - $points[] = [ - 'timestamp' => ($row->created_at ?? now())->timestamp * 1000, - 'value' => $price, - ]; - } - - return $points; - } - - /** - * @param array{DAILY?: array, WEEKLY?: array, MONTHLY?: array} $prices - * @return array{DAILY: array, WEEKLY: array, MONTHLY: array} - */ - public function sanitizeChartPrices(array $prices, ?int $minRial = null): array - { - $minRial ??= self::MIN_VALID_CHART_PRICE_RIAL; - $minScaled = intdiv($minRial, 1000); - - foreach (['DAILY', 'WEEKLY', 'MONTHLY'] as $period) { - if (! isset($prices[$period]) || ! is_array($prices[$period])) { - $prices[$period] = []; - continue; - } - - $lastValid = null; - $sanitized = []; - - foreach ($prices[$period] as $point) { - $value = (int) ($point['value'] ?? 0); - $timestamp = (int) ($point['timestamp'] ?? 0); - - if ($timestamp <= 0) { - continue; - } - - $isValid = $value > $minRial - || ($value > $minScaled && $value <= self::MIN_VALID_CHART_PRICE_RIAL); - - if ($isValid) { - $lastValid = $value; - } elseif ($lastValid !== null) { - $value = $lastValid; - } else { - continue; - } - - $sanitized[] = [ - 'timestamp' => $timestamp, - 'value' => $value, - ]; - } - - $prices[$period] = $sanitized; - } - - return $prices; - } + return $prices; + } }