khanegi setting and prices records

This commit is contained in:
2026-06-13 17:35:40 +03:30
parent c70cda0b6b
commit 91a82c06e4
15 changed files with 509 additions and 73 deletions
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Console\Commands;
use App\Services\LivePriceTickService;
use Illuminate\Console\Command;
class PriceTickCommand extends Command
{
protected $signature = 'prices:tick';
protected $description = 'Fetch live prices, sync products, cache last/list, log snapshot (every 10s)';
public function handle(LivePriceTickService $tickService): int
{
$tickService->tick();
return self::SUCCESS;
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Services\PriceSnapshotService;
use Illuminate\Console\Command;
class PrunePriceSnapshotsCommand extends Command
{
protected $signature = 'prices:prune';
protected $description = 'Downsample and prune price history (daily at midnight)';
public function handle(PriceSnapshotService $snapshots): int
{
$stats = $snapshots->pruneHistory();
$this->info(json_encode($stats, JSON_UNESCAPED_UNICODE));
return self::SUCCESS;
}
}
+4 -5
View File
@@ -2,19 +2,18 @@
namespace App\Console\Commands;
use App\Services\ProductPriceSyncService;
use App\Services\LivePriceTickService;
use Illuminate\Console\Command;
class SyncProductPrices extends Command
{
protected $signature = 'product:sync-prices';
protected $description = 'Fetch zaarbox price board + sync product DB (scheduler: every 30s)';
protected $description = 'Alias for prices:tick (backward compatible cron)';
public function handle(ProductPriceSyncService $syncService): int
public function handle(LivePriceTickService $tickService): int
{
// کران: GET https://price.zaarbox.ir/api/free (یا zaarbox_api_url) → کش + UPDATE محصولات
$syncService->syncAll(true);
$tickService->tick();
return self::SUCCESS;
}
+13 -19
View File
@@ -2,44 +2,38 @@
namespace App\Console;
use App\Console\Commands\Price;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
protected $commands = [
Price::class,
Commands\Price::class,
];
/**
* Define the application's command schedule.
*
* @param \Illuminate\Console\Scheduling\Schedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
{
// همگام‌سازی قیمت محصولات متغیر (reading_method) — هر ۳۰ ثانیه
// تیک قیمت — هر ۱۰ ثانیه (۶ بار در دقیقه)
$schedule->call(function () {
for ($i = 0; $i < 2; $i++) {
\Illuminate\Support\Facades\Artisan::call('product:sync-prices');
if ($i < 1) {
sleep(30);
for ($i = 0; $i < 6; $i++) {
\Illuminate\Support\Facades\Artisan::call('prices:tick');
if ($i < 5) {
sleep(10);
}
}
})->everyMinute()->name('sync_product_prices_30s')->withoutOverlapping();
})->everyMinute()->name('prices_tick_10s')->withoutOverlapping();
$schedule->command('prices:refresh-zaarbox')
->everyThirtyMinutes()
->name('refresh_zaarbox_prices')
->withoutOverlapping();
// پاک‌سازی و downsample تاریخچه — ساعت ۱۲ شب
$schedule->command('prices:prune')
->dailyAt('00:00')
->name('prune_price_snapshots');
}
/**
* Register the commands for the application.
*
* @return void
*/
protected function commands()
{
$this->load(__DIR__.'/Commands');
@@ -5,7 +5,7 @@ namespace App\Http\Controllers\Admin;
use App\DB\Te\TableEngine;
use App\Http\Controllers\Controller;
use App\Models\PriceSyncLog;
use App\Services\ProductPriceSyncService;
use App\Services\LivePriceTickService;
use Illuminate\Http\Request;
class PriceSyncLogController extends Controller
@@ -29,14 +29,14 @@ class PriceSyncLogController extends Controller
return response()->json($log);
}
public function runSync(ProductPriceSyncService $syncService)
public function runSync(LivePriceTickService $tickService)
{
$syncService->syncStorePrices(true);
$tickService->tick();
$log = PriceSyncLog::with('items')->orderByDesc('id')->first();
return response()->json([
'message' => $log ? 'قیمت‌ها به‌روزرسانی شد' : 'تغییری در قیمت محصولات نبود',
'message' => $log ? 'قیمت‌ها به‌روزرسانی شد' : 'همگام‌سازی انجام شد',
'data' => $log,
]);
}
@@ -175,15 +175,12 @@ class ProductController extends SuperController
return response()->json($products);
}
// آبشده خانگی — ادمین: sync از کش؛ کاربر: enrich بدون UPDATE
if (! str_contains(request()->path(), '/customer/')) {
app(ProductPriceSyncService::class)->syncAll(false);
}
// آبشده خانگی — کران قیمت را آپدیت می‌کند؛ list فقط از DB می‌خواند
$products = Product::with(['file'])->where('active', 1)->get();
if (str_contains(request()->path(), '/customer/')) {
$displayPriceService = app(ProductDisplayPriceService::class);
$displayPriceService->warmLivePriceCache();
$displayPriceService->enrichForApi($products);
$products->each(function (Product $product) use ($displayPriceService) {
if (! $displayPriceService->hasCustomerDisplayPrice($product)) {
+33 -23
View File
@@ -21,6 +21,7 @@ use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Morilog\Jalali\Jalalian;
use App\Services\ConfigService;
use App\Services\LivePriceCacheService;
use App\Services\ProductDisplayPriceService;
@@ -127,15 +128,37 @@ class ReportController extends Controller
$payload['last_historical'] = app(ProductDisplayPriceService::class)->resolveHistoricalBasePrice();
app(ProductDisplayPriceService::class)->cacheLivePricePayload($payload);
if (request()->boolean('force_refresh')) {
return app(LivePriceCacheService::class)->put($payload);
}
return $payload;
}
public function lastPrice()
public function lastPrice(Request $request)
{
$config = app(ConfigService::class);
$cache = app(LivePriceCacheService::class);
if (! $request->boolean('force_refresh')) {
$cached = $cache->get();
if ($cached !== null) {
return $cached;
}
$dbBuilt = $cache->buildFromDatabase();
if (($dbBuilt['price'] ?? 0) > 0) {
return $dbBuilt;
}
}
if ($config->isStore()) {
return $this->storeLastPrice($config);
return $this->appendUpPrices(array_merge([
'closed' => false,
'time' => now(),
'main_product' => 0,
'zaarbox' => 0,
], $this->storeLastPrice($config)));
}
$closed = false;
@@ -361,24 +384,14 @@ class ReportController extends Controller
}
if ($priceValue <= 0) {
if (!$show_last_price) {
return $this->appendUpPrices([
'sell' => 0,
'buy' => 0,
'price_last' => 0,
'price' => 0,
'main_product' => $pr_price ?? 0,
'montzar' => $montzar_price ?? 0,
'zaarbox' => $zaarbox_price ?? 0,
'closed' => true,
'time' => now(),
]);
}
$lastPrice = Price::latest()->first();
$storedPrice = $lastPrice ? (float) $lastPrice->price : 0.0;
$useFallback = $request->boolean('force_refresh') || $show_last_price;
if ($storedPrice <= 0) {
if ($storedPrice > 30_000_000 && $useFallback) {
$priceValue = $storedPrice;
$priceSource = 'database_last';
} else {
return $this->appendUpPrices([
'sell' => 0,
'buy' => 0,
@@ -391,9 +404,6 @@ class ReportController extends Controller
'time' => now(),
]);
}
$priceValue = $storedPrice;
$priceSource = 'database_last';
}
/*
@@ -406,9 +416,9 @@ class ReportController extends Controller
$lastPrice = Price::latest()->first();
if (
!$closed &&
(!$lastPrice || (float) $lastPrice->price != (float) $priceToStore) &&
$priceToStore > 1000000
! $closed &&
(! $lastPrice || (float) $lastPrice->price != (float) $priceToStore) &&
$priceToStore > 30_000_000
) {
$price = new Price();
$price->price = $priceToStore;
+89
View File
@@ -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;
}
}
+48
View File
@@ -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);
}
}
}
+173
View File
@@ -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;
}
}
+16
View File
@@ -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(),
]);
}
}
+13 -1
View File
@@ -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);
+8 -1
View File
@@ -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'])) {