chart fixed

This commit is contained in:
2026-06-17 12:12:35 +03:30
parent 3e70b1ab43
commit c3b868c6b5
9 changed files with 293 additions and 22 deletions
+13 -3
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Http\Controllers\Site\ReportController;
use App\Models\PriceSyncLog;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -12,13 +13,15 @@ class LivePriceTickService
{
private const LOCK_SECONDS = 25;
public function tick(): void
public function tick(): ?PriceSyncLog
{
$lock = Cache::lock('prices:tick', self::LOCK_SECONDS);
if (! $lock->get()) {
return;
return null;
}
$syncLog = null;
try {
$config = app(ConfigService::class);
$sync = app(ProductPriceSyncService::class);
@@ -73,15 +76,22 @@ class LivePriceTickService
->whereNotNull('reading_method')
->count();
app(PriceSyncLogService::class)->recordTick($source, $sourceUrl, $liquidGold, $activeProducts);
$syncLog = app(PriceSyncLogService::class)->recordTick($source, $sourceUrl, $liquidGold, $activeProducts);
if ($payload !== []) {
app(PriceSnapshotService::class)->recordTick($payload);
}
app(LivePriceCacheService::class)->markTick();
$broadcastPayload = $payload;
dispatch(function () use ($broadcastPayload) {
app(PriceBroadcastService::class)->broadcastAfterPriceUpdate($broadcastPayload);
})->afterResponse();
} finally {
$lock->release();
}
return $syncLog;
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\Services;
use App\Http\Controllers\Admin\ProductController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class PriceBroadcastService
{
private const LAST_HASH_KEY = 'ws_broadcast:last_hash';
private const LIST_HASH_KEY = 'ws_broadcast:list_hash';
/**
* @param array<string, mixed>|null $lastPayload
*/
public function broadcastAfterPriceUpdate(?array $lastPayload = null): void
{
$ws = app(WebsocketBroadcastService::class);
if (! $ws->isEnabled()) {
return;
}
if ($lastPayload === null) {
$cache = app(LivePriceCacheService::class);
$lastPayload = $cache->get() ?? $cache->buildFromDatabase();
}
if (is_array($lastPayload) && $lastPayload !== []) {
$this->broadcastLastIfChanged($lastPayload);
}
$listPayload = $this->buildProductListPayload();
if ($listPayload !== null) {
$this->broadcastListIfChanged($listPayload);
}
}
/**
* @param array<string, mixed> $payload
*/
public function broadcastLastIfChanged(array $payload): void
{
$hash = md5(json_encode($payload, JSON_UNESCAPED_UNICODE));
if (Cache::get(self::LAST_HASH_KEY) === $hash) {
return;
}
Cache::put(self::LAST_HASH_KEY, $hash, now()->addHour());
app(WebsocketBroadcastService::class)->sendEvent('last', $payload);
}
/**
* @param array<int, mixed> $products
*/
public function broadcastListIfChanged(array $products): void
{
$hash = md5(json_encode($products, JSON_UNESCAPED_UNICODE));
if (Cache::get(self::LIST_HASH_KEY) === $hash) {
return;
}
Cache::put(self::LIST_HASH_KEY, $hash, now()->addHour());
app(WebsocketBroadcastService::class)->sendEvent('list', ['data' => $products]);
}
/**
* @return array<int, mixed>|null
*/
private function buildProductListPayload(): ?array
{
$request = Request::create('/customer/product/list', 'GET');
app()->instance('request', $request);
$response = app(ProductController::class)->listProduct();
$decoded = json_decode($response->getContent(), true);
return is_array($decoded) ? $decoded : null;
}
}
+31 -4
View File
@@ -8,9 +8,30 @@ use Carbon\Carbon;
class PriceSnapshotService
{
/** حداقل قیمت معتبر آبشده (ریال) — زیر این در نمودار نمایش داده نمی‌شود */
/** حداقل قیمت معتبر آبشده (ریال) — برای تاریخچه ذخیره‌شده در DB */
public const MIN_VALID_CHART_PRICE_RIAL = 30_000_000;
/** حداقل پیش‌فرض نمودار Zaarbox — مس و کالاهای ارزان‌تر از آبشده */
public const MIN_VALID_ZAARBOX_CHART_PRICE_RIAL = 1_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;
}
if (app(ConfigService::class)->usesZaarboxPricing()) {
return self::MIN_VALID_ZAARBOX_CHART_PRICE_RIAL;
}
return self::MIN_VALID_CHART_PRICE_RIAL;
}
/** هر تیک کران — ذخیره در prices برای نمودار (هرگز صفر ثبت نمی‌شود) */
public function recordTick(array $payload): void
{
@@ -23,6 +44,11 @@ class PriceSnapshotService
$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(),
@@ -199,9 +225,10 @@ class PriceSnapshotService
* @param array{DAILY?: array, WEEKLY?: array, MONTHLY?: array} $prices
* @return array{DAILY: array, WEEKLY: array, MONTHLY: array}
*/
public function sanitizeChartPrices(array $prices): array
public function sanitizeChartPrices(array $prices, ?int $minRial = null): array
{
$minScaled = intdiv(self::MIN_VALID_CHART_PRICE_RIAL, 1000);
$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])) {
@@ -220,7 +247,7 @@ class PriceSnapshotService
continue;
}
$isValid = $value > self::MIN_VALID_CHART_PRICE_RIAL
$isValid = $value > $minRial
|| ($value > $minScaled && $value <= self::MIN_VALID_CHART_PRICE_RIAL);
if ($isValid) {
+7 -2
View File
@@ -57,10 +57,15 @@ class PriceSyncLogService
return $log->load('items');
}
/** هر تیک کران — حتی بدون تغییر قیمت محصول */
/** فقط وقتی قیمت مرجع آبشده نسبت به آخرین رکورد عوض شده باشد */
public function recordTick(string $source, ?string $sourceUrl, int $liquidGoldPrice, int $productsSynced): ?PriceSyncLog
{
if (! $this->isAvailable()) {
if (! $this->isAvailable() || $liquidGoldPrice <= 0) {
return null;
}
$last = PriceSyncLog::query()->orderByDesc('id')->first();
if ($last && (int) $last->liquid_gold_price === $liquidGoldPrice) {
return null;
}
@@ -0,0 +1,97 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class WebsocketBroadcastService
{
private const DEFAULT_TRIGGER_PATH = '/prc/api/trigger-update';
public function triggerBaseUrl(): ?string
{
$fromEnv = trim((string) (env('WSS_LINK') ?: env('wssLink', '')));
if ($fromEnv !== '') {
return rtrim($fromEnv, '/');
}
$fromSetting = trim((string) app(ConfigService::class)->getString('wss_link', ''));
if ($fromSetting !== '') {
return rtrim($fromSetting, '/');
}
return null;
}
public function triggerPath(): string
{
$path = trim((string) app(ConfigService::class)->getString('wss_trigger_path', ''));
return $path !== '' ? $path : self::DEFAULT_TRIGGER_PATH;
}
public function isEnabled(): bool
{
if (! app(ConfigService::class)->getBool('wss_enabled', true)) {
return false;
}
return $this->triggerBaseUrl() !== null;
}
/**
* @param array<string, mixed> $payload
*/
public function sendEvent(string $trigger, array $payload = []): void
{
if (! $this->isEnabled()) {
return;
}
$baseUrl = $this->triggerBaseUrl();
if ($baseUrl === null) {
return;
}
$body = array_merge($payload, ['trigger' => $trigger]);
$host = $this->resolveHost();
if ($host !== '') {
$body['host'] = $host;
}
$url = $baseUrl.$this->triggerPath();
try {
Http::timeout(3)
->withHeaders(['Content-Type' => 'application/json'])
->withOptions([
'curl' => [
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
],
])
->post($url, $body);
} catch (\Throwable $e) {
Log::warning('Websocket broadcast failed', [
'trigger' => $trigger,
'url' => $url,
'message' => $e->getMessage(),
]);
}
}
private function resolveHost(): string
{
$config = app(ConfigService::class);
$nameEn = trim((string) $config->getString('name_en', ''));
if ($nameEn !== '') {
return $nameEn;
}
if (! app()->runningInConsole() && request()) {
return trim((string) request()->getHost());
}
return '';
}
}
+20 -8
View File
@@ -90,20 +90,23 @@ class ZaarboxPriceService
*/
public function getAllPrices(): array
{
$snapshot = app(PriceSnapshotService::class);
if ($this->config->getBool('chart_use_local_data', false)) {
$local = app(PriceSnapshotService::class)->chartFromDatabase();
$local = $snapshot->chartFromDatabase();
if (! empty($local['DAILY'])) {
return app(PriceSnapshotService::class)->sanitizeChartPrices($local);
return $snapshot->sanitizeChartPrices($local);
}
}
$minRial = $snapshot->minValidChartPriceRialForExternalFeed();
$cached = Cache::get($this->cacheKey());
if (is_array($cached) && isset($cached['prices'])) {
return app(PriceSnapshotService::class)->sanitizeChartPrices($cached['prices']);
return $snapshot->sanitizeChartPrices($cached['prices'], $minRial);
}
return app(PriceSnapshotService::class)->sanitizeChartPrices($this->refreshCache());
return $snapshot->sanitizeChartPrices($this->refreshCache(), $minRial);
}
/**
@@ -483,6 +486,8 @@ class ZaarboxPriceService
}
});
$minRial = app(PriceSnapshotService::class)->minValidChartPriceRialForExternalFeed();
foreach (self::PERIODS as $key => $config) {
$response = $responses[$key] ?? null;
$start = $now->copy()->subHours($config['hours']);
@@ -501,7 +506,8 @@ class ZaarboxPriceService
$history,
$this->toMillis($start),
$this->toMillis($now),
$config['intervalMs']
$config['intervalMs'],
$minRial
);
}
@@ -510,8 +516,14 @@ class ZaarboxPriceService
private function historyUrl(Carbon $start, Carbon $end): string
{
$base = rtrim($this->apiUrl(), '/');
if (str_ends_with($base, '/free')) {
$base = substr($base, 0, -strlen('/free'));
}
return sprintf(
'https://price.zaarbox.ir/api/history/%s?start=%s&end=%s&limit=1000000000',
'%s/history/%s?start=%s&end=%s&limit=1000000000',
$base,
$this->historyId(),
$this->toIso8601($start),
$this->toIso8601($end)
@@ -532,7 +544,7 @@ class ZaarboxPriceService
* @param array<int, array<string, mixed>> $history
* @return array<int, array{timestamp: int, value: int}>
*/
private function mapHistory(array $history, int $startMs, int $endMs, int $intervalMs): array
private function mapHistory(array $history, int $startMs, int $endMs, int $intervalMs, int $minRial): array
{
$points = [];
@@ -566,7 +578,7 @@ class ZaarboxPriceService
for ($bucket = $firstBucket; $bucket <= $endMs; $bucket += $intervalMs) {
while ($index < $count && $points[$index]['timestamp'] <= $bucket) {
if ($points[$index]['price'] > PriceSnapshotService::MIN_VALID_CHART_PRICE_RIAL) {
if ($points[$index]['price'] > $minRial) {
$lastPoint = $points[$index];
}
$index++;