khanegi setting and prices records
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Price;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class LivePriceCacheService
|
||||
{
|
||||
public const FULL_RESPONSE_KEY = 'live_price_full_response';
|
||||
|
||||
public const TTL_SECONDS = 15;
|
||||
|
||||
public function get(): ?array
|
||||
{
|
||||
$cached = Cache::get(self::FULL_RESPONSE_KEY);
|
||||
if (! is_array($cached) || ! isset($cached['body'], $cached['stored_at'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (now()->diffInSeconds($cached['stored_at']) > self::TTL_SECONDS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $cached['body'];
|
||||
}
|
||||
|
||||
public function put(array $body): array
|
||||
{
|
||||
Cache::put(self::FULL_RESPONSE_KEY, [
|
||||
'body' => $body,
|
||||
'stored_at' => now(),
|
||||
], now()->addSeconds(self::TTL_SECONDS + 5));
|
||||
|
||||
app(ProductDisplayPriceService::class)->cacheLivePricePayload($body);
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/** پاسخ سریع بدون API خارجی — از آخرین رکورد prices + تنظیمات */
|
||||
public function buildFromDatabase(): array
|
||||
{
|
||||
$config = app(ConfigService::class);
|
||||
$display = app(ProductDisplayPriceService::class);
|
||||
$last = Price::orderByDesc('id')->first();
|
||||
$base = $last ? (int) $last->price : 0;
|
||||
|
||||
if ($base <= 0) {
|
||||
$payload = [
|
||||
'sell' => 0,
|
||||
'buy' => 0,
|
||||
'price_last' => 0,
|
||||
'price' => 0,
|
||||
'main_product' => 0,
|
||||
'montzar' => 0,
|
||||
'zaarbox' => 0,
|
||||
'closed' => true,
|
||||
'time' => now(),
|
||||
];
|
||||
} else {
|
||||
$diffBuy = (int) $config->displayBuySpreadRial();
|
||||
$diffSell = (int) $config->displaySellSpreadRial();
|
||||
$buy = (int) ceil($base + $diffBuy);
|
||||
$sell = max(0, (int) ceil($base - $diffSell));
|
||||
|
||||
$payload = [
|
||||
'sell' => $sell,
|
||||
'buy' => $buy,
|
||||
'price_last' => $buy,
|
||||
'price' => $base,
|
||||
'main_product' => 0,
|
||||
'montzar' => 0,
|
||||
'zaarbox' => 0,
|
||||
'closed' => false,
|
||||
'time' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
$payload['up'] = $config->resolvedDailyBaseExtremes()['up'] > 0
|
||||
? [
|
||||
'sell' => max(0, (int) ceil($config->resolvedDailyBaseExtremes()['down'] - $config->displaySellSpreadRial())),
|
||||
'buy' => (int) ceil($config->resolvedDailyBaseExtremes()['up'] + $config->displayBuySpreadRial()),
|
||||
]
|
||||
: ['sell' => $payload['sell'] ?? 0, 'buy' => $payload['buy'] ?? 0];
|
||||
$payload['last_historical'] = $display->resolveHistoricalBasePrice();
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Http\Controllers\Site\ReportController;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LivePriceTickService
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
$liquidGold = (int) $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);
|
||||
|
||||
$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)) {
|
||||
app(PriceSnapshotService::class)->recordTick($payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Price;
|
||||
use App\Models\PriceCandle;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class PriceSnapshotService
|
||||
{
|
||||
/** هر تیک کران — ذخیره در prices برای نمودار (هرگز صفر ثبت نمیشود) */
|
||||
public function recordTick(array $payload): void
|
||||
{
|
||||
$priceValue = (int) ($payload['price'] ?? 0);
|
||||
if ($priceValue <= 30_000_000) {
|
||||
$last = Price::orderByDesc('id')->first();
|
||||
if (! $last || (int) $last->price <= 30_000_000) {
|
||||
return;
|
||||
}
|
||||
$priceValue = (int) $last->price;
|
||||
}
|
||||
|
||||
Price::create([
|
||||
'price' => $priceValue,
|
||||
'date_at' => Carbon::now()->toDateString(),
|
||||
'time_at' => Carbon::now()->toTimeString(),
|
||||
'user_id' => null,
|
||||
]);
|
||||
|
||||
$this->touchCandle($priceValue);
|
||||
}
|
||||
|
||||
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<int, array{timestamp: int, value: int}>, WEEKLY: array, MONTHLY: array} */
|
||||
public function chartFromDatabase(): array
|
||||
{
|
||||
$daily = Price::query()
|
||||
->where('created_at', '>=', now()->subDay())
|
||||
->orderBy('created_at')
|
||||
->get(['price', 'created_at']);
|
||||
|
||||
$dailyPoints = $daily->map(function (Price $row) {
|
||||
$ts = ($row->created_at ?? now())->timestamp * 1000;
|
||||
|
||||
return ['timestamp' => $ts, 'value' => (int) $row->price];
|
||||
})->values()->all();
|
||||
|
||||
$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<int, Price> $rows */
|
||||
private function bucketRows($rows, int $bucketSeconds): array
|
||||
{
|
||||
$points = [];
|
||||
$lastBucket = null;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$ts = $row->created_at?->timestamp ?? 0;
|
||||
$bucket = (int) floor($ts / $bucketSeconds);
|
||||
$point = [
|
||||
'timestamp' => $ts * 1000,
|
||||
'value' => (int) $row->price,
|
||||
];
|
||||
|
||||
if ($bucket !== $lastBucket) {
|
||||
$points[] = $point;
|
||||
$lastBucket = $bucket;
|
||||
} else {
|
||||
$points[count($points) - 1] = $point;
|
||||
}
|
||||
}
|
||||
|
||||
return $points;
|
||||
}
|
||||
}
|
||||
@@ -56,4 +56,20 @@ class PriceSyncLogService
|
||||
|
||||
return $log->load('items');
|
||||
}
|
||||
|
||||
/** هر تیک کران — حتی بدون تغییر قیمت محصول */
|
||||
public function recordTick(string $source, ?string $sourceUrl, int $liquidGoldPrice, int $productsSynced): ?PriceSyncLog
|
||||
{
|
||||
if (! $this->isAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PriceSyncLog::create([
|
||||
'source' => $source,
|
||||
'source_url' => $sourceUrl,
|
||||
'liquid_gold_price' => $liquidGoldPrice,
|
||||
'products_updated' => $productsSynced,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class ProductDisplayPriceService
|
||||
{
|
||||
public const LIVE_PRICE_CACHE_KEY = 'live_price_api_payload';
|
||||
|
||||
public const LIVE_PRICE_CACHE_SECONDS = 45;
|
||||
public const LIVE_PRICE_CACHE_SECONDS = 15;
|
||||
|
||||
public function usesAppPrice(Product $product): bool
|
||||
{
|
||||
@@ -262,6 +262,18 @@ class ProductDisplayPriceService
|
||||
/** @param array<int, array<string, mixed>> $feed */
|
||||
private function applyReferenceFeedPrice(Product $product, array $feed, ZaarboxPriceService $zaarbox): void
|
||||
{
|
||||
if (Product::hasStoredPriceColumns()) {
|
||||
$stored = (int) ($product->price ?? 0) + (int) ($product->price_profit ?? 0);
|
||||
if ($stored > 0) {
|
||||
$product->setAttribute('price', (int) ($product->price ?? 0));
|
||||
if (Schema::hasColumn($product->getTable(), 'price_profit')) {
|
||||
$product->setAttribute('price_profit', (int) ($product->price_profit ?? 0));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$row = $zaarbox->findFeedRow($feed, (string) $product->reading_method);
|
||||
if ($row === null) {
|
||||
$useLast = $this->productUsesLastPriceIfZero($product);
|
||||
|
||||
@@ -20,7 +20,7 @@ class ZaarboxPriceService
|
||||
private const CACHE_TTL_MINUTES = 35;
|
||||
|
||||
/** TTL کش فید — کران هر ۳۰ث refresh میکند؛ list فقط از همین میخواند */
|
||||
private const PRICE_LIST_CACHE_SECONDS = 45;
|
||||
private const PRICE_LIST_CACHE_SECONDS = 15;
|
||||
|
||||
private const DEFAULT_HISTORY_ID = 'ca3800a0-1f57-3528-b963-178f9168e04f';
|
||||
|
||||
@@ -90,6 +90,13 @@ class ZaarboxPriceService
|
||||
*/
|
||||
public function getAllPrices(): array
|
||||
{
|
||||
if ($this->config->getBool('chart_use_local_data', false)) {
|
||||
$local = app(PriceSnapshotService::class)->chartFromDatabase();
|
||||
if (! empty($local['DAILY'])) {
|
||||
return $local;
|
||||
}
|
||||
}
|
||||
|
||||
$cached = Cache::get($this->cacheKey());
|
||||
|
||||
if (is_array($cached) && isset($cached['prices'])) {
|
||||
|
||||
Reference in New Issue
Block a user