diff --git a/app/Http/Controllers/Admin/AutomationController.php b/app/Http/Controllers/Admin/AutomationController.php index 5590a86..3274197 100644 --- a/app/Http/Controllers/Admin/AutomationController.php +++ b/app/Http/Controllers/Admin/AutomationController.php @@ -8,6 +8,7 @@ use App\Models\Automation; use App\Services\Automation\AutomationCatalog; use App\Services\Automation\AutomationEngine; use App\Services\Automation\AutomationFingerprint; +use App\Services\ConfigService; use Illuminate\Http\Request; use Illuminate\Support\Str; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; @@ -16,6 +17,7 @@ class AutomationController extends Controller { public function table(Request $request) { + $this->assertFeatureEnabled(); $query = Automation::query(); TableEngine::create($query, $request->all()) ->initSearch(['name', 'code', 'title', 'body']) @@ -27,11 +29,14 @@ class AutomationController extends Controller public function catalog() { + $this->assertFeatureEnabled(); + return response()->json(app(AutomationCatalog::class)->toArray()); } public function preview(Request $request) { + $this->assertFeatureEnabled(); $conditions = array_values((array) $request->input('conditions', [])); $count = app(AutomationEngine::class)->previewCount($conditions); @@ -40,6 +45,7 @@ class AutomationController extends Controller public function store(Request $request) { + $this->assertFeatureEnabled(); $data = $this->validated($request); $data['created_by'] = auth()->id(); $row = Automation::create($data); @@ -49,6 +55,7 @@ class AutomationController extends Controller public function update(Request $request, $id) { + $this->assertFeatureEnabled(); $row = Automation::query()->findOrFail($id); $data = $this->validated($request, (int) $row->id); $row->fill($data)->save(); @@ -58,6 +65,7 @@ class AutomationController extends Controller public function toggle($id) { + $this->assertFeatureEnabled(); $row = Automation::query()->findOrFail($id); if (! $row->is_active) { $dup = $this->duplicateOf($row->logic_fingerprint, (int) $row->id, true); @@ -75,11 +83,19 @@ class AutomationController extends Controller public function destroy($id) { + $this->assertFeatureEnabled(); Automation::query()->findOrFail($id)->delete(); 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 { $name = trim((string) $request->input('name', '')); diff --git a/app/Http/Controllers/Site/ReportController.php b/app/Http/Controllers/Site/ReportController.php index 732451c..18641f9 100644 --- a/app/Http/Controllers/Site/ReportController.php +++ b/app/Http/Controllers/Site/ReportController.php @@ -341,7 +341,8 @@ class ReportController extends Controller $config->applyMontzarSyncFailure(); 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) { $priceValue = $zaarbox_price; $priceSource = 'zaarbox'; - } elseif ($priceProvider === 'montzar' && ($montzarLivePrice ?? 0) !== 0 && !$closed) { + } elseif ( + $priceProvider === 'montzar' + && ($montzarLivePrice ?? 0) !== 0 + && !$closed + && $config->isPlausibleQuotePrice((float) $montzarLivePrice) + ) { $priceValue = (int) $montzarLivePrice; $priceSource = 'montzar'; } elseif ($priceProvider === 'main_product' && ($pr_price ?? 0) !== 0 && !$closed) { @@ -581,13 +587,14 @@ class ReportController extends Controller $montzar = 0; $config->applyMontzarSyncFailure(); 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()]); } } - if ($montzarEnabled && $montzar !== 0) { + if ($montzarEnabled && $montzar !== 0 && $config->isPlausibleQuotePrice((float) $montzar)) { $priceValue = $montzar; $priceSource = 'montzar'; } else { diff --git a/app/Services/Automation/AutomationEngine.php b/app/Services/Automation/AutomationEngine.php index ccbd96e..16ae220 100644 --- a/app/Services/Automation/AutomationEngine.php +++ b/app/Services/Automation/AutomationEngine.php @@ -6,6 +6,7 @@ use App\Models\Automation; use App\Models\AutomationDelayedJob; use App\Models\Customer; use App\Models\Product; +use App\Services\ConfigService; use Carbon\Carbon; use Illuminate\Support\Facades\Log; @@ -19,12 +20,18 @@ class AutomationEngine public function runDue(): void { + if (! app(ConfigService::class)->isAutomationEnabled()) { + return; + } $this->runSchedule(); $this->processDelayedJobs(); } public function onCustomerEvent(string $event, int $customerId): void { + if (! app(ConfigService::class)->isAutomationEnabled()) { + return; + } $customer = Customer::query()->find($customerId); if (! $customer || (int) $customer->type !== 2 || $customer->is_archive) { return; @@ -52,6 +59,9 @@ class AutomationEngine public function evaluatePriceMove(array $payload = []): void { + if (! app(ConfigService::class)->isAutomationEnabled()) { + return; + } $rows = Automation::query() ->where('is_active', true) ->where('trigger_type', AutomationCatalog::TRIGGER_PRICE) diff --git a/app/Services/ConfigService.php b/app/Services/ConfigService.php index 13b8a14..979063a 100644 --- a/app/Services/ConfigService.php +++ b/app/Services/ConfigService.php @@ -14,11 +14,44 @@ class ConfigService { 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) { 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. * برای کلیدهای can_edit=0 همیشه از این مسیر (یا ConfigService) بخوانید — نه Setting::where مستقیم. @@ -281,7 +314,8 @@ class ConfigService * اولویت: * 1) price_provider (یا price_primary_source قدیمی) — فقط مقادیر معتبر * 2) main_product_price=true → main_product (legacy) - * 3) پیش‌فرض فایل: montzar + * 3) اگر zaarbox_price_key تنظیم شده → zaarbox (نه پیش‌فرض montzar) + * 4) پیش‌فرض فایل */ public function priceProvider(): string { @@ -296,6 +330,10 @@ class ConfigService return 'main_product'; } + if ($this->getString('zaarbox_price_key', '') !== '') { + return 'zaarbox'; + } + $default = strtolower((string) config('price_providers.price_provider', 'montzar')); return in_array($default, self::PRICE_PROVIDERS, true) ? $default : 'montzar'; @@ -473,6 +511,13 @@ class ConfigService $today = now()->toDateString(); $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) { $this->UpdateConfig('up_price', $priceInt); $this->UpdateConfig('down_price', $priceInt); @@ -480,9 +525,6 @@ class ConfigService return; } - $currentUp = (float) ($upSetting->value ?: 0); - $currentDown = (float) ($downSetting->value ?: 0); - if ($currentUp <= 0 || $priceInt > $currentUp) { $this->UpdateConfig('up_price', $priceInt); } @@ -533,8 +575,21 @@ class ConfigService [$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); - if ($down > 0 && $down < $storedDown) { + if ($down > 0 && $down < $storedDown && $this->isPlausibleQuotePrice((float) $down, (float) max($up, 1))) { $this->UpdateConfig('down_price', $down); } @@ -629,8 +684,9 @@ class ConfigService public function applyMontzarSyncFailure(): void { $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); } + /** سوییچ سوپرادمین اتوماسیون ادمین (پیش‌فرض خاموش). */ + public function isAutomationEnabled(): bool + { + return $this->getBool('enable_automation', false); + } + /** سوییچ دیپلوی قلک per-product (can_edit=0، پیش‌فرض خاموش). */ public function isProductFundsEnabled(): bool { diff --git a/app/Services/LivePriceCacheService.php b/app/Services/LivePriceCacheService.php index 0e2ee7e..e66e9a8 100644 --- a/app/Services/LivePriceCacheService.php +++ b/app/Services/LivePriceCacheService.php @@ -104,11 +104,14 @@ class LivePriceCacheService if ($provider === 'montzar') { $liveMontzar = (float) $config->get('montzar_live_price', 0); - if ($liveMontzar > 0) { + if ($liveMontzar > 0 && $config->isPlausibleQuotePrice($liveMontzar)) { $base = $liveMontzar; + $montzarSource = (int) $liveMontzar; + $priceSource = 'montzar'; + } else { + $montzarSource = 0; + $priceSource = 'database_last'; } - $montzarSource = (int) ($liveMontzar > 0 ? $liveMontzar : $base); - $priceSource = 'montzar'; } elseif ($provider === 'zaarbox') { $zaarboxSource = (int) $base; } elseif ($provider === 'main_product') { diff --git a/app/Services/SuperAdminProgramSettingsOverlay.php b/app/Services/SuperAdminProgramSettingsOverlay.php index a77d122..01e76f2 100644 --- a/app/Services/SuperAdminProgramSettingsOverlay.php +++ b/app/Services/SuperAdminProgramSettingsOverlay.php @@ -23,12 +23,14 @@ class SuperAdminProgramSettingsOverlay private const LAST_GOOD_CACHE_MINUTES = 1440; /** - * تنها کلیدهایی که مالکشان سوپرادمین است — گروه «دسترسی مشتری به قابلیت‌ها». - * هر کلید دیگری مالکش جدول settings همین نصب است و در overlay دور ریخته می‌شود. + * کلیدهایی که فقط سوپرادمین ویرایش می‌کند (پنل خانگی 403). + * این لیست فیلتر overlay نیست — برای load از blacklist زیر استفاده می‌شود + * تا مثلاً price_provider از سوپرادمین/کش last-good حذف نشود. */ public const SUPERADMIN_OWNED_KEYS = [ 'customer_direct_sms', 'customer_special_pricing', + 'enable_automation', 'enable_conditional_order', 'enable_customer_level_profit', 'enable_product_funds', @@ -42,7 +44,7 @@ class SuperAdminProgramSettingsOverlay 'show_to_users_chat', ]; - /** کلیدهایی که هنوز صفحهٔ اختصاصی ادمین خانگی مالک‌شان است */ + /** کلیدهایی که هنوز صفحهٔ اختصاصی ادمین خانگی مالک‌شان است — از overlay دور انداخته می‌شوند */ public const LOCAL_OWNED_KEYS = [ 'tracking_systems', ]; @@ -134,7 +136,9 @@ class SuperAdminProgramSettingsOverlay continue; } $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; } $map[$key] = $row; diff --git a/database/migrations/2026_08_24_000001_add_enable_automation_setting.php b/database/migrations/2026_08_24_000001_add_enable_automation_setting.php new file mode 100644 index 0000000..f0840e3 --- /dev/null +++ b/database/migrations/2026_08_24_000001_add_enable_automation_setting.php @@ -0,0 +1,49 @@ + 'اتوماسیون', + '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 + { + // کلید را حذف نمی‌کنیم + } +}; diff --git a/database/sql/deploy_settings_const.sql b/database/sql/deploy_settings_const.sql index 5a80b94..236848c 100644 --- a/database/sql/deploy_settings_const.sql +++ b/database/sql/deploy_settings_const.sql @@ -403,6 +403,13 @@ WHERE NOT EXISTS (SELECT 1 FROM `settings` WHERE `key` = 'charge_notif' LIMIT 1) -- 'switch' -- 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] -- INSERT INTO `settings` (`key`, `value`, `title_fa`, `can_edit`, `is_active`, `description`, `type`) -- 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', '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_conditional_order', 'enable_product_funds', + 'enable_conditional_order', 'enable_product_funds', 'enable_automation', 'sms_notif_order_admin', 'sms_notif_order_customer', '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', diff --git a/tests/Unit/QuotePricePlausibilityTest.php b/tests/Unit/QuotePricePlausibilityTest.php new file mode 100644 index 0000000..995f1f9 --- /dev/null +++ b/tests/Unit/QuotePricePlausibilityTest.php @@ -0,0 +1,36 @@ +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)); + } +}