2026-03-13 22:20:52 +09:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services\Demo;
|
|
|
|
|
|
|
|
|
|
use App\Models\Tenants\Tenant;
|
|
|
|
|
use App\Services\Service;
|
2026-03-13 22:27:39 +09:00
|
|
|
use Illuminate\Support\Facades\DB;
|
2026-03-13 22:20:52 +09:00
|
|
|
use Illuminate\Support\Facades\Log;
|
2026-03-13 22:27:39 +09:00
|
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
2026-03-13 22:20:52 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 데모 테넌트 생성/관리 서비스
|
|
|
|
|
*
|
|
|
|
|
* 기존 코드 영향 없음: 데모 전용 로직만 포함
|
|
|
|
|
*
|
|
|
|
|
* @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();
|
|
|
|
|
}
|
2026-03-13 22:27:39 +09:00
|
|
|
|
|
|
|
|
// ──────────────────────────────────────────────
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
2026-03-13 22:20:52 +09:00
|
|
|
}
|