feat: [demo] 데모 테넌트 운영 자동화 (Phase 2)

- DemoLimitMiddleware: 쇼케이스 읽기전용, 만료 체크, 외부연동 차단
- DemoTenantService: 파트너/체험 테넌트 생성, 기간 연장, 정식 전환
- ResetDemoShowcaseCommand: 매일 자정 데이터 리셋 + 샘플 재시드
- ManufacturingPresetSeeder: 부서/거래처/품목/견적/수주 샘플 데이터
- 스케줄러 등록 (00:00 demo:reset-showcase --seed)
- 미들웨어 별칭 등록 (demo.limit)
This commit is contained in:
김보곤
2026-03-13 22:20:52 +09:00
parent 39844a3ba0
commit 1eb8d2cb01
6 changed files with 696 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
<?php
namespace App\Console\Commands;
use App\Models\Tenants\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* 데모 쇼케이스 테넌트 데이터 리셋 커맨드
*
* 매일 자정에 쇼케이스 테넌트의 비즈니스 데이터를 삭제하고
* 샘플 데이터를 다시 시드한다.
*
* 기존 코드 영향 없음: DEMO_SHOWCASE 테넌트만 대상
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class ResetDemoShowcaseCommand extends Command
{
protected $signature = 'demo:reset-showcase
{--seed : 리셋 후 샘플 데이터 시드}
{--dry-run : 실제 삭제 없이 대상만 표시}';
protected $description = '데모 쇼케이스 테넌트의 비즈니스 데이터를 리셋합니다';
/**
* 리셋 대상 테이블 목록 (tenant_id 기반)
* 순서 중요: FK 의존성 역순으로 삭제
*/
private const RESET_TABLES = [
// 영업/주문
'order_item_components',
'order_items',
'order_histories',
'orders',
'quotes',
// 생산
'production_results',
'production_plans',
// 자재/재고
'material_inspection_items',
'material_inspections',
'material_receipts',
'lot_sales',
'lots',
// 마스터
'price_histories',
'product_components',
'items',
'clients',
// 파일 (데모 데이터 관련)
// files는 morphable이므로 별도 처리 필요
// 조직
'departments',
// 감사 로그 (데모 데이터)
'audit_logs',
];
public function handle(): int
{
$showcases = Tenant::withoutGlobalScopes()
->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(),
]);
}
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Http\Middleware;
use App\Models\Tenants\Tenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* 데모 테넌트 제한 미들웨어
*
* - DEMO_SHOWCASE: 모든 쓰기 작업 차단 (읽기 전용)
* - DEMO_PARTNER / DEMO_TRIAL: 만료 체크 + 차단 기능 체크
*
* 기존 코드 영향 없음: 프로덕션 테넌트(STD/TPL/HQ)는 즉시 통과
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class DemoLimitMiddleware
{
/**
* 데모에서 차단하는 라우트 프리픽스 (외부 시스템 연동)
*/
private const BLOCKED_ROUTE_PREFIXES = [
'api/v1/barobill',
'api/v1/ecount',
];
public function handle(Request $request, Closure $next): Response
{
$tenantId = app('tenant_id');
if (! $tenantId) {
return $next($request);
}
$tenant = Tenant::withoutGlobalScopes()->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;
}
}

View File

@@ -0,0 +1,167 @@
<?php
namespace App\Services\Demo;
use App\Models\Tenants\Tenant;
use App\Services\Service;
use Illuminate\Support\Facades\Log;
/**
* 데모 테넌트 생성/관리 서비스
*
* 기존 코드 영향 없음: 데모 전용 로직만 포함
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class DemoTenantService extends Service
{
/**
* 기본 데모 수량 제한
*/
private const DEFAULT_DEMO_LIMITS = [
'max_items' => 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();
}
}

View File

@@ -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) {

View File

@@ -0,0 +1,248 @@
<?php
namespace Database\Seeders\Demo;
use Illuminate\Support\Facades\DB;
/**
* 제조업 데모 프리셋 시더
*
* 데모 쇼케이스 테넌트용 샘플 데이터를 생성한다.
* DatabaseSeeder에 등록하지 않으며, ResetDemoShowcaseCommand에서만 호출된다.
*
* 기존 코드/데이터에 영향 없음: 지정된 tenant_id에만 데이터 생성
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class ManufacturingPresetSeeder
{
public function run(int $tenantId): void
{
$now = now();
// 1. 부서
$this->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,
]);
}
}
}

View File

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