2026-07-21 21:23:26 +03:30
|
|
|
<?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);
|
|
|
|
|
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): int
|
|
|
|
|
{
|
|
|
|
|
if ($display->usesAppPrice($product)) {
|
|
|
|
|
$base = $liquidPrice > 0 ? $liquidPrice : $display->resolveOrderBasePrice($product);
|
|
|
|
|
|
|
|
|
|
return max(0, (int) $base);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$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
|
|
|
|
|
{
|
2026-07-22 14:15:22 +03:30
|
|
|
$now = now()->timezone(config('app.timezone'))->format('Y-m-d H:i:s');
|
|
|
|
|
|
2026-07-21 21:23:26 +03:30
|
|
|
return ConditionalOrder::query()
|
|
|
|
|
->where('status', ConditionalOrder::STATUS_PENDING)
|
|
|
|
|
->whereNotNull('expires_at')
|
2026-07-22 14:15:22 +03:30
|
|
|
->where('expires_at', '<', $now)
|
2026-07-21 21:23:26 +03:30
|
|
|
->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;
|
|
|
|
|
}
|
|
|
|
|
}
|