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:
@@ -13,6 +13,9 @@ class PriceTriggerController extends Controller
|
|||||||
public function table(Request $request)
|
public function table(Request $request)
|
||||||
{
|
{
|
||||||
$query = PriceTrigger::query()->with('customer');
|
$query = PriceTrigger::query()->with('customer');
|
||||||
|
if (PriceTrigger::hasProductIdColumn()) {
|
||||||
|
$query->with('product:id,title');
|
||||||
|
}
|
||||||
|
|
||||||
TableEngine::create($query, $request->all())
|
TableEngine::create($query, $request->all())
|
||||||
->initSearch(['customer_id', 'target_price', 'action', 'price_condition'])
|
->initSearch(['customer_id', 'target_price', 'action', 'price_condition'])
|
||||||
@@ -31,7 +34,12 @@ class PriceTriggerController extends Controller
|
|||||||
'is_active' => $data['is_active'] ?? true,
|
'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)
|
public function update(Request $request, int $id)
|
||||||
@@ -71,12 +79,17 @@ class PriceTriggerController extends Controller
|
|||||||
{
|
{
|
||||||
$rules = [
|
$rules = [
|
||||||
'customer_id' => ($partial ? 'sometimes|' : '') . 'required|integer|exists:customers,id',
|
'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',
|
'target_price' => ($partial ? 'sometimes|' : '') . 'required|numeric|min:1',
|
||||||
'price_condition' => ($partial ? 'sometimes|' : '') . 'required|in:>,<',
|
'price_condition' => ($partial ? 'sometimes|' : '') . 'required|in:>,<',
|
||||||
'action' => ($partial ? 'sometimes|' : '') . 'required|in:sms,email',
|
'action' => ($partial ? 'sometimes|' : '') . 'required|in:sms,email',
|
||||||
'is_active' => 'sometimes|boolean',
|
'is_active' => 'sometimes|boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (! PriceTrigger::hasProductIdColumn()) {
|
||||||
|
unset($rules['product_id']);
|
||||||
|
}
|
||||||
|
|
||||||
$data = $request->validate($rules);
|
$data = $request->validate($rules);
|
||||||
|
|
||||||
if (isset($data['price_condition']) && !in_array($data['price_condition'], ['>', '<'], true)) {
|
if (isset($data['price_condition']) && !in_array($data['price_condition'], ['>', '<'], true)) {
|
||||||
|
|||||||
@@ -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)
|
public function charge2(Request $request)
|
||||||
{
|
{
|
||||||
$data = $request->all();
|
$data = $request->all();
|
||||||
@@ -271,20 +338,17 @@ class WalletController extends Controller
|
|||||||
$deposit = true;
|
$deposit = true;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
if (!$deposit) {
|
||||||
|
$this->consolidateOrphanedMainWalletGold((int) $customer->id, (int) $customer->type);
|
||||||
|
}
|
||||||
$wallet_interface = new WalletInterface($data['customer_id'], (int) $customer->type);
|
$wallet_interface = new WalletInterface($data['customer_id'], (int) $customer->type);
|
||||||
$wallet = $wallet_interface->wallet;
|
$wallet = $wallet_interface->wallet;
|
||||||
// کسر دستی نباید موجودی طلا را منفی کند؛ شارژ مثبت بدون هیچ شرطی باز است
|
// کسر دستی نباید موجودی طلا را منفی کند؛ شارژ مثبت بدون هیچ شرطی باز است
|
||||||
if (!$deposit) {
|
if (!$deposit) {
|
||||||
$available = is_null($wallet) ? 0.0 : (float) $wallet->gold;
|
$available = is_null($wallet) ? 0.0 : (float) $wallet->gold;
|
||||||
$requested = abs((float) $data['value']);
|
$requested = abs((float) $data['value']);
|
||||||
// ادمین عدد را از نمایش سهرقمی میخواند؛ اختلاف تا نصف پلهٔ نمایش یعنی «کل موجودی»
|
$resolved = $this->resolveGoldDischargeAmount($available, $requested, 'مقدار موجودی طلا کمتر از درخواست شماست');
|
||||||
if ($requested > $available && $requested - $available <= self::GOLD_DISPLAY_STEP) {
|
$data['value'] = -$resolved;
|
||||||
$requested = $available;
|
|
||||||
$data['value'] = -$available;
|
|
||||||
}
|
|
||||||
if ($requested <= 0 || $requested > $available) {
|
|
||||||
throw new UnprocessableEntityHttpException('مقدار موجودی طلا کمتر از درخواست شماست');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
$this->assertNotDuplicateCharge((int) $customer->id, (float) $data['value'], 'gold');
|
$this->assertNotDuplicateCharge((int) $customer->id, (float) $data['value'], 'gold');
|
||||||
$gold_block = 0;
|
$gold_block = 0;
|
||||||
@@ -489,6 +553,11 @@ class WalletController extends Controller
|
|||||||
throw new UnprocessableEntityHttpException('برای محصول تعدادی فقط عدد صحیح مجاز است');
|
throw new UnprocessableEntityHttpException('برای محصول تعدادی فقط عدد صحیح مجاز است');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$isMainWalletProduct = Controller::isMainWalletProduct($productId);
|
||||||
|
if (!$deposit && $isMainWalletProduct) {
|
||||||
|
$this->consolidateOrphanedMainWalletGold((int) $customer->id, (int) $customer->type);
|
||||||
|
}
|
||||||
|
|
||||||
$effectiveProductId = Controller::effectiveProductWalletId($productId);
|
$effectiveProductId = Controller::effectiveProductWalletId($productId);
|
||||||
$wallet_interface = new WalletInterface(
|
$wallet_interface = new WalletInterface(
|
||||||
(int) $data['customer_id'],
|
(int) $data['customer_id'],
|
||||||
@@ -498,8 +567,15 @@ class WalletController extends Controller
|
|||||||
);
|
);
|
||||||
$wallet = $wallet_interface->wallet;
|
$wallet = $wallet_interface->wallet;
|
||||||
|
|
||||||
if (!$deposit && $wallet !== null && (float) $wallet->gold < abs((float) $data['value'])) {
|
if (!$deposit) {
|
||||||
throw new UnprocessableEntityHttpException('مقدار موجودی کمتر از درخواست شماست');
|
$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');
|
$this->assertNotDuplicateCharge((int) $customer->id, (float) $data['value'], 'gold');
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
namespace App\Http\Controllers\Customer;
|
namespace App\Http\Controllers\Customer;
|
||||||
|
|
||||||
use App\Http\Controllers\SuperController;
|
use App\Http\Controllers\SuperController;
|
||||||
|
use App\Models\PriceTrigger;
|
||||||
|
use App\Models\Product;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Models\PriceTrigger; // اطمینان حاصل کنید که این مدل به درستی وارد شده است
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
class PriceTriggerController extends SuperController
|
class PriceTriggerController extends SuperController
|
||||||
{
|
{
|
||||||
@@ -24,10 +26,55 @@ class PriceTriggerController extends SuperController
|
|||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$data=PriceTrigger::where("customer_id",auth()->id())->get();
|
$query = PriceTrigger::where("customer_id", auth()->id());
|
||||||
return response()->json($data);
|
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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,17 @@ namespace App\Http\Controllers\Site;
|
|||||||
|
|
||||||
use App\Http\Controllers\Admin\PriceController;
|
use App\Http\Controllers\Admin\PriceController;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Mail\YourMailableClass;
|
|
||||||
use App\Models\Cnt;
|
use App\Models\Cnt;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\ConfigStatic;
|
use App\Models\ConfigStatic;
|
||||||
use App\Models\Customer;
|
|
||||||
use App\Services\MontzarService;
|
use App\Services\MontzarService;
|
||||||
use App\Services\ZaarboxPriceService;
|
use App\Services\ZaarboxPriceService;
|
||||||
use App\Models\Price;
|
use App\Models\Price;
|
||||||
use App\Models\PriceCandle;
|
use App\Models\PriceCandle;
|
||||||
use App\Models\PriceTrigger;
|
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Morilog\Jalali\Jalalian;
|
use Morilog\Jalali\Jalalian;
|
||||||
use App\Services\ConfigService;
|
use App\Services\ConfigService;
|
||||||
use App\Services\LivePriceCacheService;
|
use App\Services\LivePriceCacheService;
|
||||||
@@ -51,41 +47,7 @@ class ReportController extends Controller
|
|||||||
|
|
||||||
public function updatePrise()
|
public function updatePrise()
|
||||||
{
|
{
|
||||||
$config = app(ConfigService::class);
|
app(\App\Services\PriceTriggerService::class)->evaluateActive();
|
||||||
$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();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json();
|
return response()->json();
|
||||||
}
|
}
|
||||||
@@ -164,9 +126,11 @@ class ReportController extends Controller
|
|||||||
$pricePayload = $payload;
|
$pricePayload = $payload;
|
||||||
if (app()->runningInConsole()) {
|
if (app()->runningInConsole()) {
|
||||||
app(\App\Services\ConditionalOrderService::class)->evaluateFromLastPrice($pricePayload);
|
app(\App\Services\ConditionalOrderService::class)->evaluateFromLastPrice($pricePayload);
|
||||||
|
app(\App\Services\PriceTriggerService::class)->evaluateActive($pricePayload);
|
||||||
} else {
|
} else {
|
||||||
dispatch(function () use ($pricePayload) {
|
dispatch(function () use ($pricePayload) {
|
||||||
app(\App\Services\ConditionalOrderService::class)->evaluateFromLastPrice($pricePayload);
|
app(\App\Services\ConditionalOrderService::class)->evaluateFromLastPrice($pricePayload);
|
||||||
|
app(\App\Services\PriceTriggerService::class)->evaluateActive($pricePayload);
|
||||||
})->afterResponse();
|
})->afterResponse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ class PriceTrigger extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes;
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
private static ?bool $productIdColumnExists = null;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'customer_id',
|
'customer_id',
|
||||||
|
'product_id',
|
||||||
'target_price',
|
'target_price',
|
||||||
'price_condition',
|
'price_condition',
|
||||||
'action',
|
'action',
|
||||||
@@ -20,8 +23,32 @@ class PriceTrigger extends Model
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
"is_active" => "boolean",
|
"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()
|
public function customer()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Customer::class);
|
return $this->belongsTo(Customer::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function product()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Product::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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` (
|
CREATE TABLE `price_triggers` (
|
||||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||||
`customer_id` bigint unsigned NOT NULL,
|
`customer_id` bigint unsigned NOT NULL,
|
||||||
|
`product_id` bigint unsigned DEFAULT NULL,
|
||||||
`target_price` bigint NOT NULL,
|
`target_price` bigint NOT NULL,
|
||||||
`price_condition` varchar(50) CHARACTER SET latin1 COLLATE latin1_swedish_ci 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,
|
`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;
|
||||||
@@ -523,6 +523,38 @@ PREPARE stmt FROM @sql;
|
|||||||
EXECUTE stmt;
|
EXECUTE stmt;
|
||||||
DEALLOCATE PREPARE 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';
|
-- UPDATE `settings` SET `value` = 'کیلوگرم' WHERE `key` = 'unit';
|
||||||
|
|||||||
Reference in New Issue
Block a user