diff --git a/app/Console/Commands/SuperAdminProgramSettingsRefreshCommand.php b/app/Console/Commands/SuperAdminProgramSettingsRefreshCommand.php new file mode 100644 index 0000000..37fd494 --- /dev/null +++ b/app/Console/Commands/SuperAdminProgramSettingsRefreshCommand.php @@ -0,0 +1,42 @@ +option('clear-only')) { + $overlay->flushCache(); + $this->line('cleared SuperAdmin program-settings cache'); + + return self::SUCCESS; + } + + $result = $overlay->refreshFromSuperAdmin(); + if (! ($result['ok'] ?? false)) { + $this->error('fetch from SuperAdmin failed — cache cleared; next HTTP request will retry'); + + return self::FAILURE; + } + + $this->info('fetched '.(int) ($result['count'] ?? 0).' setting(s) from SuperAdmin'); + if (($result['enable_automation'] ?? null) !== null) { + $this->line('enable_automation='.$result['enable_automation']); + } else { + $this->warn('enable_automation missing in SuperAdmin response'); + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php index 2f20ba1..007dfcc 100644 --- a/app/Http/Controllers/Admin/SettingsController.php +++ b/app/Http/Controllers/Admin/SettingsController.php @@ -78,6 +78,31 @@ class SettingsController extends Controller return response()->json($rows); } + /** + * پاک کردن کش overlay و خواندن دوباره از سوپرادمین + * (سوییچ‌هایی مثل enable_automation بعد از تغییر در پنل سوپرادمین). + */ + public function refreshSuperAdminOverlay() + { + $result = app(SuperAdminProgramSettingsOverlay::class)->refreshFromSuperAdmin(); + + if (! ($result['ok'] ?? false)) { + return response()->json([ + 'message' => 'ارتباط با سوپرادمین برقرار نشد؛ کش پاک شد و در درخواست بعدی دوباره تلاش می‌شود', + 'ok' => false, + 'count' => 0, + ], 502); + } + + return response()->json([ + 'message' => 'تنظیمات پیشرفته از سوپرادمین همگام شد', + 'ok' => true, + 'count' => (int) ($result['count'] ?? 0), + 'enable_automation' => $result['enable_automation'] ?? null, + 'source' => $result['source'] ?? 'superadmin', + ]); + } + public function edit(Request $request) { $key = trim((string) $request->key); diff --git a/app/Http/Controllers/Site/ReportController.php b/app/Http/Controllers/Site/ReportController.php index 18641f9..8c91234 100644 --- a/app/Http/Controllers/Site/ReportController.php +++ b/app/Http/Controllers/Site/ReportController.php @@ -174,6 +174,7 @@ class ReportController extends Controller $cached = $cache->get(); if ($cached !== null) { $cached['gold_ounce'] = $this->resolveGoldOuncePayload(); + $cached = $config->applyGlobalTradingHoursToPayload($cached); return $this->personalizeLastPriceForCustomer($cached); } diff --git a/app/Services/ConfigService.php b/app/Services/ConfigService.php index 979063a..4c4bf3c 100644 --- a/app/Services/ConfigService.php +++ b/app/Services/ConfigService.php @@ -638,6 +638,86 @@ class ConfigService ]; } + /** + * @return array{mode: 'uniform'|'per_day', days: array}|null + */ + public function tradingHoursByDay(): ?array + { + $raw = $this->getString('trading_hours_by_day', ''); + if ($raw === '') { + return null; + } + + try { + $parsed = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable) { + return null; + } + + if (! is_array($parsed) || ! isset($parsed['days']) || ! is_array($parsed['days'])) { + return null; + } + + $mode = ($parsed['mode'] ?? 'uniform') === 'per_day' ? 'per_day' : 'uniform'; + $defaults = $this->tradingHourBounds(); + $dayKeys = ['sat', 'sun', 'mon', 'tue', 'wed', 'thu', 'fri']; + $days = []; + + foreach ($dayKeys as $key) { + $row = is_array($parsed['days'][$key] ?? null) ? $parsed['days'][$key] : []; + $days[$key] = [ + 'enabled' => ($row['enabled'] ?? true) !== false, + 'start' => $this->normalizeTradingTime((string) ($row['start'] ?? $defaults['start']), $defaults['start']), + 'end' => $this->normalizeTradingTime((string) ($row['end'] ?? $defaults['end']), $defaults['end']), + ]; + } + + return [ + 'mode' => $mode, + 'days' => $days, + ]; + } + + private function weekdayKey(Carbon $now): string + { + return match ($now->dayOfWeek) { + Carbon::SATURDAY => 'sat', + Carbon::SUNDAY => 'sun', + Carbon::MONDAY => 'mon', + Carbon::TUESDAY => 'tue', + Carbon::WEDNESDAY => 'wed', + Carbon::THURSDAY => 'thu', + Carbon::FRIDAY => 'fri', + default => 'sat', + }; + } + + /** + * بازهٔ مؤثر ساعت کاری برای لحظهٔ فعلی. + * + * @return array{enabled: bool, start: string, end: string} + */ + public function effectiveTradingBounds(?Carbon $now = null): array + { + $now = $now ?? Carbon::now(); + $weekly = $this->tradingHoursByDay(); + + if ($weekly !== null && $weekly['mode'] === 'per_day') { + $day = $weekly['days'][$this->weekdayKey($now)] ?? null; + if ($day !== null) { + return $day; + } + } + + $bounds = $this->tradingHourBounds(); + + return [ + 'enabled' => true, + 'start' => $bounds['start'], + 'end' => $bounds['end'], + ]; + } + private function normalizeTradingTime(string $value, string $default): string { $value = trim($value); @@ -659,7 +739,12 @@ class ConfigService } $now = $now ?? Carbon::now(); - $bounds = $this->tradingHourBounds(); + $bounds = $this->effectiveTradingBounds($now); + + if (!$bounds['enabled']) { + return true; + } + [$startHour, $startMinute] = array_map('intval', explode(':', $bounds['start'])); [$endHour, $endMinute] = array_map('intval', explode(':', $bounds['end'])); @@ -669,6 +754,39 @@ class ConfigService return !$now->between($start, $end); } + /** + * گیت ساعت کاری روی payload کش/SSE — وقتی close_price روشن است. + * + * @param array $payload + * @return array + */ + public function applyGlobalTradingHoursToPayload(array $payload): array + { + if (!$this->isClosePriceEnabled() || !$this->isOutsideTradingHours()) { + return $payload; + } + + if ($this->shouldShowHomePriceWhenClosed()) { + $payload['closed'] = true; + $payload['status'] = 'closed'; + + return $payload; + } + + return [ + 'sell' => 0, + 'buy' => 0, + 'price_last' => 0, + 'price' => 0, + 'main_product' => (int) ($payload['main_product'] ?? 0), + 'montzar' => (int) ($payload['montzar'] ?? 0), + 'zaarbox' => (int) ($payload['zaarbox'] ?? 0), + 'closed' => true, + 'status' => 'closed', + 'time' => now(), + ]; + } + public function syncAppClosedFlag(?Carbon $now = null): bool { $closed = $this->isOutsideTradingHours($now); @@ -710,7 +828,13 @@ class ConfigService public function tradingClosedMessage(?Carbon $now = null): string { - $bounds = $this->tradingHourBounds(); + $now = $now ?? Carbon::now(); + $bounds = $this->effectiveTradingBounds($now); + + if (!$bounds['enabled']) { + return 'فروشگاه امروز تعطیل است.'; + } + $start = $this->formatTradingTimeForDisplay($bounds['start']); $end = $this->formatTradingTimeForDisplay($bounds['end']); diff --git a/app/Services/LivePriceCacheService.php b/app/Services/LivePriceCacheService.php index e66e9a8..5c61b9d 100644 --- a/app/Services/LivePriceCacheService.php +++ b/app/Services/LivePriceCacheService.php @@ -142,6 +142,8 @@ class LivePriceCacheService : ['sell' => $payload['sell'] ?? 0, 'buy' => $payload['buy'] ?? 0]; $payload['last_historical'] = $display->resolveHistoricalBasePrice(); + $payload = $config->applyGlobalTradingHoursToPayload($payload); + return app(ZaarboxPriceService::class)->attachGoldOunceToPayload($payload); } } diff --git a/app/Services/SuperAdminProgramSettingsClient.php b/app/Services/SuperAdminProgramSettingsClient.php index eb66b34..bf28fe6 100644 --- a/app/Services/SuperAdminProgramSettingsClient.php +++ b/app/Services/SuperAdminProgramSettingsClient.php @@ -8,8 +8,11 @@ use Illuminate\Support\Facades\Log; /** * خواندن تنظیمات پیشرفته از سوپرادمین (منبع حقیقت can_edit=0). * + * Public اول (دامنه‌محور، بدون وابستگی به HMAC): GET {origin}/api/public/program-settings?domain= * Full (با HMAC/Bearer): GET {origin}/api/remote/program-settings - * Public: GET {origin}/api/public/program-settings?domain= + * + * Public برای همین ۱۴ کلید مالکیت سوپرادمین کافی است؛ اگر remote اول باشد و replica عقب بماند، + * سوییچ‌هایی مثل enable_automation روی false کهنه می‌مانند. */ class SuperAdminProgramSettingsClient { @@ -21,6 +24,11 @@ class SuperAdminProgramSettingsClient public function fetchItems(): ?array { + $public = $this->fetchPublic(); + if ($public !== null) { + return $public; + } + if ($this->canAuth()) { $items = $this->fetchRemoteFull(); if ($items !== null) { @@ -28,7 +36,7 @@ class SuperAdminProgramSettingsClient } } - return $this->fetchPublic(); + return null; } private function canAuth(): bool @@ -79,31 +87,34 @@ class SuperAdminProgramSettingsClient */ private function fetchPublic(): ?array { - $domain = $this->domain(); - if ($domain === '') { + $domains = $this->domainCandidates(); + if ($domains === []) { return null; } - foreach ($this->attemptOrigins() as $origin) { - try { - $response = Http::acceptJson() - ->withHeaders(['Cache-Control' => 'no-store']) - ->timeout(6) - ->get($origin.self::PUBLIC_PATH, ['domain' => $domain]); + foreach ($domains as $domain) { + foreach ($this->attemptOrigins() as $origin) { + try { + $response = Http::acceptJson() + ->withHeaders(['Cache-Control' => 'no-store']) + ->timeout(6) + ->get($origin.self::PUBLIC_PATH, ['domain' => $domain]); - if (! $response->successful() || ! $this->isJsonResponse($response)) { - continue; - } + if (! $response->successful() || ! $this->isJsonResponse($response)) { + continue; + } - $items = $this->extractItems($response->json()); - if ($items !== null) { - return $items; + $items = $this->extractItems($response->json()); + if ($items !== null) { + return $items; + } + } catch (\Throwable $e) { + Log::warning('SuperAdmin public program-settings exception', [ + 'origin' => $origin, + 'domain' => $domain, + 'error' => $e->getMessage(), + ]); } - } catch (\Throwable $e) { - Log::warning('SuperAdmin public program-settings exception', [ - 'origin' => $origin, - 'error' => $e->getMessage(), - ]); } } @@ -189,7 +200,7 @@ class SuperAdminProgramSettingsClient { $secret = $this->sharedSecret(); if ($secret !== '') { - $domain = $this->domain(); + $domain = $this->domainCandidates()[0] ?? ''; $timestamp = (string) time(); $payload = $timestamp.'.'.strtoupper($method).'.'.$canonicalPath.'.'.$domain; $signature = hash_hmac('sha256', $payload, $secret); @@ -221,16 +232,50 @@ class SuperAdminProgramSettingsClient return trim((string) config('services.superadmin.remote_token', '')); } - private function domain(): string + /** + * دامنه‌های کاندید برای resolve نسخه در سوپرادمین. + * + * @return list + */ + private function domainCandidates(): array { + $candidates = []; + $override = trim((string) config('services.superadmin.sms_domain', '')); if ($override !== '') { - return $this->normalizeHost($override); + $candidates[] = $this->normalizeHost($override); } $fromApp = parse_url((string) config('app.url'), PHP_URL_HOST); + if (is_string($fromApp) && $fromApp !== '') { + $candidates[] = $this->normalizeHost($fromApp); + } - return $this->normalizeHost(is_string($fromApp) ? $fromApp : ''); + try { + $request = request(); + if ($request) { + $candidates[] = $this->normalizeHost((string) $request->getHost()); + $forwarded = (string) $request->headers->get('X-Forwarded-Host', ''); + if ($forwarded !== '') { + $candidates[] = $this->normalizeHost(explode(',', $forwarded)[0] ?? ''); + } + } + } catch (\Throwable) { + // CLI / بدون request + } + + $out = []; + foreach ($candidates as $host) { + if ($host === '' || in_array($host, $out, true)) { + continue; + } + if (in_array($host, ['localhost', '127.0.0.1', '::1'], true)) { + continue; + } + $out[] = $host; + } + + return $out; } private function normalizeHost(string $value): string diff --git a/app/Services/SuperAdminProgramSettingsOverlay.php b/app/Services/SuperAdminProgramSettingsOverlay.php index 01e76f2..ffebdcd 100644 --- a/app/Services/SuperAdminProgramSettingsOverlay.php +++ b/app/Services/SuperAdminProgramSettingsOverlay.php @@ -71,6 +71,46 @@ class SuperAdminProgramSettingsOverlay private static bool $loaded = false; + /** پاک کردن کش last-good و memo درخواست — برای دکمهٔ همگام‌سازی تنظیمات پیشرفته */ + public function flushCache(): void + { + self::$memo = null; + self::$loaded = false; + try { + Cache::forget(self::LAST_GOOD_CACHE_KEY); + } catch (\Throwable $e) { + // کش در دسترس نیست + } + } + + /** + * کش را پاک می‌کند و دوباره از سوپرادمین می‌خواند. + * + * @return array{ok: bool, count: int, enable_automation: string|null, source: string} + */ + public function refreshFromSuperAdmin(): array + { + $this->flushCache(); + $map = $this->catalogMap(); + if ($map === null) { + return [ + 'ok' => false, + 'count' => 0, + 'enable_automation' => null, + 'source' => 'unavailable', + ]; + } + + $auto = $map['enable_automation']['value'] ?? null; + + return [ + 'ok' => true, + 'count' => count($map), + 'enable_automation' => $auto === null || $auto === '' ? null : (string) $auto, + 'source' => 'superadmin', + ]; + } + /** * همهٔ کلیدهای سوپرادمین (حتی value خالی). null = سوپرادمین در دسترس نبود. * @@ -122,6 +162,11 @@ class SuperAdminProgramSettingsOverlay $this->rememberLastGood($items); } else { $items = $this->lastGoodItems(); + if ($items !== null) { + \Illuminate\Support\Facades\Log::warning('SuperAdmin program-settings using last-good cache', [ + 'count' => count($items), + ]); + } } if ($items === null) { diff --git a/routes/admin.php b/routes/admin.php index e3e455e..e3a38c1 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -41,6 +41,7 @@ Route::middleware('auth:user')->group(function (){ Route::get('get_settings',[SettingsController::class, 'index']); Route::post('get_settings',[SettingsController::class, 'edit']); Route::get('settings/table',[SettingsController::class, 'localTable']); + Route::post('settings/refresh-superadmin', [SettingsController::class, 'refreshSuperAdminOverlay']); //------------balence Route::post('getBalance', [DashboardController::class, 'getBalance']); diff --git a/tests/Unit/ConfigServiceTradingHoursTest.php b/tests/Unit/ConfigServiceTradingHoursTest.php new file mode 100644 index 0000000..ff585c0 --- /dev/null +++ b/tests/Unit/ConfigServiceTradingHoursTest.php @@ -0,0 +1,125 @@ + $values */ + private function cfg(array $values): ConfigService + { + $cfg = Mockery::mock(ConfigService::class)->makePartial(); + $cfg->shouldReceive('get')->andReturnUsing(function (string $key, $default = null) use ($values) { + return array_key_exists($key, $values) ? $values[$key] : $default; + }); + $cfg->shouldReceive('getString')->andReturnUsing(function (string $key, string $default = '') use ($values) { + $value = $values[$key] ?? $default; + + return trim((string) $value); + }); + $cfg->shouldReceive('getBool')->andReturnUsing(function (string $key, bool $default = false) use ($values) { + if (! array_key_exists($key, $values)) { + return $default; + } + + return filter_var($values[$key], FILTER_VALIDATE_BOOLEAN); + }); + + return $cfg; + } + + public function test_per_day_schedule_closes_before_global_end_day(): void + { + $weekly = json_encode([ + 'mode' => 'per_day', + 'days' => [ + 'sat' => ['enabled' => true, 'start' => '08:30:00', 'end' => '21:30:00'], + 'sun' => ['enabled' => true, 'start' => '08:30:00', 'end' => '21:30:00'], + 'mon' => ['enabled' => true, 'start' => '08:30:00', 'end' => '21:00:00'], + 'tue' => ['enabled' => true, 'start' => '08:30:00', 'end' => '21:30:00'], + 'wed' => ['enabled' => true, 'start' => '08:30:00', 'end' => '21:30:00'], + 'thu' => ['enabled' => true, 'start' => '08:30:00', 'end' => '21:30:00'], + 'fri' => ['enabled' => true, 'start' => '08:30:00', 'end' => '21:30:00'], + ], + ], JSON_UNESCAPED_UNICODE); + + $cfg = $this->cfg([ + 'close_price' => 'true', + 'startDay' => '08:30', + 'endDay' => '21:30', + 'trading_hours_by_day' => $weekly, + ]); + + $monday2115 = Carbon::create(2026, 8, 24, 21, 15, 0); + $monday2059 = Carbon::create(2026, 8, 24, 20, 59, 0); + + $this->assertTrue($cfg->isOutsideTradingHours($monday2115)); + $this->assertFalse($cfg->isOutsideTradingHours($monday2059)); + } + + public function test_disabled_day_is_closed_all_day(): void + { + $weekly = json_encode([ + 'mode' => 'per_day', + 'days' => [ + 'fri' => ['enabled' => false, 'start' => '08:30:00', 'end' => '21:30:00'], + ], + ], JSON_UNESCAPED_UNICODE); + + $cfg = $this->cfg([ + 'close_price' => 'true', + 'startDay' => '08:30', + 'endDay' => '21:30', + 'trading_hours_by_day' => $weekly, + ]); + + $fridayNoon = Carbon::create(2026, 8, 28, 12, 0, 0); + + $this->assertTrue($cfg->isOutsideTradingHours($fridayNoon)); + } + + public function test_apply_global_trading_hours_zeros_payload_when_closed(): void + { + $weekly = json_encode([ + 'mode' => 'per_day', + 'days' => [ + 'mon' => ['enabled' => true, 'start' => '08:30:00', 'end' => '21:00:00'], + ], + ], JSON_UNESCAPED_UNICODE); + + $cfg = $this->cfg([ + 'close_price' => 'true', + 'show_home_price_when_closed' => 'false', + 'startDay' => '08:30', + 'endDay' => '21:30', + 'trading_hours_by_day' => $weekly, + ]); + + $now = Carbon::create(2026, 8, 24, 21, 15, 0); + Carbon::setTestNow($now); + + $result = $cfg->applyGlobalTradingHoursToPayload([ + 'sell' => 100, + 'buy' => 110, + 'price' => 105, + 'closed' => false, + 'status' => 'open', + ]); + + $this->assertTrue($result['closed']); + $this->assertSame('closed', $result['status']); + $this->assertSame(0, $result['price']); + + Carbon::setTestNow(); + } +}