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();
}
}