feat: [demo] 데모 테넌트 관리 API 및 만료 알림 (Phase 3)

- 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.*)
This commit is contained in:
김보곤
2026-03-13 22:27:39 +09:00
parent 1eb8d2cb01
commit e12fc461a7
8 changed files with 567 additions and 0 deletions

View File

@@ -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;
}
}