Files
boursecu_server/app/Services/ProductDisplayPriceService.php
T
mohmdsdgandCursor fabce6176a Price orders at the provider's real trade rate instead of the mid quote.
montzar_live_price is the midpoint between the colleague's buy and sell rates,
so orders priced straight from it charged the customer less than we pay the
provider and the whole spread was lost on every trade, even though the app
already showed the correct side. Order base price now follows the traded side
(base + spread on buy, base - spread on sell), which also makes price_original
the real cost basis so the dashboard stops counting the provider spread as
profit. Conditional order triggers compare against the same displayed price.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 19:21:56 +03:30

665 lines
22 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 (FormulaPriceService::isEnabled($product) && $product->reading_method && is_array($zaarboxFeed)) {
$this->applyFormulaFeedPrice($product, $zaarboxFeed, $zaarbox);
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 applyFormulaFeedPrice(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);
$this->applyPriceOrFallback($product, 0, $useLast);
return;
}
$parts = $zaarbox->resolveReferenceBaseProfit($row);
$providerActive = $useLast || $parts['price'] > 0;
$product->setAttribute('reference_provider_active', $providerActive);
if (! $useLast && $parts['price'] <= 0) {
$this->zeroProductPrice($product);
return;
}
$computed = FormulaPriceService::calculateForProduct($product, [
'price' => $parts['price'],
'active' => true,
]);
if ($computed === null || $computed <= 0) {
$this->applyPriceOrFallback($product, 0, $useLast);
return;
}
$product->setAttribute('price', $computed);
if (Schema::hasColumn($product->getTable(), 'price_profit')) {
$product->setAttribute('price_profit', 0);
}
}
/** @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 (FormulaPriceService::isEnabled($product)) {
return (int) (($product->price ?? 0) + ($product->price_profit ?? 0));
}
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();
}
/**
* قیمت پایهٔ سفارش برای یک جهت — با اختلاف واقعی نرخ همکار.
* پایه وسطِ نرخ خرید/فروش همکار است؛ سفارش باید با همان سمتی حساب شود که ما با همکار معامله می‌کنیم.
*/
public function resolveOrderTradePrice(?Product $product = null, ?int $direction = null): int
{
$base = (float) $this->resolveOrderBasePrice($product);
return (int) $this->applyProviderTradeSpread($base, $product, $direction);
}
/**
* خرید → پایه + اختلاف نرخ همکار، فروش → پایه − اختلاف.
* فقط روی قیمتی که از مظنهٔ اپ آمده؛ محصول با قیمت دستی/فید/فرمول دست نمی‌خورد.
* ceil مثل buildTradeDisplayPrices تا قیمت سفارش با قیمت نمایشی اپ یکی بماند.
*/
public function applyProviderTradeSpread(
float $basePrice,
?Product $product = null,
?int $direction = null
): float {
if ($basePrice <= 0 || $direction === null) {
return $basePrice;
}
if ($product instanceof Product && ! $this->usesAppPrice($product)) {
return $basePrice;
}
$spread = app(ConfigService::class)->providerTradeSpreadRial();
if ($spread <= 0) {
return $basePrice;
}
return $direction === 2
? max(0.0, ceil($basePrice - $spread))
: ceil($basePrice + $spread);
}
/** @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;
}
}