feat(server): evaluate price alerts per product and fix gold discharge rounding

Alerts now bind to a product_id and share one evaluator; displayed gold within a rounding step discharges the full wallet.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 22:23:20 +03:30
co-authored by Cursor
parent 341b2da2c9
commit b0296c6e94
10 changed files with 454 additions and 55 deletions
@@ -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)) {
+86 -10
View File
@@ -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');
@@ -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]);
}
}
+3 -39
View File
@@ -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();
}
}
+27
View File
@@ -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);
}
}
+180
View File
@@ -0,0 +1,180 @@
<?php
namespace App\Services;
use App\Http\Controllers\Controller;
use App\Mail\YourMailableClass;
use App\Models\Customer;
use App\Models\Price;
use App\Models\PriceTrigger;
use App\Models\Product;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class PriceTriggerService
{
public function evaluateActive(array $payload = []): void
{
$triggers = PriceTrigger::query()->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);
}
}
}
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('price_triggers')) {
return;
}
Schema::table('price_triggers', function (Blueprint $table) {
if (! Schema::hasColumn('price_triggers', 'product_id')) {
$table->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');
});
}
};
@@ -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,
@@ -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;
+32
View File
@@ -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';