From e12fc461a7e6d2f455d012d60db8f2a9e7a09ada 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:27:39 +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=EA=B4=80=EB=A6=AC=20API=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=20=EC=95=8C=EB=A6=BC=20(Phase=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DemoTenantController: 목록/상세/생성/리셋/연장/전환/통계 API - DemoTenantStoreRequest: 고객 체험 테넌트 생성 검증 - DemoTenantService: API용 메서드 추가 (index/show/reset/extend/convert/stats) - CheckDemoExpiredCommand: 만료 임박(7일) 알림 + 만료 테넌트 비활성 처리 - 라우트 등록 (api/v1/demo-tenants, 7개 엔드포인트) - 스케줄러 등록 (04:20 demo:check-expired) - i18n 메시지 추가 (message.demo_tenant.*, error.demo_tenant.*) --- .../Commands/CheckDemoExpiredCommand.php | 92 ++++++ .../Api/V1/DemoTenantController.php | 95 ++++++ .../Requests/Demo/DemoTenantStoreRequest.php | 35 ++ app/Services/Demo/DemoTenantService.php | 302 ++++++++++++++++++ lang/ko/error.php | 11 + lang/ko/message.php | 9 + routes/api/v1/sales.php | 12 + routes/console.php | 11 + 8 files changed, 567 insertions(+) create mode 100644 app/Console/Commands/CheckDemoExpiredCommand.php create mode 100644 app/Http/Controllers/Api/V1/DemoTenantController.php create mode 100644 app/Http/Requests/Demo/DemoTenantStoreRequest.php diff --git a/app/Console/Commands/CheckDemoExpiredCommand.php b/app/Console/Commands/CheckDemoExpiredCommand.php new file mode 100644 index 0000000..4d56cb5 --- /dev/null +++ b/app/Console/Commands/CheckDemoExpiredCommand.php @@ -0,0 +1,92 @@ +where('tenant_type', Tenant::TYPE_DEMO_TRIAL) + ->where('tenant_st_code', '!=', 'expired') + ->whereNotNull('demo_expires_at') + ->where('demo_expires_at', '>', now()) + ->where('demo_expires_at', '<=', now()->addDays(7)) + ->get(); + + if ($expiringSoon->isNotEmpty()) { + $this->info("만료 임박 테넌트: {$expiringSoon->count()}건"); + foreach ($expiringSoon as $tenant) { + $daysLeft = (int) now()->diffInDays($tenant->demo_expires_at, false); + $this->line(" - [{$tenant->id}] {$tenant->company_name} (D-{$daysLeft})"); + + Log::info('데모 체험 만료 임박', [ + 'tenant_id' => $tenant->id, + 'company_name' => $tenant->company_name, + 'expires_at' => $tenant->demo_expires_at->toDateString(), + 'days_left' => $daysLeft, + 'partner_id' => $tenant->demo_source_partner_id, + ]); + } + } + + // 2. 이미 만료된 테넌트 → 상태 변경 + $expired = Tenant::withoutGlobalScopes() + ->where('tenant_type', Tenant::TYPE_DEMO_TRIAL) + ->where('tenant_st_code', '!=', 'expired') + ->whereNotNull('demo_expires_at') + ->where('demo_expires_at', '<', now()) + ->get(); + + if ($expired->isEmpty()) { + $this->info('만료 처리 대상 없음'); + + return self::SUCCESS; + } + + $this->info("만료 처리 대상: {$expired->count()}건"); + + foreach ($expired as $tenant) { + $this->line(" - [{$tenant->id}] {$tenant->company_name} (만료: {$tenant->demo_expires_at->toDateString()})"); + + if (! $this->option('dry-run')) { + $tenant->forceFill(['tenant_st_code' => 'expired']); + $tenant->save(); + + Log::info('데모 체험 만료 처리', [ + 'tenant_id' => $tenant->id, + 'company_name' => $tenant->company_name, + 'partner_id' => $tenant->demo_source_partner_id, + ]); + } + } + + if ($this->option('dry-run')) { + $this->warn('(dry-run 모드 — 실제 변경 없음)'); + } else { + $this->info(" {$expired->count()}건 만료 처리 완료"); + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/V1/DemoTenantController.php b/app/Http/Controllers/Api/V1/DemoTenantController.php new file mode 100644 index 0000000..87df2e1 --- /dev/null +++ b/app/Http/Controllers/Api/V1/DemoTenantController.php @@ -0,0 +1,95 @@ +service->index($request->all()); + }, __('message.demo_tenant.fetched')); + } + + /** + * 데모 테넌트 상세 조회 + */ + public function show(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.demo_tenant.fetched')); + } + + /** + * 고객 체험 테넌트 생성 (Tier 3) + */ + public function store(DemoTenantStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->createTrialFromApi($request->validated()); + }, __('message.demo_tenant.created')); + } + + /** + * 데모 데이터 리셋 + */ + public function reset(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->resetFromApi($id); + }, __('message.demo_tenant.reset')); + } + + /** + * 체험 기간 연장 + */ + public function extend(int $id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + $days = (int) $request->input('days', 30); + + return $this->service->extendFromApi($id, $days); + }, __('message.demo_tenant.extended')); + } + + /** + * 데모 → 정식 전환 + */ + public function convert(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->convertFromApi($id); + }, __('message.demo_tenant.converted')); + } + + /** + * 데모 현황 통계 + */ + public function stats() + { + return ApiResponse::handle(function () { + return $this->service->stats(); + }, __('message.fetched')); + } +} diff --git a/app/Http/Requests/Demo/DemoTenantStoreRequest.php b/app/Http/Requests/Demo/DemoTenantStoreRequest.php new file mode 100644 index 0000000..71c4c3a --- /dev/null +++ b/app/Http/Requests/Demo/DemoTenantStoreRequest.php @@ -0,0 +1,35 @@ + 'required|string|max:100', + 'email' => 'required|email|max:255', + 'duration_days' => 'sometimes|integer|min:7|max:60', + 'preset' => 'sometimes|string|in:manufacturing', + ]; + } + + public function messages(): array + { + return [ + 'company_name.required' => '회사명은 필수입니다.', + 'email.required' => '이메일은 필수입니다.', + 'email.email' => '올바른 이메일 형식이 아닙니다.', + 'duration_days.min' => '체험 기간은 최소 7일입니다.', + 'duration_days.max' => '체험 기간은 최대 60일입니다.', + 'preset.in' => '유효하지 않은 프리셋입니다.', + ]; + } +} diff --git a/app/Services/Demo/DemoTenantService.php b/app/Services/Demo/DemoTenantService.php index 24f32de..c500e0e 100644 --- a/app/Services/Demo/DemoTenantService.php +++ b/app/Services/Demo/DemoTenantService.php @@ -4,7 +4,9 @@ use App\Models\Tenants\Tenant; use App\Services\Service; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * 데모 테넌트 생성/관리 서비스 @@ -164,4 +166,304 @@ public function getExpiredTrials(): \Illuminate\Database\Eloquent\Collection ->where('demo_expires_at', '<', now()) ->get(); } + + // ────────────────────────────────────────────── + // Phase 3: API 엔드포인트용 메서드 + // ────────────────────────────────────────────── + + /** + * 내가 생성한 데모 테넌트 목록 + */ + public function index(array $params): array + { + $userId = $this->apiUserId(); + + // 현재 사용자의 파트너 ID 조회 + $partnerId = DB::table('sales_partners') + ->where('user_id', $userId) + ->value('id'); + + $query = Tenant::withoutGlobalScopes() + ->whereIn('tenant_type', Tenant::DEMO_TYPES); + + // 파트너인 경우 자기가 생성한 데모만 조회 + if ($partnerId) { + $query->where('demo_source_partner_id', $partnerId); + } + + // 상태 필터 + if (! empty($params['status'])) { + match ($params['status']) { + 'active' => $query->where(function ($q) { + $q->whereNull('demo_expires_at') + ->orWhere('demo_expires_at', '>', now()); + }), + 'expired' => $query->whereNotNull('demo_expires_at') + ->where('demo_expires_at', '<', now()), + default => null, + }; + } + + // 타입 필터 + if (! empty($params['type'])) { + $query->where('tenant_type', $params['type']); + } + + $tenants = $query->orderByDesc('created_at')->get(); + + return $tenants->map(function (Tenant $t) { + return $this->formatTenantResponse($t); + })->toArray(); + } + + /** + * 데모 테넌트 상세 조회 + */ + public function show(int $id): array + { + $tenant = $this->findDemoTenant($id); + + $response = $this->formatTenantResponse($tenant); + + // 상세 조회 시 데이터 현황도 포함 + $response['data_counts'] = $this->getDataCounts($tenant->id); + + return $response; + } + + /** + * API에서 고객 체험 테넌트 생성 + */ + public function createTrialFromApi(array $data): array + { + $userId = $this->apiUserId(); + + $partnerId = DB::table('sales_partners') + ->where('user_id', $userId) + ->value('id'); + + // 파트너가 아닌 경우 관리자 권한으로 생성 (partnerId = 0) + $partnerId = $partnerId ?? 0; + + $preset = $data['preset'] ?? 'manufacturing'; + $durationDays = $data['duration_days'] ?? 30; + + $tenant = $this->createTrialDemo( + $partnerId, + $data['company_name'], + $data['email'], + $durationDays, + $preset + ); + + // 샘플 데이터 시드 + try { + $seeder = new \Database\Seeders\Demo\ManufacturingPresetSeeder; + $seeder->run($tenant->id); + } catch (\Exception $e) { + Log::warning('데모 샘플 데이터 시드 실패', [ + 'tenant_id' => $tenant->id, + 'error' => $e->getMessage(), + ]); + } + + return $this->formatTenantResponse($tenant); + } + + /** + * API에서 데모 데이터 리셋 + */ + public function resetFromApi(int $id): array + { + $tenant = $this->findDemoTenant($id); + $this->checkOwnership($tenant); + + // 리셋 커맨드의 테이블 목록 사용 + $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', + 'departments', 'audit_logs', + ]; + + DB::beginTransaction(); + try { + $totalDeleted = 0; + foreach ($tables as $table) { + if (! \Schema::hasTable($table) || ! \Schema::hasColumn($table, 'tenant_id')) { + continue; + } + $totalDeleted += DB::table($table)->where('tenant_id', $tenant->id)->delete(); + } + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + throw $e; + } + + // 재시드 + $preset = $tenant->getDemoPreset() ?? 'manufacturing'; + try { + $seeder = new \Database\Seeders\Demo\ManufacturingPresetSeeder; + $seeder->run($tenant->id); + } catch (\Exception $e) { + Log::warning('리셋 후 재시드 실패', [ + 'tenant_id' => $tenant->id, + 'error' => $e->getMessage(), + ]); + } + + Log::info('데모 데이터 API 리셋', [ + 'tenant_id' => $tenant->id, + 'deleted_count' => $totalDeleted, + ]); + + return [ + 'tenant_id' => $tenant->id, + 'deleted_count' => $totalDeleted, + 'data_counts' => $this->getDataCounts($tenant->id), + ]; + } + + /** + * API에서 체험 기간 연장 + */ + public function extendFromApi(int $id, int $days = 30): array + { + $tenant = $this->findDemoTenant($id); + $this->checkOwnership($tenant); + + if (! $tenant->isDemoTrial()) { + return ['error' => __('error.demo_tenant.not_trial'), 'code' => 400]; + } + + if (! $this->extendTrial($tenant, $days)) { + return ['error' => __('error.demo_tenant.already_extended'), 'code' => 400]; + } + + return $this->formatTenantResponse($tenant->fresh()); + } + + /** + * API에서 데모 → 정식 전환 + */ + public function convertFromApi(int $id): array + { + $tenant = $this->findDemoTenant($id); + $this->checkOwnership($tenant); + + $this->convertToProduction($tenant); + + return $this->formatTenantResponse($tenant->fresh()); + } + + /** + * 데모 현황 통계 + */ + public function stats(): array + { + $userId = $this->apiUserId(); + + $partnerId = DB::table('sales_partners') + ->where('user_id', $userId) + ->value('id'); + + $baseQuery = Tenant::withoutGlobalScopes() + ->whereIn('tenant_type', Tenant::DEMO_TYPES); + + if ($partnerId) { + $baseQuery->where('demo_source_partner_id', $partnerId); + } + + $all = (clone $baseQuery)->get(); + + $active = $all->filter(fn (Tenant $t) => ! $t->isDemoExpired()); + $expired = $all->filter(fn (Tenant $t) => $t->isDemoExpired()); + $converted = Tenant::withoutGlobalScopes() + ->where('tenant_type', Tenant::TYPE_STD) + ->when($partnerId, fn ($q) => $q->where('demo_source_partner_id', $partnerId)) + ->whereNotNull('demo_source_partner_id') + ->count(); + + return [ + 'total' => $all->count(), + 'active' => $active->count(), + 'expired' => $expired->count(), + 'converted' => $converted, + 'by_type' => [ + 'showcase' => $all->where('tenant_type', Tenant::TYPE_DEMO_SHOWCASE)->count(), + 'partner' => $all->where('tenant_type', Tenant::TYPE_DEMO_PARTNER)->count(), + 'trial' => $all->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)->count(), + ], + ]; + } + + // ────────────────────────────────────────────── + // Private Helpers + // ────────────────────────────────────────────── + + private function findDemoTenant(int $id): Tenant + { + $tenant = Tenant::withoutGlobalScopes()->find($id); + + if (! $tenant || ! $tenant->isDemoTenant()) { + throw new NotFoundHttpException(__('error.demo_tenant.not_found')); + } + + return $tenant; + } + + private function checkOwnership(Tenant $tenant): void + { + $userId = $this->apiUserId(); + + $partnerId = DB::table('sales_partners') + ->where('user_id', $userId) + ->value('id'); + + // 파트너가 아닌 경우 (관리자) → 통과 + if (! $partnerId) { + return; + } + + // 파트너인 경우 → 자기가 생성한 데모만 접근 가능 + if ($tenant->demo_source_partner_id !== $partnerId) { + throw new NotFoundHttpException(__('error.demo_tenant.not_owned')); + } + } + + private function formatTenantResponse(Tenant $tenant): array + { + return [ + 'id' => $tenant->id, + 'company_name' => $tenant->company_name, + 'code' => $tenant->code, + 'email' => $tenant->email, + 'tenant_type' => $tenant->tenant_type, + 'tenant_st_code' => $tenant->tenant_st_code, + 'demo_preset' => $tenant->getDemoPreset(), + 'demo_limits' => $tenant->getDemoLimits(), + 'demo_expires_at' => $tenant->demo_expires_at?->toDateString(), + 'demo_source_partner_id' => $tenant->demo_source_partner_id, + 'is_expired' => $tenant->isDemoExpired(), + 'is_extended' => (bool) $tenant->getOption('demo_extended', false), + 'created_at' => $tenant->created_at?->toDateString(), + ]; + } + + private function getDataCounts(int $tenantId): array + { + $tables = ['departments', 'clients', 'items', 'quotes', 'orders']; + $counts = []; + + foreach ($tables as $table) { + if (\Schema::hasTable($table) && \Schema::hasColumn($table, 'tenant_id')) { + $counts[$table] = DB::table($table)->where('tenant_id', $tenantId)->count(); + } + } + + return $counts; + } } diff --git a/lang/ko/error.php b/lang/ko/error.php index a7ad36b..29d03c1 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -527,4 +527,15 @@ 'in_use' => '전표에서 사용 중인 계정과목은 삭제할 수 없습니다.', ], 'tenant_access_denied' => '해당 테넌트에 대한 접근 권한이 없습니다.', + + // 데모 테넌트 관련 + 'demo_tenant' => [ + 'not_found' => '데모 테넌트를 찾을 수 없습니다.', + 'not_demo' => '데모 테넌트가 아닙니다.', + 'not_trial' => '고객 체험 테넌트가 아닙니다.', + 'already_extended' => '이미 연장된 체험 테넌트입니다.', + 'expired' => '체험 기간이 만료되었습니다.', + 'invalid_preset' => '유효하지 않은 프리셋입니다.', + 'not_owned' => '해당 데모 테넌트에 대한 권한이 없습니다.', + ], ]; diff --git a/lang/ko/message.php b/lang/ko/message.php index bd1e14a..c0fed9b 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -591,6 +591,15 @@ 'photo_uploaded' => '사진이 업로드되었습니다.', ], + // 데모 테넌트 관리 + 'demo_tenant' => [ + 'fetched' => '데모 테넌트를 조회했습니다.', + 'created' => '데모 테넌트가 생성되었습니다.', + 'reset' => '데모 데이터가 초기화되었습니다.', + 'extended' => '체험 기간이 연장되었습니다.', + 'converted' => '정식 테넌트로 전환되었습니다.', + ], + // 일반전표입력 'journal_entry' => [ 'fetched' => '전표 조회 성공', diff --git a/routes/api/v1/sales.php b/routes/api/v1/sales.php index f588ea9..18a7f9e 100644 --- a/routes/api/v1/sales.php +++ b/routes/api/v1/sales.php @@ -14,6 +14,7 @@ use App\Http\Controllers\Api\V1\ClientController; use App\Http\Controllers\Api\V1\ClientGroupController; use App\Http\Controllers\Api\V1\CompanyController; +use App\Http\Controllers\Api\V1\DemoTenantController; use App\Http\Controllers\Api\V1\EstimateController; use App\Http\Controllers\Api\V1\OrderController; use App\Http\Controllers\Api\V1\PricingController; @@ -193,6 +194,17 @@ Route::post('/bulk-issue-statement', [SaleController::class, 'bulkIssueStatement'])->name('v1.sales.bulk-issue-statement'); }); +// Demo Tenant API (데모 테넌트 관리) +Route::prefix('demo-tenants')->group(function () { + Route::get('', [DemoTenantController::class, 'index'])->name('v1.demo-tenants.index'); + Route::get('/stats', [DemoTenantController::class, 'stats'])->name('v1.demo-tenants.stats'); + Route::post('', [DemoTenantController::class, 'store'])->name('v1.demo-tenants.store'); + Route::get('/{id}', [DemoTenantController::class, 'show'])->whereNumber('id')->name('v1.demo-tenants.show'); + Route::post('/{id}/reset', [DemoTenantController::class, 'reset'])->whereNumber('id')->name('v1.demo-tenants.reset'); + Route::post('/{id}/extend', [DemoTenantController::class, 'extend'])->whereNumber('id')->name('v1.demo-tenants.extend'); + Route::post('/{id}/convert', [DemoTenantController::class, 'convert'])->whereNumber('id')->name('v1.demo-tenants.convert'); +}); + // Company API (회사 추가 관리) Route::prefix('companies')->group(function () { Route::post('/check', [CompanyController::class, 'check'])->name('v1.companies.check'); // 사업자등록번호 검증 diff --git a/routes/console.php b/routes/console.php index 8b2ccb8..b18adcf 100644 --- a/routes/console.php +++ b/routes/console.php @@ -159,6 +159,17 @@ // ─── 데모 쇼케이스 리셋 ─── +// 매일 새벽 04:20에 데모 체험 테넌트 만료 체크 및 비활성 처리 +Schedule::command('demo:check-expired') + ->dailyAt('04:20') + ->appendOutputTo(storage_path('logs/scheduler.log')) + ->onSuccess(function () { + \Illuminate\Support\Facades\Log::info('✅ demo:check-expired 스케줄러 실행 성공', ['time' => now()]); + }) + ->onFailure(function () { + \Illuminate\Support\Facades\Log::error('❌ demo:check-expired 스케줄러 실행 실패', ['time' => now()]); + }); + // 매일 자정 00:00에 쇼케이스 테넌트 데이터 리셋 + 샘플 재시드 Schedule::command('demo:reset-showcase --seed') ->dailyAt('00:00')