feat(settings): enable_automation gate; reject stale montzar quote jumps

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 16:15:52 +03:30
co-authored by Cursor
parent b28a98938e
commit 352bb9976d
9 changed files with 212 additions and 18 deletions
@@ -8,6 +8,7 @@ use App\Models\Automation;
use App\Services\Automation\AutomationCatalog; use App\Services\Automation\AutomationCatalog;
use App\Services\Automation\AutomationEngine; use App\Services\Automation\AutomationEngine;
use App\Services\Automation\AutomationFingerprint; use App\Services\Automation\AutomationFingerprint;
use App\Services\ConfigService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
@@ -16,6 +17,7 @@ class AutomationController extends Controller
{ {
public function table(Request $request) public function table(Request $request)
{ {
$this->assertFeatureEnabled();
$query = Automation::query(); $query = Automation::query();
TableEngine::create($query, $request->all()) TableEngine::create($query, $request->all())
->initSearch(['name', 'code', 'title', 'body']) ->initSearch(['name', 'code', 'title', 'body'])
@@ -27,11 +29,14 @@ class AutomationController extends Controller
public function catalog() public function catalog()
{ {
$this->assertFeatureEnabled();
return response()->json(app(AutomationCatalog::class)->toArray()); return response()->json(app(AutomationCatalog::class)->toArray());
} }
public function preview(Request $request) public function preview(Request $request)
{ {
$this->assertFeatureEnabled();
$conditions = array_values((array) $request->input('conditions', [])); $conditions = array_values((array) $request->input('conditions', []));
$count = app(AutomationEngine::class)->previewCount($conditions); $count = app(AutomationEngine::class)->previewCount($conditions);
@@ -40,6 +45,7 @@ class AutomationController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
$this->assertFeatureEnabled();
$data = $this->validated($request); $data = $this->validated($request);
$data['created_by'] = auth()->id(); $data['created_by'] = auth()->id();
$row = Automation::create($data); $row = Automation::create($data);
@@ -49,6 +55,7 @@ class AutomationController extends Controller
public function update(Request $request, $id) public function update(Request $request, $id)
{ {
$this->assertFeatureEnabled();
$row = Automation::query()->findOrFail($id); $row = Automation::query()->findOrFail($id);
$data = $this->validated($request, (int) $row->id); $data = $this->validated($request, (int) $row->id);
$row->fill($data)->save(); $row->fill($data)->save();
@@ -58,6 +65,7 @@ class AutomationController extends Controller
public function toggle($id) public function toggle($id)
{ {
$this->assertFeatureEnabled();
$row = Automation::query()->findOrFail($id); $row = Automation::query()->findOrFail($id);
if (! $row->is_active) { if (! $row->is_active) {
$dup = $this->duplicateOf($row->logic_fingerprint, (int) $row->id, true); $dup = $this->duplicateOf($row->logic_fingerprint, (int) $row->id, true);
@@ -75,11 +83,19 @@ class AutomationController extends Controller
public function destroy($id) public function destroy($id)
{ {
$this->assertFeatureEnabled();
Automation::query()->findOrFail($id)->delete(); Automation::query()->findOrFail($id)->delete();
return response()->json(true); return response()->json(true);
} }
private function assertFeatureEnabled(): void
{
if (! app(ConfigService::class)->isAutomationEnabled()) {
throw new UnprocessableEntityHttpException('قابلیت اتوماسیون خاموش است.');
}
}
private function validated(Request $request, ?int $ignoreId = null): array private function validated(Request $request, ?int $ignoreId = null): array
{ {
$name = trim((string) $request->input('name', '')); $name = trim((string) $request->input('name', ''));
+11 -4
View File
@@ -341,7 +341,8 @@ class ReportController extends Controller
$config->applyMontzarSyncFailure(); $config->applyMontzarSyncFailure();
if (!$config->getBool('montzar_closed', true)) { if (!$config->getBool('montzar_closed', true)) {
$montzarLivePrice = $config->getInt('montzar_live_price', 0); $stale = $config->getInt('montzar_live_price', 0);
$montzarLivePrice = $config->isPlausibleQuotePrice((float) $stale) ? $stale : 0;
} }
} }
} }
@@ -392,7 +393,12 @@ class ReportController extends Controller
if ($priceProvider === 'zaarbox' && $zaarbox_price > 0 && !$closed) { if ($priceProvider === 'zaarbox' && $zaarbox_price > 0 && !$closed) {
$priceValue = $zaarbox_price; $priceValue = $zaarbox_price;
$priceSource = 'zaarbox'; $priceSource = 'zaarbox';
} elseif ($priceProvider === 'montzar' && ($montzarLivePrice ?? 0) !== 0 && !$closed) { } elseif (
$priceProvider === 'montzar'
&& ($montzarLivePrice ?? 0) !== 0
&& !$closed
&& $config->isPlausibleQuotePrice((float) $montzarLivePrice)
) {
$priceValue = (int) $montzarLivePrice; $priceValue = (int) $montzarLivePrice;
$priceSource = 'montzar'; $priceSource = 'montzar';
} elseif ($priceProvider === 'main_product' && ($pr_price ?? 0) !== 0 && !$closed) { } elseif ($priceProvider === 'main_product' && ($pr_price ?? 0) !== 0 && !$closed) {
@@ -581,13 +587,14 @@ class ReportController extends Controller
$montzar = 0; $montzar = 0;
$config->applyMontzarSyncFailure(); $config->applyMontzarSyncFailure();
if (!$config->getBool('montzar_closed', true)) { if (!$config->getBool('montzar_closed', true)) {
$montzar = $config->getInt('montzar_live_price', 0); $stale = $config->getInt('montzar_live_price', 0);
$montzar = $config->isPlausibleQuotePrice((float) $stale) ? $stale : 0;
} }
\Log::error('STORE MONZAR PRICE ERROR', ['message' => $e->getMessage()]); \Log::error('STORE MONZAR PRICE ERROR', ['message' => $e->getMessage()]);
} }
} }
if ($montzarEnabled && $montzar !== 0) { if ($montzarEnabled && $montzar !== 0 && $config->isPlausibleQuotePrice((float) $montzar)) {
$priceValue = $montzar; $priceValue = $montzar;
$priceSource = 'montzar'; $priceSource = 'montzar';
} else { } else {
@@ -6,6 +6,7 @@ use App\Models\Automation;
use App\Models\AutomationDelayedJob; use App\Models\AutomationDelayedJob;
use App\Models\Customer; use App\Models\Customer;
use App\Models\Product; use App\Models\Product;
use App\Services\ConfigService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -19,12 +20,18 @@ class AutomationEngine
public function runDue(): void public function runDue(): void
{ {
if (! app(ConfigService::class)->isAutomationEnabled()) {
return;
}
$this->runSchedule(); $this->runSchedule();
$this->processDelayedJobs(); $this->processDelayedJobs();
} }
public function onCustomerEvent(string $event, int $customerId): void public function onCustomerEvent(string $event, int $customerId): void
{ {
if (! app(ConfigService::class)->isAutomationEnabled()) {
return;
}
$customer = Customer::query()->find($customerId); $customer = Customer::query()->find($customerId);
if (! $customer || (int) $customer->type !== 2 || $customer->is_archive) { if (! $customer || (int) $customer->type !== 2 || $customer->is_archive) {
return; return;
@@ -52,6 +59,9 @@ class AutomationEngine
public function evaluatePriceMove(array $payload = []): void public function evaluatePriceMove(array $payload = []): void
{ {
if (! app(ConfigService::class)->isAutomationEnabled()) {
return;
}
$rows = Automation::query() $rows = Automation::query()
->where('is_active', true) ->where('is_active', true)
->where('trigger_type', AutomationCatalog::TRIGGER_PRICE) ->where('trigger_type', AutomationCatalog::TRIGGER_PRICE)
+68 -6
View File
@@ -14,11 +14,44 @@ class ConfigService
{ {
public const PRICE_PROVIDERS = ['montzar', 'main_product', 'zaarbox']; public const PRICE_PROVIDERS = ['montzar', 'main_product', 'zaarbox'];
/**
* حداکثر انحراف مجاز مظنه نسبت به آخرین قیمت معتبر DB.
* پرش اشتباه provider (مثل ۱۶۸M ماندهٔ montzar روی مظنهٔ ~۲۲۰M) را رد می‌کند.
*/
public const MAX_QUOTE_DEVIATION_RATIO = 0.15;
public function Config($name) public function Config($name)
{ {
return $this->get($name, false); return $this->get($name, false);
} }
/**
* آخرین قیمت ذخیره‌شده در جدول prices — مرجع سلامت مظنهٔ زنده.
*/
public function referenceQuotePrice(): float
{
$last = Price::query()->orderByDesc('id')->value('price');
return $last !== null ? (float) $last : 0.0;
}
/**
* مظنهٔ زنده وقتی خیلی از آخرین قیمت DB فاصله دارد قابل اتکا نیست (ماندهٔ provider اشتباه).
*/
public function isPlausibleQuotePrice(float $candidate, ?float $reference = null): bool
{
if ($candidate <= 0) {
return false;
}
$reference = $reference ?? $this->referenceQuotePrice();
if ($reference <= 0) {
return true;
}
return abs($candidate - $reference) / $reference <= self::MAX_QUOTE_DEVIATION_RATIO;
}
/** /**
* مقدار setting؛ اول سوپرادمین، بعد DB لوکال، بعد price_providers.php. * مقدار setting؛ اول سوپرادمین، بعد DB لوکال، بعد price_providers.php.
* برای کلیدهای can_edit=0 همیشه از این مسیر (یا ConfigService) بخوانید — نه Setting::where مستقیم. * برای کلیدهای can_edit=0 همیشه از این مسیر (یا ConfigService) بخوانید — نه Setting::where مستقیم.
@@ -281,7 +314,8 @@ class ConfigService
* اولویت: * اولویت:
* 1) price_provider (یا price_primary_source قدیمی) — فقط مقادیر معتبر * 1) price_provider (یا price_primary_source قدیمی) — فقط مقادیر معتبر
* 2) main_product_price=true → main_product (legacy) * 2) main_product_price=true → main_product (legacy)
* 3) پیش‌فرض فایل: montzar * 3) اگر zaarbox_price_key تنظیم شده → zaarbox (نه پیش‌فرض montzar)
* 4) پیش‌فرض فایل
*/ */
public function priceProvider(): string public function priceProvider(): string
{ {
@@ -296,6 +330,10 @@ class ConfigService
return 'main_product'; return 'main_product';
} }
if ($this->getString('zaarbox_price_key', '') !== '') {
return 'zaarbox';
}
$default = strtolower((string) config('price_providers.price_provider', 'montzar')); $default = strtolower((string) config('price_providers.price_provider', 'montzar'));
return in_array($default, self::PRICE_PROVIDERS, true) ? $default : 'montzar'; return in_array($default, self::PRICE_PROVIDERS, true) ? $default : 'montzar';
@@ -473,6 +511,13 @@ class ConfigService
$today = now()->toDateString(); $today = now()->toDateString();
$extremeDate = $this->Config('price_extreme_date'); $extremeDate = $this->Config('price_extreme_date');
$currentUp = (float) ($upSetting->value ?: 0);
$currentDown = (float) ($downSetting->value ?: 0);
$anchor = $currentUp > 0 ? $currentUp : $this->referenceQuotePrice();
if ($anchor > 0 && ! $this->isPlausibleQuotePrice((float) $priceInt, $anchor)) {
return;
}
if ($extremeDate !== $today) { if ($extremeDate !== $today) {
$this->UpdateConfig('up_price', $priceInt); $this->UpdateConfig('up_price', $priceInt);
$this->UpdateConfig('down_price', $priceInt); $this->UpdateConfig('down_price', $priceInt);
@@ -480,9 +525,6 @@ class ConfigService
return; return;
} }
$currentUp = (float) ($upSetting->value ?: 0);
$currentDown = (float) ($downSetting->value ?: 0);
if ($currentUp <= 0 || $priceInt > $currentUp) { if ($currentUp <= 0 || $priceInt > $currentUp) {
$this->UpdateConfig('up_price', $priceInt); $this->UpdateConfig('up_price', $priceInt);
} }
@@ -533,8 +575,21 @@ class ConfigService
[$up, $down] = [$down, $up]; [$up, $down] = [$down, $up];
} }
// کف روز آلوده به مظنهٔ اشتباه provider (مثلاً ۱۶۸M) را دور بریز
if ($up > 0 && $down > 0 && ! $this->isPlausibleQuotePrice((float) $down, (float) $up)) {
$fromTable = $this->filteredTodayMinMax();
if ($fromTable['min'] > 0 && $this->isPlausibleQuotePrice((float) $fromTable['min'], (float) $up)) {
$down = $fromTable['min'];
} else {
$down = $up;
}
if ($this->getInt('down_price', 0) !== $down) {
$this->UpdateConfig('down_price', $down);
}
}
$storedDown = $this->getInt('down_price', 0); $storedDown = $this->getInt('down_price', 0);
if ($down > 0 && $down < $storedDown) { if ($down > 0 && $down < $storedDown && $this->isPlausibleQuotePrice((float) $down, (float) max($up, 1))) {
$this->UpdateConfig('down_price', $down); $this->UpdateConfig('down_price', $down);
} }
@@ -629,8 +684,9 @@ class ConfigService
public function applyMontzarSyncFailure(): void public function applyMontzarSyncFailure(): void
{ {
$staleLive = $this->getInt('montzar_live_price', 0); $staleLive = $this->getInt('montzar_live_price', 0);
$usable = $staleLive > 0 && $this->isPlausibleQuotePrice((float) $staleLive);
$this->UpdateConfig('montzar_closed', $staleLive > 0 ? 'false' : 'true'); $this->UpdateConfig('montzar_closed', $usable ? 'false' : 'true');
} }
/** /**
@@ -817,6 +873,12 @@ class ConfigService
return $this->getBool('enable_conditional_order', false); return $this->getBool('enable_conditional_order', false);
} }
/** سوییچ سوپرادمین اتوماسیون ادمین (پیش‌فرض خاموش). */
public function isAutomationEnabled(): bool
{
return $this->getBool('enable_automation', false);
}
/** سوییچ دیپلوی قلک per-product (can_edit=0، پیش‌فرض خاموش). */ /** سوییچ دیپلوی قلک per-product (can_edit=0، پیش‌فرض خاموش). */
public function isProductFundsEnabled(): bool public function isProductFundsEnabled(): bool
{ {
+6 -3
View File
@@ -104,11 +104,14 @@ class LivePriceCacheService
if ($provider === 'montzar') { if ($provider === 'montzar') {
$liveMontzar = (float) $config->get('montzar_live_price', 0); $liveMontzar = (float) $config->get('montzar_live_price', 0);
if ($liveMontzar > 0) { if ($liveMontzar > 0 && $config->isPlausibleQuotePrice($liveMontzar)) {
$base = $liveMontzar; $base = $liveMontzar;
} $montzarSource = (int) $liveMontzar;
$montzarSource = (int) ($liveMontzar > 0 ? $liveMontzar : $base);
$priceSource = 'montzar'; $priceSource = 'montzar';
} else {
$montzarSource = 0;
$priceSource = 'database_last';
}
} elseif ($provider === 'zaarbox') { } elseif ($provider === 'zaarbox') {
$zaarboxSource = (int) $base; $zaarboxSource = (int) $base;
} elseif ($provider === 'main_product') { } elseif ($provider === 'main_product') {
@@ -23,12 +23,14 @@ class SuperAdminProgramSettingsOverlay
private const LAST_GOOD_CACHE_MINUTES = 1440; private const LAST_GOOD_CACHE_MINUTES = 1440;
/** /**
* تنها کلیدهایی که مالکشان سوپرادمین است — گروه «دسترسی مشتری به قابلیت‌ها». * کلیدهایی که فقط سوپرادمین ویرایش می‌کند (پنل خانگی 403).
* هر کلید دیگری مالکش جدول settings همین نصب است و در overlay دور ریخته می‌شود. * این لیست فیلتر overlay نیست — برای load از blacklist زیر استفاده می‌شود
* تا مثلاً price_provider از سوپرادمین/کش last-good حذف نشود.
*/ */
public const SUPERADMIN_OWNED_KEYS = [ public const SUPERADMIN_OWNED_KEYS = [
'customer_direct_sms', 'customer_direct_sms',
'customer_special_pricing', 'customer_special_pricing',
'enable_automation',
'enable_conditional_order', 'enable_conditional_order',
'enable_customer_level_profit', 'enable_customer_level_profit',
'enable_product_funds', 'enable_product_funds',
@@ -42,7 +44,7 @@ class SuperAdminProgramSettingsOverlay
'show_to_users_chat', 'show_to_users_chat',
]; ];
/** کلیدهایی که هنوز صفحهٔ اختصاصی ادمین خانگی مالک‌شان است */ /** کلیدهایی که هنوز صفحهٔ اختصاصی ادمین خانگی مالک‌شان است — از overlay دور انداخته می‌شوند */
public const LOCAL_OWNED_KEYS = [ public const LOCAL_OWNED_KEYS = [
'tracking_systems', 'tracking_systems',
]; ];
@@ -134,7 +136,9 @@ class SuperAdminProgramSettingsOverlay
continue; continue;
} }
$key = trim((string) ($row['key'] ?? '')); $key = trim((string) ($row['key'] ?? ''));
if ($key === '' || ! self::isSuperAdminOwned($key)) { // blacklist: فقط کلیدهای لوکال/runtime را دور بریز — نه whitelist قابلیت‌ها
// (whitelist قبلی price_provider را حذف می‌کرد و با قطعی SA به montzar لوکال می‌پرید)
if ($key === '' || self::isLocalOwned($key) || self::isRuntime($key)) {
continue; continue;
} }
$map[$key] = $row; $map[$key] = $row;
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* اتوماسیون ادمین — پیش‌فرض false، can_edit=0 (مالک سوپرادمین).
* value ردیف موجود را تغییر نمی‌دهد.
*/
return new class extends Migration
{
public function up(): void
{
$key = 'enable_automation';
$meta = [
'title_fa' => 'اتوماسیون',
'description' => 'روشن: منوی اتوماسیون در پنل ادمین و اجرای موتور فعال است. خاموش: منو مخفی است و API/کران اجرا نمی‌شود. فقط از سوپرادمین قابل تغییر است.',
'type' => 'switch',
];
$exists = DB::table('settings')->where('key', $key)->exists();
if (! $exists) {
DB::table('settings')->insert([
'key' => $key,
'value' => 'false',
'title_fa' => $meta['title_fa'],
'can_edit' => 0,
'is_active' => 1,
'description' => $meta['description'],
'type' => $meta['type'],
]);
return;
}
DB::table('settings')->where('key', $key)->update([
'title_fa' => $meta['title_fa'],
'description' => $meta['description'],
'type' => $meta['type'],
'can_edit' => 0,
'is_active' => 1,
]);
}
public function down(): void
{
// کلید را حذف نمی‌کنیم
}
};
+8 -1
View File
@@ -403,6 +403,13 @@ WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'charge_notif' LIMIT 1)
-- 'switch' -- 'switch'
-- WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'customer_direct_sms' LIMIT 1); -- WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'customer_direct_sms' LIMIT 1);
-- [super-admin can_edit=0]
-- INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`)
-- SELECT 'enable_automation', 'false', 'اتوماسیون', 0, 1,
-- 'روشن: منوی اتوماسیون در پنل ادمین و اجرای موتور فعال است. خاموش: منو مخفی است و API/کران اجرا نمی‌شود. فقط از سوپرادمین قابل تغییر است.',
-- 'switch'
-- WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'enable_automation' LIMIT 1);
-- [super-admin can_edit=0] -- [super-admin can_edit=0]
-- INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) -- INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`)
-- SELECT 'enable_conditional_order', 'false', 'سفارش شرطی', 0, 1, -- SELECT 'enable_conditional_order', 'false', 'سفارش شرطی', 0, 1,
@@ -1257,7 +1264,7 @@ UPDATE `settings` SET `type` = 'switch' WHERE `key` IN (
'profit_gold_api_enabled', 'payping_round_toman', 'payping_strict_failed_withdraw', 'profit_gold_api_enabled', 'payping_round_toman', 'payping_strict_failed_withdraw',
'has_page_earn_money', 'has_page_price_trigger', 'has_page_notification', 'has_page_gift_card', 'has_page_earn_money', 'has_page_price_trigger', 'has_page_notification', 'has_page_gift_card',
'enable_customer_level_profit', 'charge_notif', 'group_sms', 'customer_special_pricing', 'sms_panel', 'customer_direct_sms', 'enable_customer_level_profit', 'charge_notif', 'group_sms', 'customer_special_pricing', 'sms_panel', 'customer_direct_sms',
'enable_conditional_order', 'enable_product_funds', 'enable_conditional_order', 'enable_product_funds', 'enable_automation',
'sms_notif_order_admin', 'sms_notif_order_customer', 'sms_notif_order_admin', 'sms_notif_order_customer',
'sms_notif_withdraw_admin', 'sms_notif_withdraw_otp', 'sms_notif_login_otp', 'sms_notif_withdraw_admin', 'sms_notif_withdraw_otp', 'sms_notif_login_otp',
'sms_notif_gateway_charge_admin', 'sms_notif_register_customer', 'sms_notif_register_admin', 'sms_notif_gateway_charge_admin', 'sms_notif_register_customer', 'sms_notif_register_admin',
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Tests\Unit;
use App\Services\ConfigService;
use PHPUnit\Framework\TestCase;
class QuotePricePlausibilityTest extends TestCase
{
public function test_rejects_stale_montzar_jump_against_reference(): void
{
$config = new class extends ConfigService {
public function referenceQuotePrice(): float
{
return 223_260_000.0;
}
};
$this->assertFalse($config->isPlausibleQuotePrice(168_336_410.0));
$this->assertTrue($config->isPlausibleQuotePrice(222_010_000.0));
$this->assertTrue($config->isPlausibleQuotePrice(223_260_000.0));
}
public function test_accepts_any_positive_when_no_reference(): void
{
$config = new class extends ConfigService {
public function referenceQuotePrice(): float
{
return 0.0;
}
};
$this->assertTrue($config->isPlausibleQuotePrice(168_336_410.0));
$this->assertFalse($config->isPlausibleQuotePrice(0.0));
}
}