Files
boursecu_server/app/Services/ProductDisplayPriceService.php
T
2026-07-16 14:33:57 +03:30

578 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Models\Price;
use App\Models\Product;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
/**
* قیمت نمایشی محصولات برای API.
*
* • آبشده با «قیمت اپ» → montzar_live_price / کش /api/price/last
* • محصول با reading_method → فید zaarbox (کش ۱۵ث + fetch روی cache miss)
*/
class ProductDisplayPriceService
{
public const LIVE_PRICE_CACHE_KEY = 'live_price_api_payload';
public const LIVE_PRICE_CACHE_SECONDS = 15;
public function usesAppPrice(Product $product): bool
{
if ($product->reading_method) {
return false;
}
// پرچم صریح «قیمت اپ» برای هر دسته‌ای (شمش، سکه، …) — قیمت هر گرم × وزن
if (Product::hasAppPriceColumn() && (bool) ($product->app_price ?? false)) {
return true;
}
if ((int) ($product->direct_price ?? 0) === 1) {
return false;
}
return $this->isMeltedTradeProduct($product)
|| $this->usesQuantityAppPrice($product);
}
/** محصولی که با پرچم صریح app_price قیمت اپ می‌گیرد (نه استنتاج آبشده/مقداری) */
public function usesFlaggedAppPrice(Product $product): bool
{
return Product::hasAppPriceColumn()
&& (bool) ($product->app_price ?? false)
&& ! $product->reading_method;
}
/** ضریب وزن برای محصول قیمت‌اپ: وزن واحد به گرم‌واحد محصول (amount) یا ۱ */
public function appPriceWeightFactor(Product $product): float
{
$amount = (float) ($product->amount ?? 0);
return $amount > 0 ? $amount : 1.0;
}
/** معامله مقداری با قیمت اپ — نه قیمت دستی قدیمی با price پر در DB */
private function usesQuantityAppPrice(Product $product): bool
{
if (! ($product->trade_by_quantity ?? false)) {
return false;
}
if ($product->reading_method || (int) ($product->direct_price ?? 0) === 1) {
return false;
}
return (int) ($product->price ?? 0) <= 0;
}
/**
* پر کردن price روی مدل‌ها برای پاسخ JSON — بدون UPDATE دیتابیس
*/
/** پر کردن کش مظنه و فید zaarbox اگر خالی باشند (قبل از enrich لیست مشتری) */
public function warmLivePriceCache(): void
{
if (! is_array(Cache::get(self::LIVE_PRICE_CACHE_KEY))) {
$this->resolveFallbackPayload();
}
// فروشگاه و خانگی هر دو ممکن است reading_method / قیمت اپ داشته باشند
app(ZaarboxPriceService::class)->cachedPriceList(false);
}
public function enrichForApi(Collection|iterable $products): void
{
$collection = collect($products);
$zaarbox = app(ZaarboxPriceService::class);
// مثل ادمین — روی cache miss یک بار fetch؛ نه فقط getCachedPriceListOnly
$zaarboxFeed = $zaarbox->cachedPriceList(false);
foreach ($collection as $product) {
if (!$product instanceof Product) {
continue;
}
if ($this->usesAppPrice($product)) {
$base = $this->productUsesLastPriceIfZero($product)
? $this->resolveAppBasePrice()
: $this->resolveStrictLiveAppBasePrice();
$this->applyAppPrice($product, $base);
continue;
}
if ($product->reading_method && is_array($zaarboxFeed)) {
$this->applyReferenceFeedPrice($product, $zaarboxFeed, $zaarbox);
continue;
}
if ($product->reading_method && ! $this->productUsesLastPriceIfZero($product)) {
$product->setAttribute('reference_provider_active', false);
$this->zeroProductPrice($product);
continue;
}
if ($product->reading_method) {
$product->setAttribute('reference_provider_active', true);
continue;
}
if ($this->isWeightBasedStoreProduct($product)) {
$this->enrichWeightBasedPrice($product);
}
}
}
/** شمش / سکه و … — قیمت واحد (هر گرم) از مظنه زنده یا آخرین قیمت */
private function isWeightBasedStoreProduct(Product $product): bool
{
if ($this->usesAppPrice($product) || $product->reading_method) {
return false;
}
if ((int) ($product->direct_price ?? 0) === 1) {
return false;
}
return (float) ($product->amount ?? 0) > 0;
}
private function enrichWeightBasedPrice(Product $product): void
{
$useLast = $this->productUsesLastPriceIfZero($product);
$unitBuy = $useLast
? $this->resolveLiveBuyPriceWithFallback()
: $this->resolveStrictLiveBuyPrice();
if ($unitBuy <= 0 && app(ConfigService::class)->getBool('show_last_price', false)) {
$unitBuy = $this->resolveHistoricalBasePrice();
}
$offset = ProductPriceService::offsetPercent($product);
if ($unitBuy > 0 && $offset != 0.0) {
$unitBuy = ProductPriceService::adjustedTotal($unitBuy, 0, $offset);
}
$this->applyPriceOrFallback($product, $unitBuy, $useLast);
}
private function resolveStrictLiveBuyPrice(): int
{
$cached = Cache::get(self::LIVE_PRICE_CACHE_KEY);
if (! is_array($cached)) {
return 0;
}
$buy = (int) ($cached['buy'] ?? 0);
if ($buy > 0) {
return $buy;
}
$priceLast = (int) ($cached['price_last'] ?? 0);
if ($priceLast > 0) {
return $priceLast;
}
return $this->extractBaseFromPayload($cached);
}
private function resolveLiveBuyPriceWithFallback(): int
{
$strict = $this->resolveStrictLiveBuyPrice();
if ($strict > 0) {
return $strict;
}
$payload = $this->resolveFallbackPayload();
return (int) ($payload['buy'] ?? 0) ?: (int) ($payload['price_last'] ?? 0);
}
private function applyAppPrice(Product $product, int $basePrice): void
{
$useLast = $this->productUsesLastPriceIfZero($product);
$offset = Product::hasPriceOffsetColumn()
? (float) ($product->price_offset_percent ?? 0)
: 0.0;
if ($basePrice <= 0) {
$this->applyPriceOrFallback($product, 0, $useLast);
return;
}
$computed = ProductPriceService::adjustedTotal($basePrice, 0, $offset);
$this->applyPriceOrFallback($product, $computed, $useLast);
}
private function applyPriceOrFallback(Product $product, int $computed, bool $useLast): void
{
if ($computed > 0) {
$product->setAttribute('price', $computed);
if (Schema::hasColumn($product->getTable(), 'price_profit')) {
$product->setAttribute('price_profit', 0);
}
return;
}
if ($useLast) {
$fallback = $this->resolveLastPriceFallback($product);
if ($fallback > 0) {
$product->setAttribute('price', $fallback);
if (Schema::hasColumn($product->getTable(), 'price_profit')) {
$product->setAttribute('price_profit', 0);
}
return;
}
}
$product->setAttribute('price', 0);
if (Schema::hasColumn($product->getTable(), 'price_profit')) {
$product->setAttribute('price_profit', 0);
}
}
private function productUsesLastPriceIfZero(Product $product): bool
{
return Product::hasUseLastPriceIfZeroColumn()
&& ($product->use_last_price_if_zero ?? false);
}
/**
* آخرین قیمت ذخیره‌شده: اول products.price، بعد جدول prices (تاریخچه مظنه).
*/
public function resolveLastPriceFallback(Product $product): int
{
$stored = (int) ($product->price ?? 0);
if ($stored > 0) {
return $stored;
}
$storedProfit = (int) ($product->price_profit ?? 0);
if ($storedProfit > 0) {
return $stored + $storedProfit;
}
$historical = $this->resolveHistoricalBasePrice();
if ($historical <= 0) {
return 0;
}
if ($this->usesAppPrice($product) || $product->reading_method) {
$offset = Product::hasPriceOffsetColumn()
? (float) ($product->price_offset_percent ?? 0)
: 0.0;
return ProductPriceService::adjustedTotal($historical, 0, $offset);
}
return $historical;
}
public function resolveHistoricalBasePrice(): int
{
$last = Price::orderByDesc('id')->first();
if (!$last || (float) $last->price <= 0) {
return 0;
}
return (int) $last->price;
}
private function zeroProductPrice(Product $product): void
{
$product->setAttribute('price', 0);
if (Schema::hasColumn($product->getTable(), 'price_profit')) {
$product->setAttribute('price_profit', 0);
}
}
/**
* فقط قیمت زنده (کش /api/price/last) — بدون montzar_live_price قدیمی settings.
* وقتی use_last_price_if_zero خاموش است.
*/
public function resolveStrictLiveAppBasePrice(): int
{
$cached = Cache::get(self::LIVE_PRICE_CACHE_KEY);
if (!is_array($cached)) {
return 0;
}
return $this->extractBaseFromPayload($cached);
}
/** @param array<int, array<string, mixed>> $feed */
private function applyReferenceFeedPrice(Product $product, array $feed, ZaarboxPriceService $zaarbox): void
{
$useLast = $this->productUsesLastPriceIfZero($product);
$row = $zaarbox->findFeedRow($feed, (string) $product->reading_method);
if ($row === null) {
$product->setAttribute('reference_provider_active', $useLast);
$offset = ProductPriceService::offsetPercent($product);
$historical = $useLast ? $this->resolveHistoricalBasePrice() : 0;
$computed = $historical > 0
? ProductPriceService::adjustedTotal($historical, 0, $offset)
: 0;
$this->applyPriceOrFallback($product, $computed, $useLast);
return;
}
$providerRaw = $zaarbox->resolveReferenceProviderTotal($row);
$providerActive = $useLast || $providerRaw > 0;
$product->setAttribute('reference_provider_active', $providerActive);
if (! $useLast && $providerRaw <= 0) {
$this->zeroProductPrice($product);
return;
}
$computed = $zaarbox->computeAdjustedProductPrice($product, $row);
if ($computed <= 0) {
if ($useLast) {
$fallback = $this->resolveLastPriceFallback($product);
if ($fallback > 0) {
$product->setAttribute('price', $fallback);
if (Schema::hasColumn($product->getTable(), 'price_profit')) {
$product->setAttribute('price_profit', 0);
}
} else {
$this->zeroProductPrice($product);
}
} else {
$this->zeroProductPrice($product);
}
return;
}
$product->setAttribute('price', $computed);
if (Schema::hasColumn($product->getTable(), 'price_profit')) {
$product->setAttribute('price_profit', 0);
}
}
/** @param array<string, mixed> $payload */
public function cacheLivePricePayload(array $payload): void
{
Cache::put(self::LIVE_PRICE_CACHE_KEY, [
'price' => (int) ($payload['price'] ?? 0),
'buy' => (int) ($payload['buy'] ?? 0),
'sell' => (int) ($payload['sell'] ?? 0),
'price_last' => (int) ($payload['price_last'] ?? 0),
], now()->addSeconds(self::LIVE_PRICE_CACHE_SECONDS));
}
public function resolveAppBasePrice(): int
{
return $this->extractBaseFromPayload($this->livePricePayload());
}
/** قیمت پایه سفارش — همان منبع نمایش اپ (کش / montzar_live_price / prices) */
public function resolveOrderBasePrice(?Product $product = null): int
{
$this->warmLivePriceCache();
if ($product instanceof Product) {
if ($this->usesAppPrice($product)) {
$base = $this->resolveAppBasePrice();
if ($base <= 0) {
$base = $this->resolveHistoricalBasePrice();
}
if ($base <= 0 && $this->productUsesLastPriceIfZero($product)) {
$base = $this->resolveLastPriceFallback($product);
}
if ($base <= 0) {
return 0;
}
$offset = Product::hasPriceOffsetColumn()
? (float) ($product->price_offset_percent ?? 0)
: 0.0;
return ProductPriceService::adjustedTotal($base, 0, $offset);
}
if ($product->reading_method) {
$this->enrichForApi(collect([$product]));
$unit = (int) (($product->price ?? 0) + ($product->price_profit ?? 0));
if ($unit > 0) {
return $unit;
}
}
if ((int) ($product->direct_price ?? 0) === 1) {
return (int) (($product->price ?? 0) + ($product->price_profit ?? 0));
}
}
$base = $this->resolveAppBasePrice();
if ($base > 0) {
return $base;
}
return $this->resolveHistoricalBasePrice();
}
/** @param array<string, mixed> $payload */
private function extractBaseFromPayload(array $payload): int
{
$price = (int) ($payload['price'] ?? 0);
if ($price > 0) {
return $price;
}
$buy = (int) ($payload['buy'] ?? 0);
$sell = (int) ($payload['sell'] ?? 0);
if ($buy > 0 && $sell > 0) {
return (int) round(($buy + $sell) / 2);
}
$priceLast = (int) ($payload['price_last'] ?? 0);
if ($priceLast > 0) {
return $priceLast;
}
return 0;
}
/** @return array{price: int, buy: int, sell: int, price_last: int} */
private function livePricePayload(): array
{
$cached = Cache::get(self::LIVE_PRICE_CACHE_KEY);
if (is_array($cached)) {
return $cached;
}
return $this->resolveFallbackPayload();
}
/** @return array{price: int, buy: int, sell: int, price_last: int} */
private function resolveFallbackPayload(): array
{
$config = app(ConfigService::class);
$provider = $config->priceProvider();
$price = 0;
if ($provider === 'montzar') {
$price = $config->getInt('montzar_live_price', 0);
} elseif ($provider === 'zaarbox') {
$price = $this->resolveZaarboxBoardPriceFromCache($config);
} elseif ($provider === 'main_product') {
$main = $config->mainProduct();
if ($main) {
$price = (int) (($main->price ?? 0) + ($main->price_profit ?? 0));
}
}
if ($price <= 0 && $config->getBool('show_last_price', false)) {
$last = Price::orderByDesc('id')->first();
if ($last && (float) $last->price > 0) {
$price = (int) $last->price;
}
}
$diffBuy = (int) $config->displayBuyPriceDiffRial();
$diffSell = (int) $config->displaySellPriceDiffRial();
$payload = [
'price' => $price,
'buy' => $price > 0 ? $price + $diffBuy : 0,
'sell' => $price > 0 ? max(0, $price - $diffSell) : 0,
'price_last' => $price > 0 ? $price + $diffBuy : 0,
];
if ($this->extractBaseFromPayload($payload) > 0) {
$this->cacheLivePricePayload($payload);
}
return $payload;
}
private function resolveZaarboxBoardPriceFromCache(ConfigService $config): int
{
$identifier = trim($config->getString('zaarbox_price_key', ''));
if ($identifier === '') {
return 0;
}
$feed = app(ZaarboxPriceService::class)->cachedPriceList(false);
if (!is_array($feed)) {
return 0;
}
$row = app(ZaarboxPriceService::class)->findFeedRow($feed, $identifier);
if ($row === null) {
return 0;
}
$row = app(ZaarboxPriceService::class)->normalizeFeedRow($row);
return app(ZaarboxPriceService::class)->resolveReferenceProviderTotal($row);
}
private function isMeltedTradeProduct(Product $product): bool
{
$group = trim((string) ($product->group_type ?? ''));
return in_array($group, ['آبشده', 'کاتد مس'], true);
}
/**
* آیا بعد از enrich قیمت پایه برای نمایش در اپ کاربر وجود دارد؟
* کارمزد (cash_fee) به‌تنهایی قیمت محسوب نمی‌شود.
*/
public function hasCustomerDisplayPrice(Product $product): bool
{
if ((int) ($product->price ?? 0) > 0) {
return true;
}
if (Schema::hasColumn($product->getTable(), 'price_profit')) {
if ((int) ($product->price_profit ?? 0) > 0) {
return true;
}
}
return false;
}
/**
* نمایش در اپ کاربر:
* • خاموش (show=false در DB) → false
* • قیمت نمایشی بعد از enrich → true
* • قیمت صفر و show_last_price خاموش → false
* • قیمت صفر و show_last_price روشن و fallback موجود → true
*/
public function resolveCustomerShow(Product $product): bool
{
if (! (bool) ($product->active ?? true)) {
return false;
}
if (! app(ConfigService::class)->isProductWithinTradingHours($product)) {
return false;
}
if ($this->hasCustomerDisplayPrice($product)) {
return true;
}
if (! app(ConfigService::class)->getBool('show_last_price', false)) {
return false;
}
return $this->resolveLastPriceFallback($product) > 0
|| $this->resolveHistoricalBasePrice() > 0;
}
}