From 1eb8d2cb0165faaca5b746b52c5d83d628a81201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 13 Mar 2026 22:20:52 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[demo]=20=EB=8D=B0=EB=AA=A8=20=ED=85=8C?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=9A=B4=EC=98=81=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=ED=99=94=20(Phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DemoLimitMiddleware: 쇼케이스 읽기전용, 만료 체크, 외부연동 차단 - DemoTenantService: 파트너/체험 테넌트 생성, 기간 연장, 정식 전환 - ResetDemoShowcaseCommand: 매일 자정 데이터 리셋 + 샘플 재시드 - ManufacturingPresetSeeder: 부서/거래처/품목/견적/수주 샘플 데이터 - 스케줄러 등록 (00:00 demo:reset-showcase --seed) - 미들웨어 별칭 등록 (demo.limit) --- .../Commands/ResetDemoShowcaseCommand.php | 174 ++++++++++++ app/Http/Middleware/DemoLimitMiddleware.php | 93 +++++++ app/Services/Demo/DemoTenantService.php | 167 ++++++++++++ bootstrap/app.php | 1 + .../Demo/ManufacturingPresetSeeder.php | 248 ++++++++++++++++++ routes/console.php | 13 + 6 files changed, 696 insertions(+) create mode 100644 app/Console/Commands/ResetDemoShowcaseCommand.php create mode 100644 app/Http/Middleware/DemoLimitMiddleware.php create mode 100644 app/Services/Demo/DemoTenantService.php create mode 100644 database/seeders/Demo/ManufacturingPresetSeeder.php diff --git a/app/Console/Commands/ResetDemoShowcaseCommand.php b/app/Console/Commands/ResetDemoShowcaseCommand.php new file mode 100644 index 0000000..56ba719 --- /dev/null +++ b/app/Console/Commands/ResetDemoShowcaseCommand.php @@ -0,0 +1,174 @@ +where('tenant_type', Tenant::TYPE_DEMO_SHOWCASE) + ->get(); + + if ($showcases->isEmpty()) { + $this->info('데모 쇼케이스 테넌트가 없습니다.'); + + return self::SUCCESS; + } + + foreach ($showcases as $tenant) { + $this->info("리셋 대상: [{$tenant->id}] {$tenant->company_name}"); + + if ($this->option('dry-run')) { + $this->showStats($tenant); + + continue; + } + + $this->resetTenantData($tenant); + + if ($this->option('seed')) { + $this->seedSampleData($tenant); + } + } + + return self::SUCCESS; + } + + private function showStats(Tenant $tenant): void + { + foreach (self::RESET_TABLES as $table) { + if (! \Schema::hasTable($table)) { + continue; + } + + if (! \Schema::hasColumn($table, 'tenant_id')) { + continue; + } + + $count = DB::table($table)->where('tenant_id', $tenant->id)->count(); + if ($count > 0) { + $this->line(" - {$table}: {$count}건"); + } + } + } + + private function resetTenantData(Tenant $tenant): void + { + $totalDeleted = 0; + + DB::beginTransaction(); + try { + foreach (self::RESET_TABLES as $table) { + if (! \Schema::hasTable($table)) { + continue; + } + + if (! \Schema::hasColumn($table, 'tenant_id')) { + continue; + } + + $deleted = DB::table($table)->where('tenant_id', $tenant->id)->delete(); + if ($deleted > 0) { + $this->line(" 삭제: {$table} → {$deleted}건"); + $totalDeleted += $deleted; + } + } + + DB::commit(); + $this->info(" 총 {$totalDeleted}건 삭제 완료"); + + Log::info('데모 쇼케이스 리셋 완료', [ + 'tenant_id' => $tenant->id, + 'deleted_count' => $totalDeleted, + ]); + } catch (\Exception $e) { + DB::rollBack(); + $this->error(" 리셋 실패: {$e->getMessage()}"); + Log::error('데모 쇼케이스 리셋 실패', [ + 'tenant_id' => $tenant->id, + 'error' => $e->getMessage(), + ]); + + return; + } + } + + private function seedSampleData(Tenant $tenant): void + { + $preset = $tenant->getDemoPreset() ?? 'manufacturing'; + $this->info(" 샘플 데이터 시드: {$preset}"); + + try { + $seeder = new \Database\Seeders\Demo\ManufacturingPresetSeeder; + $seeder->run($tenant->id); + $this->info(' 샘플 데이터 시드 완료'); + } catch (\Exception $e) { + $this->error(" 시드 실패: {$e->getMessage()}"); + Log::error('데모 샘플 데이터 시드 실패', [ + 'tenant_id' => $tenant->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Http/Middleware/DemoLimitMiddleware.php b/app/Http/Middleware/DemoLimitMiddleware.php new file mode 100644 index 0000000..9615e54 --- /dev/null +++ b/app/Http/Middleware/DemoLimitMiddleware.php @@ -0,0 +1,93 @@ +find($tenantId); + if (! $tenant || ! $tenant->isDemoTenant()) { + // 프로덕션 테넌트 → 즉시 통과 (기존 동작 유지) + return $next($request); + } + + // 1. 만료 체크 (파트너 데모, 고객 체험) + if ($tenant->isDemoExpired()) { + return response()->json([ + 'success' => false, + 'message' => '체험 기간이 만료되었습니다. 정식 계약을 진행해 주세요.', + 'error_code' => 'DEMO_EXPIRED', + ], 403); + } + + // 2. 쇼케이스 → 읽기 전용 (GET, HEAD, OPTIONS만 허용) + if ($tenant->isDemoShowcase() && ! $request->isMethodSafe()) { + return response()->json([ + 'success' => false, + 'message' => '데모 환경에서는 조회만 가능합니다.', + 'error_code' => 'DEMO_READ_ONLY', + ], 403); + } + + // 3. 읽기전용 옵션이 설정된 데모 테넌트 + if ($tenant->isDemoReadOnly() && ! $request->isMethodSafe()) { + return response()->json([ + 'success' => false, + 'message' => '데모 환경에서는 조회만 가능합니다.', + 'error_code' => 'DEMO_READ_ONLY', + ], 403); + } + + // 4. 차단 기능 체크 (바로빌, 이카운트 등 외부 연동) + if ($this->isBlockedRoute($request)) { + return response()->json([ + 'success' => false, + 'message' => '데모 환경에서 사용할 수 없는 기능입니다. 정식 계약 후 이용 가능합니다.', + 'error_code' => 'DEMO_FEATURE_BLOCKED', + ], 403); + } + + return $next($request); + } + + private function isBlockedRoute(Request $request): bool + { + $path = $request->path(); + foreach (self::BLOCKED_ROUTE_PREFIXES as $prefix) { + if (str_starts_with($path, $prefix)) { + return true; + } + } + + return false; + } +} diff --git a/app/Services/Demo/DemoTenantService.php b/app/Services/Demo/DemoTenantService.php new file mode 100644 index 0000000..24f32de --- /dev/null +++ b/app/Services/Demo/DemoTenantService.php @@ -0,0 +1,167 @@ + 100, + 'max_orders' => 50, + 'max_productions' => 30, + 'max_users' => 5, + 'max_storage_gb' => 1, + 'max_ai_tokens' => 100000, + ]; + + /** + * 사용 가능한 프리셋 목록 + */ + private const AVAILABLE_PRESETS = [ + 'manufacturing', // 제조업 기본 + // 향후 추가 예정: + // 'blinds', // 블라인드/스크린 + // 'construction', // 시공/건설 + // 'distribution', // 유통/도소매 + ]; + + /** + * 파트너 데모 테넌트 생성 (Tier 2) + * 파트너 승인 시 호출 + */ + public function createPartnerDemo(int $partnerId, string $preset = 'manufacturing'): Tenant + { + $tenant = new Tenant; + $tenant->forceFill([ + 'company_name' => '파트너데모_'.$partnerId, + 'code' => 'DEMO_P_'.$partnerId, + 'email' => 'demo-partner-'.$partnerId.'@codebridge-x.com', + 'tenant_st_code' => 'active', + 'tenant_type' => Tenant::TYPE_DEMO_PARTNER, + 'demo_source_partner_id' => $partnerId, + 'options' => [ + Tenant::OPTION_DEMO_PRESET => $preset, + Tenant::OPTION_DEMO_LIMITS => self::DEFAULT_DEMO_LIMITS, + ], + ]); + $tenant->save(); + + Log::info('파트너 데모 테넌트 생성', [ + 'tenant_id' => $tenant->id, + 'partner_id' => $partnerId, + 'preset' => $preset, + ]); + + return $tenant; + } + + /** + * 고객 체험 테넌트 생성 (Tier 3) + * 파트너가 요청 → 본사 승인 후 호출 + */ + public function createTrialDemo( + int $partnerId, + string $companyName, + string $email, + int $durationDays = 30, + string $preset = 'manufacturing' + ): Tenant { + $tenant = new Tenant; + $tenant->forceFill([ + 'company_name' => $companyName, + 'code' => 'DEMO_T_'.strtoupper(substr(md5(uniqid()), 0, 8)), + 'email' => $email, + 'tenant_st_code' => 'trial', + 'tenant_type' => Tenant::TYPE_DEMO_TRIAL, + 'demo_expires_at' => now()->addDays($durationDays), + 'demo_source_partner_id' => $partnerId, + 'options' => [ + Tenant::OPTION_DEMO_PRESET => $preset, + Tenant::OPTION_DEMO_LIMITS => self::DEFAULT_DEMO_LIMITS, + ], + ]); + $tenant->save(); + + Log::info('고객 체험 테넌트 생성', [ + 'tenant_id' => $tenant->id, + 'partner_id' => $partnerId, + 'company_name' => $companyName, + 'expires_at' => $tenant->demo_expires_at->toDateString(), + ]); + + return $tenant; + } + + /** + * 체험 기간 연장 (최대 1회, 30일) + */ + public function extendTrial(Tenant $tenant, int $days = 30): bool + { + if (! $tenant->isDemoTrial()) { + return false; + } + + // 이미 연장한 이력이 있는지 체크 (options에 기록) + if ($tenant->getOption('demo_extended', false)) { + return false; + } + + $newExpiry = ($tenant->demo_expires_at ?? now())->addDays($days); + $tenant->forceFill(['demo_expires_at' => $newExpiry]); + $tenant->setOption('demo_extended', true); + $tenant->save(); + + Log::info('고객 체험 기간 연장', [ + 'tenant_id' => $tenant->id, + 'new_expires_at' => $newExpiry->toDateString(), + ]); + + return true; + } + + /** + * 데모 → 정식 전환 + */ + public function convertToProduction(Tenant $tenant): void + { + $tenant->convertToProduction(); + + Log::info('데모 → 정식 테넌트 전환', [ + 'tenant_id' => $tenant->id, + 'company_name' => $tenant->company_name, + ]); + } + + /** + * 사용 가능한 프리셋 목록 + */ + public function getAvailablePresets(): array + { + return self::AVAILABLE_PRESETS; + } + + /** + * 만료된 체험 테넌트 조회 + */ + public function getExpiredTrials(): \Illuminate\Database\Eloquent\Collection + { + return Tenant::withoutGlobalScopes() + ->where('tenant_type', Tenant::TYPE_DEMO_TRIAL) + ->whereNotNull('demo_expires_at') + ->where('demo_expires_at', '<', now()) + ->get(); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index bded977..5e103a4 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -40,6 +40,7 @@ 'perm.map' => PermMapper::class, // 전처리: 라우트명 → perm 키 생성/주입 'permission' => CheckPermission::class, // 검사: perm 키로 접근 허용/차단 'log.api' => LogApiRequest::class, // API 요청/응답 로깅 (선택적 사용용) + 'demo.limit' => \App\Http\Middleware\DemoLimitMiddleware::class, // 데모 테넌트 제한 ]); }) ->withExceptions(function (Exceptions $exceptions) { diff --git a/database/seeders/Demo/ManufacturingPresetSeeder.php b/database/seeders/Demo/ManufacturingPresetSeeder.php new file mode 100644 index 0000000..8c50314 --- /dev/null +++ b/database/seeders/Demo/ManufacturingPresetSeeder.php @@ -0,0 +1,248 @@ +seedDepartments($tenantId, $now); + + // 2. 거래처 + $clientIds = $this->seedClients($tenantId, $now); + + // 3. 품목 (원자재, 반제품, 완제품) + $this->seedItems($tenantId, $now); + + // 4. 견적 + $this->seedQuotes($tenantId, $clientIds, $now); + + // 5. 수주 + $this->seedOrders($tenantId, $clientIds, $now); + } + + private function seedDepartments(int $tenantId, $now): void + { + $parentId = DB::table('departments')->insertGetId([ + 'tenant_id' => $tenantId, + 'name' => '데모제조(주)', + 'parent_id' => null, + 'is_active' => true, + 'sort_order' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $departments = [ + ['name' => '경영지원팀', 'sort_order' => 1], + ['name' => '영업팀', 'sort_order' => 2], + ['name' => '생산팀', 'sort_order' => 3], + ['name' => '품질관리팀', 'sort_order' => 4], + ['name' => '자재팀', 'sort_order' => 5], + ]; + + foreach ($departments as $dept) { + DB::table('departments')->insert([ + 'tenant_id' => $tenantId, + 'name' => $dept['name'], + 'parent_id' => $parentId, + 'is_active' => true, + 'sort_order' => $dept['sort_order'], + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + private function seedClients(int $tenantId, $now): array + { + $clients = [ + ['code' => 'C-DEMO-001', 'name' => '(주)한국철강', 'type' => 'SUPPLIER', 'business_no' => '123-45-67890', 'phone' => '02-1234-5678'], + ['code' => 'C-DEMO-002', 'name' => '삼성전자(주)', 'type' => 'CUSTOMER', 'business_no' => '234-56-78901', 'phone' => '02-2345-6789'], + ['code' => 'C-DEMO-003', 'name' => '(주)대한물산', 'type' => 'CUSTOMER', 'business_no' => '345-67-89012', 'phone' => '02-3456-7890'], + ['code' => 'C-DEMO-004', 'name' => '현대부품(주)', 'type' => 'SUPPLIER', 'business_no' => '456-78-90123', 'phone' => '031-4567-8901'], + ['code' => 'C-DEMO-005', 'name' => '동양기계(주)', 'type' => 'BOTH', 'business_no' => '567-89-01234', 'phone' => '032-5678-9012'], + ['code' => 'C-DEMO-006', 'name' => '(주)서울유통', 'type' => 'CUSTOMER', 'business_no' => '678-90-12345', 'phone' => '02-6789-0123'], + ['code' => 'C-DEMO-007', 'name' => '부산산업(주)', 'type' => 'SUPPLIER', 'business_no' => '789-01-23456', 'phone' => '051-7890-1234'], + ['code' => 'C-DEMO-008', 'name' => '(주)글로벌테크', 'type' => 'CUSTOMER', 'business_no' => '890-12-34567', 'phone' => '02-8901-2345'], + ['code' => 'C-DEMO-009', 'name' => '인천금속(주)', 'type' => 'SUPPLIER', 'business_no' => '901-23-45678', 'phone' => '032-9012-3456'], + ['code' => 'C-DEMO-010', 'name' => '(주)한빛전자', 'type' => 'CUSTOMER', 'business_no' => '012-34-56789', 'phone' => '02-0123-4567'], + ]; + + $ids = []; + foreach ($clients as $client) { + $ids[] = DB::table('clients')->insertGetId([ + 'tenant_id' => $tenantId, + 'client_code' => $client['code'], + 'name' => $client['name'], + 'client_type' => $client['type'], + 'business_no' => $client['business_no'], + 'phone' => $client['phone'], + 'is_active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + return $ids; + } + + private function seedItems(int $tenantId, $now): void + { + // 원자재 + $rawMaterials = [ + ['code' => 'RM-001', 'name' => 'STS304 스테인리스 판재 (1.0t)', 'unit' => 'EA'], + ['code' => 'RM-002', 'name' => 'STS304 스테인리스 판재 (1.5t)', 'unit' => 'EA'], + ['code' => 'RM-003', 'name' => '알루미늄 프로파일 40x40', 'unit' => 'M'], + ['code' => 'RM-004', 'name' => '알루미늄 프로파일 60x60', 'unit' => 'M'], + ['code' => 'RM-005', 'name' => 'SS400 철판 (3.0t)', 'unit' => 'EA'], + ['code' => 'RM-006', 'name' => '동파이프 15A', 'unit' => 'M'], + ['code' => 'RM-007', 'name' => '볼트 M8x20 STS', 'unit' => 'EA'], + ['code' => 'RM-008', 'name' => '볼트 M10x30 STS', 'unit' => 'EA'], + ['code' => 'RM-009', 'name' => '고무 패킹 (50mm)', 'unit' => 'EA'], + ['code' => 'RM-010', 'name' => '에폭시 도료 (20L)', 'unit' => 'CAN'], + ]; + + foreach ($rawMaterials as $rm) { + DB::table('items')->insert([ + 'tenant_id' => $tenantId, + 'code' => $rm['code'], + 'name' => $rm['name'], + 'unit' => $rm['unit'], + 'item_type' => 'MATERIAL', + 'is_active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + // 반제품 + $subAssemblies = [ + ['code' => 'SA-001', 'name' => '프레임 조립체 A', 'unit' => 'EA'], + ['code' => 'SA-002', 'name' => '프레임 조립체 B', 'unit' => 'EA'], + ['code' => 'SA-003', 'name' => '구동부 어셈블리', 'unit' => 'SET'], + ['code' => 'SA-004', 'name' => '제어 패널 유닛', 'unit' => 'EA'], + ['code' => 'SA-005', 'name' => '하우징 케이스', 'unit' => 'EA'], + ]; + + foreach ($subAssemblies as $sa) { + DB::table('items')->insert([ + 'tenant_id' => $tenantId, + 'code' => $sa['code'], + 'name' => $sa['name'], + 'unit' => $sa['unit'], + 'item_type' => 'SUBASSEMBLY', + 'is_active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + // 완제품 + $finishedProducts = [ + ['code' => 'FP-001', 'name' => '자동화 컨베이어 시스템 (표준형)', 'unit' => 'SET'], + ['code' => 'FP-002', 'name' => '자동화 컨베이어 시스템 (고속형)', 'unit' => 'SET'], + ['code' => 'FP-003', 'name' => '산업용 로봇암 (6축)', 'unit' => 'EA'], + ['code' => 'FP-004', 'name' => '정밀 CNC 가공기', 'unit' => 'EA'], + ['code' => 'FP-005', 'name' => '유압 프레스 (50톤)', 'unit' => 'EA'], + ]; + + foreach ($finishedProducts as $p) { + DB::table('items')->insert([ + 'tenant_id' => $tenantId, + 'code' => $p['code'], + 'name' => $p['name'], + 'unit' => $p['unit'], + 'item_type' => 'PRODUCT', + 'is_active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + private function seedQuotes(int $tenantId, array $clientIds, $now): void + { + $quotes = [ + ['no' => 'QT-2026-001', 'client_idx' => 1, 'name' => '컨베이어 시스템 표준형', 'qty' => 2, 'amount' => 45000000, 'status' => 'approved'], + ['no' => 'QT-2026-002', 'client_idx' => 2, 'name' => '로봇암 6축 3대', 'qty' => 3, 'amount' => 120000000, 'status' => 'sent'], + ['no' => 'QT-2026-003', 'client_idx' => 5, 'name' => 'CNC 가공기 설치', 'qty' => 1, 'amount' => 85000000, 'status' => 'finalized'], + ['no' => 'QT-2026-004', 'client_idx' => 7, 'name' => '유압프레스 2대', 'qty' => 2, 'amount' => 60000000, 'status' => 'draft'], + ['no' => 'QT-2026-005', 'client_idx' => 9, 'name' => '컨베이어 고속형', 'qty' => 1, 'amount' => 55000000, 'status' => 'converted'], + ]; + + foreach ($quotes as $q) { + $supplyAmount = $q['amount']; + $taxAmount = (int) round($supplyAmount * 0.1); + + DB::table('quotes')->insert([ + 'tenant_id' => $tenantId, + 'quote_number' => $q['no'], + 'quote_type' => 'manufacturing', + 'registration_date' => now()->subDays(rand(10, 60))->toDateString(), + 'client_id' => $clientIds[$q['client_idx']] ?? $clientIds[0], + 'product_category' => 'STEEL', + 'product_name' => $q['name'], + 'quantity' => $q['qty'], + 'subtotal' => $supplyAmount, + 'discount_rate' => 0, + 'discount_amount' => 0, + 'total_amount' => $supplyAmount + $taxAmount, + 'status' => $q['status'], + 'current_revision' => 1, + 'is_final' => $q['status'] === 'finalized' || $q['status'] === 'converted', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + private function seedOrders(int $tenantId, array $clientIds, $now): void + { + $orders = [ + ['no' => 'SO-2026-001', 'client_idx' => 1, 'status' => 'CONFIRMED', 'qty' => 2, 'supply' => 45000000], + ['no' => 'SO-2026-002', 'client_idx' => 2, 'status' => 'IN_PRODUCTION', 'qty' => 3, 'supply' => 120000000], + ['no' => 'SO-2026-003', 'client_idx' => 5, 'status' => 'COMPLETED', 'qty' => 1, 'supply' => 85000000], + ['no' => 'SO-2026-004', 'client_idx' => 7, 'status' => 'SHIPPED', 'qty' => 2, 'supply' => 60000000], + ['no' => 'SO-2026-005', 'client_idx' => 9, 'status' => 'DRAFT', 'qty' => 1, 'supply' => 55000000], + ['no' => 'SO-2026-006', 'client_idx' => 1, 'status' => 'CONFIRMED', 'qty' => 5, 'supply' => 30000000], + ['no' => 'SO-2026-007', 'client_idx' => 2, 'status' => 'IN_PRODUCTION', 'qty' => 1, 'supply' => 95000000], + ['no' => 'SO-2026-008', 'client_idx' => 5, 'status' => 'CONFIRMED', 'qty' => 4, 'supply' => 72000000], + ]; + + foreach ($orders as $o) { + $taxAmount = (int) round($o['supply'] * 0.1); + + DB::table('orders')->insert([ + 'tenant_id' => $tenantId, + 'order_no' => $o['no'], + 'order_type_code' => 'ORDER', + 'status_code' => $o['status'], + 'client_id' => $clientIds[$o['client_idx']] ?? $clientIds[0], + 'quantity' => $o['qty'], + 'supply_amount' => $o['supply'], + 'tax_amount' => $taxAmount, + 'total_amount' => $o['supply'] + $taxAmount, + 'received_at' => now()->subDays(rand(5, 45))->toDateString(), + 'delivery_date' => now()->addDays(rand(10, 60))->toDateString(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } +} diff --git a/routes/console.php b/routes/console.php index 73cd822..8b2ccb8 100644 --- a/routes/console.php +++ b/routes/console.php @@ -156,3 +156,16 @@ ->onFailure(function () { \Illuminate\Support\Facades\Log::error('❌ stat:check-kpi-alerts 스케줄러 실행 실패', ['time' => now()]); }); + +// ─── 데모 쇼케이스 리셋 ─── + +// 매일 자정 00:00에 쇼케이스 테넌트 데이터 리셋 + 샘플 재시드 +Schedule::command('demo:reset-showcase --seed') + ->dailyAt('00:00') + ->appendOutputTo(storage_path('logs/scheduler.log')) + ->onSuccess(function () { + \Illuminate\Support\Facades\Log::info('✅ demo:reset-showcase 스케줄러 실행 성공', ['time' => now()]); + }) + ->onFailure(function () { + \Illuminate\Support\Facades\Log::error('❌ demo:reset-showcase 스케줄러 실행 실패', ['time' => now()]); + });