diff --git a/app/Http/Controllers/Admin/PriceTriggerController.php b/app/Http/Controllers/Admin/PriceTriggerController.php index 8a76b69..fd4bbd3 100644 --- a/app/Http/Controllers/Admin/PriceTriggerController.php +++ b/app/Http/Controllers/Admin/PriceTriggerController.php @@ -13,6 +13,9 @@ class PriceTriggerController extends Controller public function table(Request $request) { $query = PriceTrigger::query()->with('customer'); + if (PriceTrigger::hasProductIdColumn()) { + $query->with('product:id,title'); + } TableEngine::create($query, $request->all()) ->initSearch(['customer_id', 'target_price', 'action', 'price_condition']) @@ -31,7 +34,12 @@ class PriceTriggerController extends Controller 'is_active' => $data['is_active'] ?? true, ]); - return response()->json($trigger->load('customer'), 201); + $relations = ['customer']; + if (PriceTrigger::hasProductIdColumn()) { + $relations[] = 'product:id,title'; + } + + return response()->json($trigger->load($relations), 201); } public function update(Request $request, int $id) @@ -71,12 +79,17 @@ class PriceTriggerController extends Controller { $rules = [ 'customer_id' => ($partial ? 'sometimes|' : '') . 'required|integer|exists:customers,id', + 'product_id' => ($partial ? 'sometimes|' : '') . 'required|integer|exists:products,id', 'target_price' => ($partial ? 'sometimes|' : '') . 'required|numeric|min:1', 'price_condition' => ($partial ? 'sometimes|' : '') . 'required|in:>,<', 'action' => ($partial ? 'sometimes|' : '') . 'required|in:sms,email', 'is_active' => 'sometimes|boolean', ]; + if (! PriceTrigger::hasProductIdColumn()) { + unset($rules['product_id']); + } + $data = $request->validate($rules); if (isset($data['price_condition']) && !in_array($data['price_condition'], ['>', '<'], true)) { diff --git a/app/Http/Controllers/Admin/WalletController.php b/app/Http/Controllers/Admin/WalletController.php index 72accbc..dbb9cf0 100644 --- a/app/Http/Controllers/Admin/WalletController.php +++ b/app/Http/Controllers/Admin/WalletController.php @@ -67,6 +67,73 @@ class WalletController extends Controller } } + /** + * عدد نمایشی موجودی تا ۳ رقم گرد می‌شود؛ اگر ادمین همان عدد را بزند، کل ماندهٔ واقعی کسر شود. + */ + private function resolveGoldDischargeAmount(float $available, float $requested, string $error): float + { + if ($requested <= 0) { + throw new UnprocessableEntityHttpException($error); + } + if ($requested <= $available) { + return $requested; + } + $sameDisplayed = round($requested, 3) <= round($available, 3); + $withinDisplayStep = ($requested - $available) <= self::GOLD_DISPLAY_STEP; + if ($sameDisplayed || $withinDisplayStep) { + return $available; + } + + throw new UnprocessableEntityHttpException($error); + } + + /** + * طلای مانده روی کیف legacy محصولات کیف اصلی را به کیف اصلی منتقل می‌کند + * تا موجودی نمایشی (effective) با کیف قابل‌کسر یکی شود. + */ + private function consolidateOrphanedMainWalletGold(int $ownerId, int $ownerType): void + { + $productIds = app(ConfigService::class)->mainWalletProductIds(); + if ($productIds === []) { + return; + } + + $orphans = Wallet::query() + ->where('owner', $ownerId) + ->where('owner_type', $ownerType) + ->whereNull('fund_id') + ->whereIn('product_id', $productIds) + ->get(); + + $main = new WalletInterface($ownerId, $ownerType, null, null); + + foreach ($orphans as $orphan) { + $amount = (float) $orphan->gold; + if ($amount <= 1e-12) { + continue; + } + + $orphanWallet = new WalletInterface($ownerId, $ownerType, null, (int) $orphan->product_id); + new TransactionInterface($orphanWallet, [ + 'gold' => $amount, + 'cash' => 0, + 'expire_at' => now(), + 'note' => 'انتقال به کیف اصلی', + 'active' => true, + ], 7, false, false, 0); + + new TransactionInterface($main, [ + 'gold' => $amount, + 'cash' => 0, + 'expire_at' => now(), + 'note' => 'انتقال از کیف محصول', + 'active' => true, + ], 7, true, false, 0); + + $main = new WalletInterface($ownerId, $ownerType, null, null); + } + } + public function charge2(Request $request) { $data = $request->all(); @@ -271,20 +338,17 @@ class WalletController extends Controller $deposit = true; } + if (!$deposit) { + $this->consolidateOrphanedMainWalletGold((int) $customer->id, (int) $customer->type); + } $wallet_interface = new WalletInterface($data['customer_id'], (int) $customer->type); $wallet = $wallet_interface->wallet; // کسر دستی نباید موجودی طلا را منفی کند؛ شارژ مثبت بدون هیچ شرطی باز است if (!$deposit) { $available = is_null($wallet) ? 0.0 : (float) $wallet->gold; $requested = abs((float) $data['value']); - // ادمین عدد را از نمایش سه‌رقمی می‌خواند؛ اختلاف تا نصف پلهٔ نمایش یعنی «کل موجودی» - if ($requested > $available && $requested - $available <= self::GOLD_DISPLAY_STEP) { - $requested = $available; - $data['value'] = -$available; - } - if ($requested <= 0 || $requested > $available) { - throw new UnprocessableEntityHttpException('مقدار موجودی طلا کمتر از درخواست شماست'); - } + $resolved = $this->resolveGoldDischargeAmount($available, $requested, 'مقدار موجودی طلا کمتر از درخواست شماست'); + $data['value'] = -$resolved; } $this->assertNotDuplicateCharge((int) $customer->id, (float) $data['value'], 'gold'); $gold_block = 0; @@ -489,6 +553,11 @@ class WalletController extends Controller throw new UnprocessableEntityHttpException('برای محصول تعدادی فقط عدد صحیح مجاز است'); } + $isMainWalletProduct = Controller::isMainWalletProduct($productId); + if (!$deposit && $isMainWalletProduct) { + $this->consolidateOrphanedMainWalletGold((int) $customer->id, (int) $customer->type); + } + $effectiveProductId = Controller::effectiveProductWalletId($productId); $wallet_interface = new WalletInterface( (int) $data['customer_id'], @@ -498,8 +567,15 @@ class WalletController extends Controller ); $wallet = $wallet_interface->wallet; - if (!$deposit && $wallet !== null && (float) $wallet->gold < abs((float) $data['value'])) { - throw new UnprocessableEntityHttpException('مقدار موجودی کمتر از درخواست شماست'); + if (!$deposit) { + $available = is_null($wallet) ? 0.0 : (float) $wallet->gold; + $resolved = $this->resolveGoldDischargeAmount( + $available, + abs($value), + 'مقدار موجودی کمتر از درخواست شماست' + ); + $value = -$resolved; + $data['value'] = $value; } $this->assertNotDuplicateCharge((int) $customer->id, (float) $data['value'], 'gold'); diff --git a/app/Http/Controllers/Customer/PriceTriggerController.php b/app/Http/Controllers/Customer/PriceTriggerController.php index 14cb689..0e6a517 100644 --- a/app/Http/Controllers/Customer/PriceTriggerController.php +++ b/app/Http/Controllers/Customer/PriceTriggerController.php @@ -2,8 +2,10 @@ namespace App\Http\Controllers\Customer; use App\Http\Controllers\SuperController; +use App\Models\PriceTrigger; +use App\Models\Product; use Illuminate\Http\Request; -use App\Models\PriceTrigger; // اطمینان حاصل کنید که این مدل به درستی وارد شده است +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; class PriceTriggerController extends SuperController { @@ -24,10 +26,55 @@ class PriceTriggerController extends SuperController */ public function index() { - $data=PriceTrigger::where("customer_id",auth()->id())->get(); - return response()->json($data); + $query = PriceTrigger::where("customer_id", auth()->id()); + if (PriceTrigger::hasProductIdColumn()) { + $query->with(['product:id,title']); + } + + return response()->json($query->get()); } - + public function store(Request $request) + { + $this->sanitizeProductId($request, true); + + return parent::store($request); + } + + public function update(Request $request, $id) + { + $this->sanitizeProductId($request, false); + + return parent::update($request, $id); + } + + private function sanitizeProductId(Request $request, bool $required): void + { + if (! PriceTrigger::hasProductIdColumn()) { + $request->request->remove('product_id'); + + return; + } + + $productId = $request->input('product_id'); + if ($productId === null || $productId === '') { + if ($required) { + throw new UnprocessableEntityHttpException('محصول را انتخاب کنید'); + } + $request->request->remove('product_id'); + + return; + } + + $exists = Product::query() + ->where('id', (int) $productId) + ->where('active', 1) + ->exists(); + + if (! $exists) { + throw new UnprocessableEntityHttpException('محصول نامعتبر است'); + } + + $request->merge(['product_id' => (int) $productId]); + } } - diff --git a/app/Http/Controllers/Site/ReportController.php b/app/Http/Controllers/Site/ReportController.php index 810d8aa..e1bc36a 100644 --- a/app/Http/Controllers/Site/ReportController.php +++ b/app/Http/Controllers/Site/ReportController.php @@ -4,21 +4,17 @@ namespace App\Http\Controllers\Site; use App\Http\Controllers\Admin\PriceController; use App\Http\Controllers\Controller; -use App\Mail\YourMailableClass; use App\Models\Cnt; use App\Models\Setting; use App\Models\ConfigStatic; -use App\Models\Customer; use App\Services\MontzarService; use App\Services\ZaarboxPriceService; use App\Models\Price; use App\Models\PriceCandle; -use App\Models\PriceTrigger; use App\Models\Product; use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Mail; use Morilog\Jalali\Jalalian; use App\Services\ConfigService; use App\Services\LivePriceCacheService; @@ -51,41 +47,7 @@ class ReportController extends Controller public function updatePrise() { - $config = app(ConfigService::class); - $lastPrice = Price::latest()->first(); - - if (!$lastPrice) { - return response()->json(); - } - - $PriceTriggers = PriceTrigger::where("is_active", true)->get(); - foreach ($PriceTriggers as $PriceTrigger) { - $diff = $PriceTrigger->dirction == 1 - ? $config->displaySellPriceDiffRial() - : ($PriceTrigger->dirction == 2 ? $config->displayBuyPriceDiffRial() : 0.0); - $priseGold = $PriceTrigger->dirction == 1 - ? $lastPrice->price - $diff - : ($PriceTrigger->dirction == 2 ? $lastPrice->price + $diff : $lastPrice->price); - - if (eval('return $priseGold ' . $PriceTrigger->price_condition . ' $PriceTrigger->target_price;')) { - $customer = Customer::find($PriceTrigger->customer_id); - if (!$customer) { - continue; - } - $message = "قیمت طلا به " . number_format($priseGold, -1) . " در هر گرم رسید\n" .$this->Config('name_fa'); - if ($PriceTrigger->action == 'email') { - Mail::to("$customer->email")->send(new YourMailableClass($message, '', '', '', '')); - } elseif ($PriceTrigger->action == 'sms') { - if (self::isSmsNotifEnabled('sms_notif_price_trigger')) { - $this->send($customer->mobile, $message); - } - } - $PriceTrigger->is_active = false; - $PriceTrigger->triggered_at = Carbon::now(); - $PriceTrigger->save(); - } - - } + app(\App\Services\PriceTriggerService::class)->evaluateActive(); return response()->json(); } @@ -164,9 +126,11 @@ class ReportController extends Controller $pricePayload = $payload; if (app()->runningInConsole()) { app(\App\Services\ConditionalOrderService::class)->evaluateFromLastPrice($pricePayload); + app(\App\Services\PriceTriggerService::class)->evaluateActive($pricePayload); } else { dispatch(function () use ($pricePayload) { app(\App\Services\ConditionalOrderService::class)->evaluateFromLastPrice($pricePayload); + app(\App\Services\PriceTriggerService::class)->evaluateActive($pricePayload); })->afterResponse(); } } diff --git a/app/Models/PriceTrigger.php b/app/Models/PriceTrigger.php index 5731068..0c25076 100644 --- a/app/Models/PriceTrigger.php +++ b/app/Models/PriceTrigger.php @@ -9,8 +9,11 @@ class PriceTrigger extends Model { use HasFactory, SoftDeletes; + private static ?bool $productIdColumnExists = null; + protected $fillable = [ 'customer_id', + 'product_id', 'target_price', 'price_condition', 'action', @@ -20,8 +23,32 @@ class PriceTrigger extends Model protected $casts = [ "is_active" => "boolean", ]; + + public static function hasProductIdColumn(): bool + { + if (static::$productIdColumnExists !== null) { + return static::$productIdColumnExists; + } + + try { + static::$productIdColumnExists = \Illuminate\Support\Facades\Schema::hasColumn( + (new static)->getTable(), + 'product_id' + ); + } catch (\Throwable) { + static::$productIdColumnExists = false; + } + + return static::$productIdColumnExists; + } + public function customer() { return $this->belongsTo(Customer::class); } + + public function product() + { + return $this->belongsTo(Product::class); + } } diff --git a/app/Services/PriceTriggerService.php b/app/Services/PriceTriggerService.php new file mode 100644 index 0000000..ad25fde --- /dev/null +++ b/app/Services/PriceTriggerService.php @@ -0,0 +1,180 @@ +where('is_active', true)->get(); + if ($triggers->isEmpty()) { + return; + } + + $config = app(ConfigService::class); + $display = app(ProductDisplayPriceService::class); + $lastPrice = Price::query()->latest('id')->first(); + $boardPrice = (float) ($payload['price'] ?? $lastPrice?->price ?? 0); + + $productsById = collect(); + if (PriceTrigger::hasProductIdColumn()) { + $ids = $triggers->pluck('product_id')->filter()->unique()->all(); + if ($ids !== []) { + $productsById = Product::query()->whereIn('id', $ids)->get()->keyBy('id'); + if ($productsById->isNotEmpty()) { + $display->warmLivePriceCache(); + $display->enrichForApi($productsById); + } + } + } + + foreach ($triggers as $trigger) { + try { + $this->tryFire($trigger, $productsById, $display, $config, $boardPrice); + } catch (\Throwable $e) { + Log::warning('Price trigger evaluate failed', [ + 'id' => $trigger->id, + 'message' => $e->getMessage(), + ]); + } + } + } + + private function tryFire( + PriceTrigger $trigger, + $productsById, + ProductDisplayPriceService $display, + ConfigService $config, + float $boardPrice + ): void { + $product = $this->resolveProduct($trigger, $productsById); + $current = $this->resolveCurrentPrice($trigger, $product, $display, $config, $boardPrice); + if ($current <= 0) { + return; + } + + if (! $this->conditionMet((string) $trigger->price_condition, $current, (float) $trigger->target_price)) { + return; + } + + $customer = Customer::query()->find($trigger->customer_id); + if (! $customer) { + return; + } + + $this->notify($trigger, $customer, $product, $current); + + $trigger->is_active = false; + $trigger->triggered_at = now(); + $trigger->save(); + } + + private function resolveProduct(PriceTrigger $trigger, $productsById): ?Product + { + if (! PriceTrigger::hasProductIdColumn()) { + return null; + } + + $productId = (int) ($trigger->product_id ?? 0); + if ($productId <= 0) { + return null; + } + + $product = $productsById->get($productId); + if ($product instanceof Product) { + return $product; + } + + return Product::query()->find($productId); + } + + private function resolveCurrentPrice( + PriceTrigger $trigger, + ?Product $product, + ProductDisplayPriceService $display, + ConfigService $config, + float $boardPrice + ): float { + if ($product instanceof Product) { + $enriched = (int) (($product->price ?? 0) + ($product->price_profit ?? 0)); + if ($enriched > 0) { + return (float) $enriched; + } + + if ($display->usesAppPrice($product)) { + $base = $boardPrice > 0 ? $boardPrice : $display->resolveOrderBasePrice($product); + if ($base <= 0) { + return 0.0; + } + + $trade = $config->buildTradeDisplayPrices((float) $base, $config->priceProvider()); + $direction = (int) ($trigger->direction ?? 0); + + return (float) max(0, (int) ($direction === 2 ? $trade['sell'] : $trade['buy'])); + } + + return max(0.0, (float) $display->resolveOrderBasePrice($product)); + } + + if ($boardPrice <= 0) { + return 0.0; + } + + $direction = (int) ($trigger->direction ?? $trigger->dirction ?? 0); + $diff = $direction === 1 + ? $config->displaySellPriceDiffRial() + : ($direction === 2 ? $config->displayBuyPriceDiffRial() : 0.0); + + if ($direction === 1) { + return max(0.0, $boardPrice - $diff); + } + + if ($direction === 2) { + return $boardPrice + $diff; + } + + return $boardPrice; + } + + private function conditionMet(string $op, float $current, float $target): bool + { + return match ($op) { + '>' => $current > $target, + '<' => $current < $target, + '>=', 'gte' => $current >= $target, + '<=', 'lte' => $current <= $target, + '==', '=' => abs($current - $target) < 1, + default => false, + }; + } + + private function notify(PriceTrigger $trigger, Customer $customer, ?Product $product, float $current): void + { + $title = trim((string) ($product?->title ?? '')); + if ($title === '') { + $title = 'طلا'; + } + + $shopName = (string) Controller::Config('name_fa'); + $message = 'قیمت ' . $title . ' به ' . number_format((int) $current) . " رسید\n" . $shopName; + + if ($trigger->action === 'email' && ! empty($customer->email)) { + Mail::to($customer->email)->send(new YourMailableClass($message, '', '', '', '')); + + return; + } + + if ($trigger->action === 'sms' && Controller::isSmsNotifEnabled('sms_notif_price_trigger')) { + Controller::Send($customer->mobile, $message); + } + } +} diff --git a/database/migrations/2026_08_18_000002_add_product_id_to_price_triggers_table.php b/database/migrations/2026_08_18_000002_add_product_id_to_price_triggers_table.php new file mode 100644 index 0000000..e4aa308 --- /dev/null +++ b/database/migrations/2026_08_18_000002_add_product_id_to_price_triggers_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('product_id')->nullable()->after('customer_id'); + $table->index('product_id'); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('price_triggers') || ! Schema::hasColumn('price_triggers', 'product_id')) { + return; + } + + Schema::table('price_triggers', function (Blueprint $table) { + $table->dropIndex(['product_id']); + $table->dropColumn('product_id'); + }); + } +}; diff --git a/database/schema/mysql-schema.sql.reference b/database/schema/mysql-schema.sql.reference index 2d7f164..aa4a617 100644 --- a/database/schema/mysql-schema.sql.reference +++ b/database/schema/mysql-schema.sql.reference @@ -593,6 +593,7 @@ DROP TABLE IF EXISTS `price_triggers`; CREATE TABLE `price_triggers` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `customer_id` bigint unsigned NOT NULL, + `product_id` bigint unsigned DEFAULT NULL, `target_price` bigint NOT NULL, `price_condition` varchar(50) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL, `action` varchar(50) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL, diff --git a/database/sql/add_price_trigger_product_id.sql b/database/sql/add_price_trigger_product_id.sql new file mode 100644 index 0000000..69b4fce --- /dev/null +++ b/database/sql/add_price_trigger_product_id.sql @@ -0,0 +1,25 @@ +-- محصول هشدار قیمت (price_triggers.product_id) +-- امن برای اجرای مجدد +-- USE دیتابیس_خودتان; + +SET @db = DATABASE(); + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'price_triggers') = 0, + 'SELECT ''skip: price_triggers table not found'' AS info', + IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'price_triggers' AND COLUMN_NAME = 'product_id') > 0, + 'SELECT ''skip: product_id exists'' AS info', + IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'price_triggers' AND COLUMN_NAME = 'customer_id') > 0, + 'ALTER TABLE `price_triggers` ADD COLUMN `product_id` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT ''محصول هشدار'' AFTER `customer_id`, ADD INDEX `price_triggers_product_id_index` (`product_id`)', + 'ALTER TABLE `price_triggers` ADD COLUMN `product_id` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT ''محصول هشدار'', ADD INDEX `price_triggers_product_id_index` (`product_id`)' + ) + ) +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/database/sql/deploy_safe_schema.sql b/database/sql/deploy_safe_schema.sql index 83f920a..246b21f 100644 --- a/database/sql/deploy_safe_schema.sql +++ b/database/sql/deploy_safe_schema.sql @@ -523,6 +523,38 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; +-- --------------------------------------------------------------------------- +-- price_triggers.product_id (هشدار قیمت روی محصول) +-- --------------------------------------------------------------------------- +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'price_triggers') > 0 + AND (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'price_triggers' AND COLUMN_NAME = 'product_id') = 0, + IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'price_triggers' AND COLUMN_NAME = 'customer_id') > 0, + 'ALTER TABLE `price_triggers` ADD COLUMN `product_id` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT ''محصول هشدار'' AFTER `customer_id`', + 'ALTER TABLE `price_triggers` ADD COLUMN `product_id` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT ''محصول هشدار''' + ), + 'SELECT ''skip price_triggers.product_id'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'price_triggers' AND INDEX_NAME = 'price_triggers_product_id_index') = 0 + AND (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'price_triggers' AND COLUMN_NAME = 'product_id') > 0, + 'ALTER TABLE `price_triggers` ADD INDEX `price_triggers_product_id_index` (`product_id`)', + 'SELECT ''skip price_triggers.product_id index'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + -- --------------------------------------------------------------------------- -- پایان — برای بورس‌مس (اختیاری، جدا اجرا کنید): -- UPDATE `settings` SET `value` = 'کیلوگرم' WHERE `key` = 'unit';