diff --git a/app/Http/Controllers/Admin/ConditionalOrderController.php b/app/Http/Controllers/Admin/ConditionalOrderController.php new file mode 100644 index 0000000..b7ae481 --- /dev/null +++ b/app/Http/Controllers/Admin/ConditionalOrderController.php @@ -0,0 +1,133 @@ +with(['customer', 'product']); + + TableEngine::create($query, $request->all()) + ->initSearch(['customer_id', 'product_id', 'status', 'direction', 'price_condition']) + ->select(['*']) + ->export($query); + + return $query; + } + + public function store(Request $request) + { + $data = $this->validatedPayload($request); + $customer = Customer::findOrFail($data['customer_id']); + + $service = app(ConditionalOrderService::class); + if (! app(ConfigService::class)->isConditionalOrderEnabled()) { + throw new UnprocessableEntityHttpException('قابلیت سفارش شرطی سراسری خاموش است.'); + } + if (! $customer->conditional_order_active) { + throw new UnprocessableEntityHttpException('سفارش شرطی برای این مشتری مجاز نیست.'); + } + $service->assertWithinLimit($customer); + + $fee = 0; + DB::beginTransaction(); + try { + $fee = $service->chargeRegistrationFee($customer); + $order = ConditionalOrder::create([ + ...$data, + 'is_active' => $data['is_active'] ?? true, + 'status' => ConditionalOrder::STATUS_PENDING, + 'fee_charged' => $fee, + ]); + DB::commit(); + } catch (\Throwable $e) { + DB::rollBack(); + throw $e; + } + + return response()->json($order->load(['customer', 'product']), 201); + } + + public function update(Request $request, int $id) + { + $order = ConditionalOrder::findOrFail($id); + $data = $this->validatedPayload($request, true); + unset($data['customer_id']); + $order->update($data); + + return response()->json(true); + } + + public function toggleActive(int $id) + { + $order = ConditionalOrder::findOrFail($id); + if ($order->status !== ConditionalOrder::STATUS_PENDING) { + throw new UnprocessableEntityHttpException('فقط سفارش‌های در انتظار قابل فعال/غیرفعال‌سازی هستند.'); + } + + $order->is_active = ! $order->is_active; + $order->save(); + + return response()->json(['is_active' => (bool) $order->is_active]); + } + + public function destroy(int $id) + { + ConditionalOrder::findOrFail($id)->delete(); + + return response()->json(true); + } + + private function validatedPayload(Request $request, bool $partial = false): array + { + $rules = [ + 'customer_id' => ($partial ? 'sometimes|' : '') . 'required|integer|exists:customers,id', + 'product_id' => ($partial ? 'sometimes|' : '') . 'required|integer|exists:products,id', + 'direction' => ($partial ? 'sometimes|' : '') . 'required|integer|in:1,2', + 'price_condition' => ($partial ? 'sometimes|' : '') . 'required|in:gte,lte', + 'trigger_price' => ($partial ? 'sometimes|' : '') . 'required|numeric|min:1', + 'amount' => 'nullable|numeric|min:0', + 'number' => 'nullable|numeric|min:0', + 'expires_at' => 'nullable|date', + 'description' => 'nullable|string|max:2000', + 'is_active' => 'sometimes|boolean', + ]; + + $data = $request->validate($rules); + + $productId = (int) ($data['product_id'] ?? $request->input('product_id') ?? 0); + $product = $productId > 0 ? Product::find($productId) : null; + + $amount = isset($data['amount']) ? (float) $data['amount'] : null; + $number = isset($data['number']) ? (float) $data['number'] : null; + + if (! $partial) { + if (($amount === null || $amount <= 0) && ($number === null || $number <= 0)) { + throw new UnprocessableEntityHttpException('مقدار یا تعداد را وارد کنید.'); + } + } + + if ($product && $amount !== null && $amount > 0 && ($number === null || $number <= 0)) { + $unit = (float) ($product->amount ?? 0); + if ($unit > 0 && ! in_array(trim((string) ($product->group_type ?? '')), ['آبشده', 'کاتد مس'], true) + && ! (bool) ($product->trade_by_quantity ?? false)) { + $data['number'] = $amount; + $data['amount'] = null; + } + } + + return $data; + } +} diff --git a/app/Http/Controllers/Admin/CustomerController.php b/app/Http/Controllers/Admin/CustomerController.php index 0b2ee52..550108f 100644 --- a/app/Http/Controllers/Admin/CustomerController.php +++ b/app/Http/Controllers/Admin/CustomerController.php @@ -266,6 +266,34 @@ class CustomerController extends SuperController ]); return response()->json(true); } + + public function changeConditionalOrderActive($id) + { + $query = Customer::findOrFail($id); + $query->update([ + 'conditional_order_active' => ! $query->conditional_order_active, + ]); + + return response()->json([ + 'conditional_order_active' => (bool) $query->fresh()->conditional_order_active, + ]); + } + + public function updateConditionalOrderLimit(Request $request, $id) + { + $query = Customer::findOrFail($id); + $data = $request->validate([ + 'conditional_order_limit' => 'nullable|integer|min:1', + ]); + + $query->update([ + 'conditional_order_limit' => $data['conditional_order_limit'] ?? null, + ]); + + return response()->json([ + 'conditional_order_limit' => $query->fresh()->conditional_order_limit, + ]); + } // single customer------------------- public function singleArchiveTable(Request $request) diff --git a/app/Http/Controllers/Customer/ConditionalOrderController.php b/app/Http/Controllers/Customer/ConditionalOrderController.php new file mode 100644 index 0000000..ebee992 --- /dev/null +++ b/app/Http/Controllers/Customer/ConditionalOrderController.php @@ -0,0 +1,161 @@ +authCustomer(); + app(ConditionalOrderService::class)->assertCustomerCanUse($customer); + + $items = ConditionalOrder::query() + ->with('product') + ->where('customer_id', $customer->id) + ->orderByDesc('id') + ->get(); + + return response()->json($items); + } + + public function store(Request $request) + { + $customer = $this->authCustomer(); + $service = app(ConditionalOrderService::class); + $service->assertCustomerCanUse($customer); + $service->assertWithinLimit($customer); + + $data = $this->validatedPayload($request); + $data['customer_id'] = $customer->id; + + $fee = 0; + DB::beginTransaction(); + try { + $fee = $service->chargeRegistrationFee($customer); + $order = ConditionalOrder::create([ + ...$data, + 'is_active' => $data['is_active'] ?? true, + 'status' => ConditionalOrder::STATUS_PENDING, + 'fee_charged' => $fee, + ]); + DB::commit(); + } catch (\Throwable $e) { + DB::rollBack(); + throw $e; + } + + return response()->json($order->load('product'), 201); + } + + public function update(Request $request, int $id) + { + $customer = $this->authCustomer(); + app(ConditionalOrderService::class)->assertCustomerCanUse($customer); + + $order = ConditionalOrder::query() + ->where('customer_id', $customer->id) + ->findOrFail($id); + + if ($order->status !== ConditionalOrder::STATUS_PENDING) { + throw new UnprocessableEntityHttpException('فقط سفارش در انتظار قابل ویرایش است.'); + } + + $data = $this->validatedPayload($request, true); + unset($data['customer_id']); + $order->update($data); + + return response()->json($order->fresh()->load('product')); + } + + public function destroy(int $id) + { + $customer = $this->authCustomer(); + app(ConditionalOrderService::class)->assertCustomerCanUse($customer); + + $order = ConditionalOrder::query() + ->where('customer_id', $customer->id) + ->findOrFail($id); + $order->delete(); + + return response()->json(true); + } + + public function toggleActive(int $id) + { + $customer = $this->authCustomer(); + app(ConditionalOrderService::class)->assertCustomerCanUse($customer); + + $order = ConditionalOrder::query() + ->where('customer_id', $customer->id) + ->findOrFail($id); + + if ($order->status !== ConditionalOrder::STATUS_PENDING) { + throw new UnprocessableEntityHttpException('فقط سفارش در انتظار قابل فعال/غیرفعال‌سازی است.'); + } + + $order->is_active = ! $order->is_active; + $order->save(); + + return response()->json(['is_active' => (bool) $order->is_active]); + } + + private function authCustomer(): Customer + { + $customer = auth('customer')->user(); + if (! $customer) { + throw new UnprocessableEntityHttpException('احراز هویت نامعتبر است.'); + } + + return $customer; + } + + private function validatedPayload(Request $request, bool $partial = false): array + { + $rules = [ + 'product_id' => ($partial ? 'sometimes|' : '') . 'required|integer|exists:products,id', + 'direction' => ($partial ? 'sometimes|' : '') . 'required|integer|in:1,2', + 'price_condition' => ($partial ? 'sometimes|' : '') . 'required|in:gte,lte', + 'trigger_price' => ($partial ? 'sometimes|' : '') . 'required|numeric|min:1', + 'amount' => 'nullable|numeric|min:0', + 'number' => 'nullable|numeric|min:0', + 'expires_at' => 'nullable|date|after:now', + 'description' => 'nullable|string|max:2000', + 'is_active' => 'sometimes|boolean', + ]; + + $data = $request->validate($rules); + + $amount = isset($data['amount']) ? (float) $data['amount'] : null; + $number = isset($data['number']) ? (float) $data['number'] : null; + + if (! $partial && ($amount === null || $amount <= 0) && ($number === null || $number <= 0)) { + throw new UnprocessableEntityHttpException('مقدار گرم یا تعداد را وارد کنید.'); + } + + $productId = (int) ($data['product_id'] ?? $request->input('product_id') ?? 0); + $product = $productId > 0 ? Product::find($productId) : null; + if ($product && $amount !== null && $amount > 0 && ($number === null || $number <= 0)) { + $group = trim((string) ($product->group_type ?? '')); + $isMelted = in_array($group, ['آبشده', 'کاتد مس'], true) + || (bool) ($product->trade_by_quantity ?? false) + || ((int) $product->id === (int) app(ConfigService::class)->mainProductId()); + + if (! $isMelted && (float) ($product->amount ?? 0) > 0) { + $data['number'] = $amount; + $data['amount'] = null; + } + } + + return $data; + } +} diff --git a/app/Http/Controllers/Site/ReportController.php b/app/Http/Controllers/Site/ReportController.php index a8b5c1e..eae4cad 100644 --- a/app/Http/Controllers/Site/ReportController.php +++ b/app/Http/Controllers/Site/ReportController.php @@ -157,6 +157,17 @@ class ReportController extends Controller })->afterResponse(); } + if ($isFreshPrice && (int) ($payload['price'] ?? 0) > 0) { + $pricePayload = $payload; + if (app()->runningInConsole()) { + app(\App\Services\ConditionalOrderService::class)->evaluateFromLastPrice($pricePayload); + } else { + dispatch(function () use ($pricePayload) { + app(\App\Services\ConditionalOrderService::class)->evaluateFromLastPrice($pricePayload); + })->afterResponse(); + } + } + if ($isFreshPrice) { $cache = app(LivePriceCacheService::class); $cache->markTick(); diff --git a/app/Models/ConditionalOrder.php b/app/Models/ConditionalOrder.php new file mode 100644 index 0000000..7d065b6 --- /dev/null +++ b/app/Models/ConditionalOrder.php @@ -0,0 +1,86 @@ + 'boolean', + 'direction' => 'integer', + 'trigger_price' => 'integer', + 'amount' => 'float', + 'number' => 'float', + 'fee_charged' => 'integer', + 'triggered_price' => 'integer', + 'expires_at' => 'datetime', + 'executed_at' => 'datetime', + ]; + + public function customer() + { + return $this->belongsTo(Customer::class); + } + + public function product() + { + return $this->belongsTo(Product::class); + } + + public function order() + { + return $this->belongsTo(Order::class); + } + + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function conditionMet(int $currentPrice): bool + { + if ($currentPrice <= 0) { + return false; + } + + $target = (int) $this->trigger_price; + + return match ($this->price_condition) { + self::CONDITION_GTE => $currentPrice >= $target, + self::CONDITION_LTE => $currentPrice <= $target, + default => false, + }; + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index d244545..bdfac17 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -41,6 +41,8 @@ class Customer extends Authenticatable implements JWTSubject 'is_archive', 'gram', 'trade_active',//آیا امکان معامله دارد؟ + 'conditional_order_active',//آیا سفارش شرطی مجاز است؟ + 'conditional_order_limit',//سقف تعداد pending (null = نامحدود) 'type', 'referral_id', 'referral_to_id', @@ -69,6 +71,8 @@ class Customer extends Authenticatable implements JWTSubject "gold" => "float", "is_archive" => "boolean", "finger_print" => "boolean", + "conditional_order_active" => "boolean", + "conditional_order_limit" => "integer", ]; static $TYPE = [ 1=>'همکار', diff --git a/app/Services/AssetSummaryService.php b/app/Services/AssetSummaryService.php index 245deda..1ee7693 100644 --- a/app/Services/AssetSummaryService.php +++ b/app/Services/AssetSummaryService.php @@ -40,6 +40,17 @@ class AssetSummaryService $mainWallet = $this->resolveMainWallet($userWallets); $metalIds = $this->catalogMetalIds($catalog); + // موجودی کیف اصلی نباید به‌خاطر inactive بودن محصول نماینده از metal_totals حذف شود + if ($customerId > 0) { + $effectiveGold = $this->walletBalanceService->effectiveMainWalletGold($customerId, $ownerType); + if (abs($effectiveGold) >= 0.000001) { + $mainMetalId = $this->walletBalanceService->resolveMainWalletMetalId(); + if ($mainMetalId > 0 && !in_array($mainMetalId, $metalIds, true)) { + $metalIds[] = $mainMetalId; + sort($metalIds); + } + } + } $weightUnit = Controller::tradeWeightUnit(); $hasCountBased = $this->catalogHasCountBased($catalog); $hasWeightBased = $this->catalogHasWeightBased($catalog); @@ -478,7 +489,11 @@ class AssetSummaryService return $group !== '' ? $group : 'آبشده'; } - /** اولین محصولِ لیست main_wallet_products که در کاتالوگ فعال است. */ + /** + * اولین محصولِ لیست main_wallet_products. + * اگر در کاتالوگ فیلترشده (مثلاً فقط active) نبود، از DB بدون فیلتر active می‌خواند + * تا موجودی کیف اصلی برای اپ مشتری صفر نشود وقتی محصول نماینده غیرفعال است. + */ private function resolveMainWalletRepresentative(Collection $catalogById, array $mainWalletProductIds): ?Product { foreach ($mainWalletProductIds as $productId) { @@ -488,6 +503,23 @@ class AssetSummaryService } } + if ($mainWalletProductIds === []) { + return null; + } + + $byId = Product::query() + ->with('file') + ->whereIn('id', $mainWalletProductIds) + ->get() + ->keyBy('id'); + + foreach ($mainWalletProductIds as $productId) { + $product = $byId->get((int) $productId); + if ($product) { + return $product; + } + } + return null; } @@ -668,7 +700,11 @@ class AssetSummaryService if ($mainWalletProductIds !== []) { // حالت صریح: کیف اصلی فقط یک‌بار، زیر فلزِ محصول نماینده - $meltedGrams = ($mainWalletMetalId !== null && (int) $metalId === $mainWalletMetalId) + // اگر نماینده پیدا نشد (محصول حذف‌شده)، به فلز resolveMainWalletMetalId برگرد + if ($mainWalletMetalId === null || $mainWalletMetalId <= 0) { + $mainWalletMetalId = $this->walletBalanceService->resolveMainWalletMetalId(); + } + $meltedGrams = ((int) $metalId === (int) $mainWalletMetalId) ? $mainWalletGrams : 0.0; } else { diff --git a/app/Services/ConditionalOrderService.php b/app/Services/ConditionalOrderService.php new file mode 100644 index 0000000..25479b1 --- /dev/null +++ b/app/Services/ConditionalOrderService.php @@ -0,0 +1,282 @@ +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 + { + 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; + } +} diff --git a/app/Services/ConfigService.php b/app/Services/ConfigService.php index ff4bc77..8acf9ff 100644 --- a/app/Services/ConfigService.php +++ b/app/Services/ConfigService.php @@ -730,6 +730,17 @@ class ConfigService public const ACCOUNTING_PARGAR = 'pargar'; /** none | tahesab | pargar — از setting accounting_provider */ + public function isConditionalOrderEnabled(): bool + { + return $this->getBool('enable_conditional_order', false); + } + + /** کارمزد ریالی هر بار ثبت تریگر سفارش شرطی */ + public function conditionalOrderFee(): int + { + return max(0, $this->getInt('fee_conditional_order', 0)); + } + public function accountingProvider(): string { $provider = strtolower(trim($this->getString('accounting_provider', ''))); diff --git a/database/migrations/2026_07_21_000001_add_conditional_order_columns_to_customers_table.php b/database/migrations/2026_07_21_000001_add_conditional_order_columns_to_customers_table.php new file mode 100644 index 0000000..d4dd048 --- /dev/null +++ b/database/migrations/2026_07_21_000001_add_conditional_order_columns_to_customers_table.php @@ -0,0 +1,61 @@ +addCustomerColumnIfMissing( + 'conditional_order_active', + 'TINYINT(1) NOT NULL DEFAULT 1', + 'trade_active' + ); + $this->addCustomerColumnIfMissing( + 'conditional_order_limit', + 'INT UNSIGNED NULL DEFAULT NULL', + 'conditional_order_active' + ); + } + + public function down(): void + { + // عمداً خالی — rollback ستون را حذف نمی‌کند + } + + private function customersTable(): string + { + return DB::getTablePrefix().'customers'; + } + + private function customerColumnExists(string $column): bool + { + $result = DB::selectOne( + 'SELECT COUNT(*) AS cnt FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?', + [DB::connection()->getDatabaseName(), $this->customersTable(), $column] + ); + + return (int) $result->cnt > 0; + } + + private function addCustomerColumnIfMissing(string $column, string $definition, ?string $after = null): void + { + if ($this->customerColumnExists($column)) { + return; + } + + $table = $this->customersTable(); + $afterClause = ($after !== null && $this->customerColumnExists($after)) + ? " AFTER `{$after}`" + : ''; + + DB::statement("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}{$afterClause}"); + } +}; diff --git a/database/migrations/2026_07_21_000002_create_conditional_orders_table.php b/database/migrations/2026_07_21_000002_create_conditional_orders_table.php new file mode 100644 index 0000000..a002e59 --- /dev/null +++ b/database/migrations/2026_07_21_000002_create_conditional_orders_table.php @@ -0,0 +1,42 @@ +id(); + $table->unsignedBigInteger('customer_id')->index(); + $table->unsignedBigInteger('product_id')->index(); + $table->unsignedTinyInteger('direction'); // 1 buy, 2 sell + $table->string('price_condition', 8); // gte | lte + $table->unsignedBigInteger('trigger_price'); + $table->decimal('amount', 16, 4)->nullable(); + $table->decimal('number', 16, 4)->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->string('status', 16)->default('pending')->index(); // pending|executed|failed|expired + $table->string('fail_reason')->nullable(); + $table->unsignedBigInteger('order_id')->nullable()->index(); + $table->unsignedBigInteger('fee_charged')->default(0); + $table->unsignedBigInteger('triggered_price')->nullable(); + $table->timestamp('executed_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('conditional_orders'); + } +}; diff --git a/database/sql/deploy_settings_const.sql b/database/sql/deploy_settings_const.sql index 1b68756..9af260a 100644 --- a/database/sql/deploy_settings_const.sql +++ b/database/sql/deploy_settings_const.sql @@ -353,6 +353,18 @@ SELECT 'customer_direct_sms', 'false', 'پیامک تکی به مشتری از 'switch' WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'customer_direct_sms' LIMIT 1); +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'enable_conditional_order', 'false', 'سفارش شرطی', 0, 1, + 'روشن: قابلیت سفارش شرطی سراسری فعال است. خاموش: منو و ارزیابی اجرا نمی‌شود. فقط از دیپلوی قابل تغییر است.', + 'switch' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'enable_conditional_order' LIMIT 1); + +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'fee_conditional_order', '0', 'کارمزد سفارش شرطی', 1, 1, + 'مبلغ ریالی که با هر بار ثبت تریگر از کیف پول ریالی مشتری کم می‌شود. صفر = رایگان.', + 'number' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'fee_conditional_order' LIMIT 1); + INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`) SELECT 'sms_notif_order_admin', 'true', 'پیامک خرید/فروش به ادمین', 1, 1, NULL WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'sms_notif_order_admin' LIMIT 1); diff --git a/database/sql/deploy_settings_const_preserve_value_sync_can_edit.sql b/database/sql/deploy_settings_const_preserve_value_sync_can_edit.sql index e4855b9..de39a65 100644 --- a/database/sql/deploy_settings_const_preserve_value_sync_can_edit.sql +++ b/database/sql/deploy_settings_const_preserve_value_sync_can_edit.sql @@ -308,6 +308,18 @@ SELECT 'customer_direct_sms', 'false', 'پیامک تکی به مشتری از 'switch' WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'customer_direct_sms' LIMIT 1); +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'enable_conditional_order', 'false', 'سفارش شرطی', 0, 1, + 'روشن: قابلیت سفارش شرطی سراسری فعال است. خاموش: منو و ارزیابی اجرا نمی‌شود. فقط از دیپلوی قابل تغییر است.', + 'switch' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'enable_conditional_order' LIMIT 1); + +INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) +SELECT 'fee_conditional_order', '0', 'کارمزد سفارش شرطی', 1, 1, + 'مبلغ ریالی که با هر بار ثبت تریگر از کیف پول ریالی مشتری کم می‌شود. صفر = رایگان.', + 'number' +WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'fee_conditional_order' LIMIT 1); + INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`) SELECT 'sms_notif_order_admin', 'true', 'پیامک خرید/فروش به ادمین', 1, 1, NULL WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'sms_notif_order_admin' LIMIT 1); diff --git a/routes/admin.php b/routes/admin.php index a45c14d..6e1284d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -45,6 +45,12 @@ Route::middleware('auth:user')->group(function (){ Route::put('price_triggers/{id}', [AdminPriceTriggerController::class, 'update']); Route::put('price_triggers/{id}/toggle', [AdminPriceTriggerController::class, 'toggleActive']); Route::delete('price_triggers/{id}', [AdminPriceTriggerController::class, 'destroy']); + //------------ConditionalOrder + Route::post('conditional_orders/table', [\App\Http\Controllers\Admin\ConditionalOrderController::class, 'table']); + Route::post('conditional_orders', [\App\Http\Controllers\Admin\ConditionalOrderController::class, 'store']); + Route::put('conditional_orders/{id}', [\App\Http\Controllers\Admin\ConditionalOrderController::class, 'update']); + Route::put('conditional_orders/{id}/toggle', [\App\Http\Controllers\Admin\ConditionalOrderController::class, 'toggleActive']); + Route::delete('conditional_orders/{id}', [\App\Http\Controllers\Admin\ConditionalOrderController::class, 'destroy']); //-----------product Route::post('/product/table', [ProductController::class, 'tableProduct']); Route::post('/product/archive/table', [ProductController::class, 'archiveTable']); @@ -89,6 +95,8 @@ Route::middleware('auth:user')->group(function (){ Route::get('/customer/active/{id}',[CustomerController::class,'active']); Route::get('/customer/archive/{id}',[CustomerController::class,'archive']); Route::get('/customer/trade/{id}',[CustomerController::class,'changeTradeActive']); + Route::get('/customer/conditional_order/{id}',[CustomerController::class,'changeConditionalOrderActive']); + Route::put('/customer/conditional_order_limit/{id}',[CustomerController::class,'updateConditionalOrderLimit']); Route::post('/customer/archive/table',[CustomerController::class,'archiveTable']); Route::get('/customer/is_valid/{id}',[CustomerController::class,'MakeValidByAdmin']); Route::get('/customer/list',[CustomerController::class,'listUser']); diff --git a/routes/customer.php b/routes/customer.php index bb9c970..ed49ce1 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -49,6 +49,12 @@ Route::middleware(['auth:customer', 'customer.active'])->group(function () use ( //---------PriceTrigger Route::resource('price_triggers', PriceTriggerController::class); + //---------ConditionalOrder + Route::get('conditional_orders', [\App\Http\Controllers\Customer\ConditionalOrderController::class, 'index']); + Route::post('conditional_orders', [\App\Http\Controllers\Customer\ConditionalOrderController::class, 'store']); + Route::put('conditional_orders/{id}', [\App\Http\Controllers\Customer\ConditionalOrderController::class, 'update']); + Route::put('conditional_orders/{id}/toggle', [\App\Http\Controllers\Customer\ConditionalOrderController::class, 'toggleActive']); + Route::delete('conditional_orders/{id}', [\App\Http\Controllers\Customer\ConditionalOrderController::class, 'destroy']); $cfg = app(ConfigService::class); if ($cfg->getBool('profit_gold_api_enabled', false)) { Route::get('profit_gold', [OrderController::class, 'profit_gold']);