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>
293 lines
9.9 KiB
PHP
293 lines
9.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Http\Controllers\Single\SingleOrderController;
|
|
use App\Models\ConditionalOrder;
|
|
use App\Models\Customer;
|
|
use App\Models\Product;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
|
|
|
class ConditionalOrderService
|
|
{
|
|
public function evaluateFromLastPrice(array $payload = []): void
|
|
{
|
|
$config = app(ConfigService::class);
|
|
if (! $config->isConditionalOrderEnabled()) {
|
|
return;
|
|
}
|
|
|
|
$this->expirePendingPastDue();
|
|
|
|
$orders = ConditionalOrder::query()
|
|
->with(['customer', 'product'])
|
|
->where('status', ConditionalOrder::STATUS_PENDING)
|
|
->where('is_active', true)
|
|
->whereHas('customer', function ($q) {
|
|
$q->where('conditional_order_active', 1);
|
|
})
|
|
->get();
|
|
|
|
if ($orders->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
$display = app(ProductDisplayPriceService::class);
|
|
$liquidPrice = (int) ($payload['price'] ?? $payload['buy'] ?? 0);
|
|
if ($liquidPrice <= 0) {
|
|
$liquidPrice = (int) ($config->getInt('montzar_live_price', 0));
|
|
}
|
|
|
|
$productsById = Product::query()
|
|
->whereIn('id', $orders->pluck('product_id')->unique()->filter()->all())
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
if ($productsById->isNotEmpty()) {
|
|
$display->enrichForApi($productsById);
|
|
}
|
|
|
|
foreach ($orders as $order) {
|
|
try {
|
|
$this->tryExecute($order, $productsById, $liquidPrice, $config);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('Conditional order evaluate failed', [
|
|
'id' => $order->id,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function tryExecute(
|
|
ConditionalOrder $order,
|
|
$productsById,
|
|
int $liquidPrice,
|
|
ConfigService $config
|
|
): void {
|
|
$product = $productsById->get($order->product_id) ?? $order->product;
|
|
if (! $product) {
|
|
return;
|
|
}
|
|
|
|
$display = app(ProductDisplayPriceService::class);
|
|
$currentPrice = $this->resolveComparePrice($product, $liquidPrice, $display, $config, (int) $order->direction);
|
|
if ($currentPrice <= 0 || ! $order->conditionMet($currentPrice)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$config->assertProductTradingOpen($product);
|
|
} catch (\Throwable $e) {
|
|
// خارج ساعت — pending بماند
|
|
return;
|
|
}
|
|
|
|
$this->executeOrder($order, $product, $currentPrice);
|
|
}
|
|
|
|
private function resolveComparePrice(
|
|
Product $product,
|
|
int $liquidPrice,
|
|
ProductDisplayPriceService $display,
|
|
ConfigService $config,
|
|
int $direction
|
|
): int {
|
|
if ($display->usesAppPrice($product)) {
|
|
$base = $liquidPrice > 0 ? $liquidPrice : $display->resolveOrderBasePrice($product);
|
|
|
|
// مقایسه با همان قیمتی که مشتری در اپ میبیند و سفارش با آن ثبت میشود، نه پایهٔ وسط
|
|
$trade = $config->buildTradeDisplayPrices((float) $base, $config->priceProvider());
|
|
|
|
return max(0, (int) ($direction === 2 ? $trade['sell'] : $trade['buy']));
|
|
}
|
|
|
|
$price = (int) ($product->price ?? 0);
|
|
if ($price > 0) {
|
|
return $price;
|
|
}
|
|
|
|
return max(0, (int) $display->resolveOrderBasePrice($product));
|
|
}
|
|
|
|
private function executeOrder(ConditionalOrder $order, Product $product, int $currentPrice): void
|
|
{
|
|
$customer = $order->customer ?? Customer::find($order->customer_id);
|
|
if (! $customer || ! $customer->conditional_order_active) {
|
|
return;
|
|
}
|
|
|
|
$payload = [
|
|
'customer_id' => (int) $customer->id,
|
|
'direction' => (int) $order->direction,
|
|
'method' => 'cash',
|
|
];
|
|
|
|
if ($this->isMeltedLikeProduct($product)) {
|
|
$payload['abshode_id'] = (int) $product->id;
|
|
$payload['amount'] = (float) ($order->amount ?? 0);
|
|
if ($payload['amount'] <= 0) {
|
|
$this->markFailed($order, $currentPrice, 'مقدار گرم نامعتبر است');
|
|
|
|
return;
|
|
}
|
|
} else {
|
|
$payload['product_id'] = (int) $product->id;
|
|
$payload['number'] = (float) ($order->number ?? $order->amount ?? 0);
|
|
if ($payload['number'] <= 0) {
|
|
$this->markFailed($order, $currentPrice, 'تعداد نامعتبر است');
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
$guard = Auth::guard('customer');
|
|
$previous = $guard->user();
|
|
$guard->setUser($customer);
|
|
|
|
try {
|
|
$request = Request::create('/customer/single/order', 'POST', $payload);
|
|
$response = app(SingleOrderController::class)->store($request);
|
|
$body = method_exists($response, 'getData')
|
|
? (array) $response->getData(true)
|
|
: (is_array($response) ? $response : []);
|
|
|
|
$orderData = (isset($body[1]) && is_array($body[1])) ? $body[1] : $body;
|
|
$createdOrderId = (int) (
|
|
data_get($orderData, 'order.id')
|
|
?: data_get($orderData, 'id')
|
|
?: 0
|
|
);
|
|
|
|
$order->status = ConditionalOrder::STATUS_EXECUTED;
|
|
$order->is_active = false;
|
|
$order->order_id = $createdOrderId > 0 ? $createdOrderId : null;
|
|
$order->triggered_price = $currentPrice;
|
|
$order->executed_at = now();
|
|
$order->fail_reason = null;
|
|
$order->save();
|
|
} catch (\Throwable $e) {
|
|
$message = $e instanceof HttpExceptionInterface
|
|
? ($e->getMessage() ?: 'خطا در ثبت سفارش')
|
|
: ($e->getMessage() ?: 'خطا در ثبت سفارش');
|
|
|
|
$this->markFailed($order, $currentPrice, $message);
|
|
} finally {
|
|
if ($previous) {
|
|
$guard->setUser($previous);
|
|
} else {
|
|
$guard->forgetUser();
|
|
}
|
|
}
|
|
}
|
|
|
|
private function markFailed(ConditionalOrder $order, int $currentPrice, string $reason): void
|
|
{
|
|
$order->status = ConditionalOrder::STATUS_FAILED;
|
|
$order->is_active = false;
|
|
$order->triggered_price = $currentPrice;
|
|
$order->executed_at = now();
|
|
$order->fail_reason = mb_substr($reason, 0, 250);
|
|
$order->save();
|
|
}
|
|
|
|
/** آبشده / معامله وزنی → amount؛ محصول ثابت → number */
|
|
private function isMeltedLikeProduct(Product $product): bool
|
|
{
|
|
$group = trim((string) ($product->group_type ?? ''));
|
|
if (in_array($group, ['آبشده', 'کاتد مس'], true)) {
|
|
return true;
|
|
}
|
|
|
|
if ((bool) ($product->trade_by_quantity ?? false)) {
|
|
return true;
|
|
}
|
|
|
|
$mainId = app(ConfigService::class)->mainProductId();
|
|
|
|
return $mainId > 0 && (int) $product->id === (int) $mainId;
|
|
}
|
|
|
|
public function expirePendingPastDue(): int
|
|
{
|
|
$now = now()->timezone(config('app.timezone'))->format('Y-m-d H:i:s');
|
|
|
|
return ConditionalOrder::query()
|
|
->where('status', ConditionalOrder::STATUS_PENDING)
|
|
->whereNotNull('expires_at')
|
|
->where('expires_at', '<', $now)
|
|
->update([
|
|
'status' => ConditionalOrder::STATUS_EXPIRED,
|
|
'is_active' => false,
|
|
'fail_reason' => 'منقضی شده',
|
|
]);
|
|
}
|
|
|
|
public function assertCustomerCanUse(Customer $customer): void
|
|
{
|
|
$config = app(ConfigService::class);
|
|
if (! $config->isConditionalOrderEnabled()) {
|
|
throw new \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException(
|
|
'قابلیت سفارش شرطی غیرفعال است.'
|
|
);
|
|
}
|
|
|
|
if (! $customer->conditional_order_active) {
|
|
throw new \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException(
|
|
'سفارش شرطی برای شما مجاز نیست.'
|
|
);
|
|
}
|
|
}
|
|
|
|
public function assertWithinLimit(Customer $customer): void
|
|
{
|
|
$limit = $customer->conditional_order_limit;
|
|
if ($limit === null || (int) $limit <= 0) {
|
|
return;
|
|
}
|
|
|
|
$pending = ConditionalOrder::query()
|
|
->where('customer_id', $customer->id)
|
|
->where('status', ConditionalOrder::STATUS_PENDING)
|
|
->count();
|
|
|
|
if ($pending >= (int) $limit) {
|
|
throw new \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException(
|
|
'سقف تعداد سفارش شرطی شما پر شده است.'
|
|
);
|
|
}
|
|
}
|
|
|
|
public function chargeRegistrationFee(Customer $customer): int
|
|
{
|
|
$fee = app(ConfigService::class)->conditionalOrderFee();
|
|
if ($fee <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
$walletInterface = new \Rooyesh\Wallet\interfaces\WalletInterface(
|
|
(int) $customer->id,
|
|
(int) $customer->type
|
|
);
|
|
$wallet = $walletInterface->wallet;
|
|
if (! $wallet || (float) $wallet->cash < $fee) {
|
|
throw new \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException(
|
|
'موجودی ریالی برای کارمزد سفارش شرطی کافی نیست.'
|
|
);
|
|
}
|
|
|
|
new \Rooyesh\Wallet\interfaces\TransactionInterface($walletInterface, [
|
|
'cash' => $fee,
|
|
'expire_at' => now(),
|
|
'note' => 'کارمزد ثبت سفارش شرطی',
|
|
'title' => 'کارمزد سفارش شرطی',
|
|
'active' => true,
|
|
], 4, true, false);
|
|
|
|
return $fee;
|
|
}
|
|
}
|