- DemoTenantController: CRUD + 연장/전환/삭제 - Blade 뷰: index, demo-list, create-modal, show-modal - 라우트: sales/demo-tenants 그룹 등록 - 메뉴: 영업관리 하위에 '데모 체험 관리' 추가
324 lines
11 KiB
PHP
324 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Sales;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Sales\SalesPartner;
|
|
use App\Models\Tenants\Tenant;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\View\View;
|
|
|
|
/**
|
|
* 데모 체험 관리 컨트롤러
|
|
*
|
|
* 영업파트너가 고객에게 SAM을 체험시키기 위한 데모 테넌트 관리
|
|
* - 고객 체험 테넌트 생성 (Tier 3)
|
|
* - 목록/상세 조회
|
|
* - 기간 연장, 정식 전환
|
|
*
|
|
* @see docs/features/sales/demo-tenant-policy.md
|
|
*/
|
|
class DemoTenantController extends Controller
|
|
{
|
|
private const DEMO_TYPES = ['DEMO_SHOWCASE', 'DEMO_PARTNER', 'DEMO_TRIAL'];
|
|
|
|
private const DEFAULT_LIMITS = [
|
|
'max_items' => 100,
|
|
'max_orders' => 50,
|
|
'max_productions' => 30,
|
|
'max_users' => 5,
|
|
'max_storage_gb' => 1,
|
|
'max_ai_tokens' => 100000,
|
|
];
|
|
|
|
/**
|
|
* 데모 체험 관리 메인 화면
|
|
*/
|
|
public function index(Request $request): View
|
|
{
|
|
if ($request->header('HX-Request') && ! $request->header('HX-Boosted')) {
|
|
return response('', 200)->header('HX-Redirect', route('sales.demo-tenants.index'));
|
|
}
|
|
|
|
$partner = SalesPartner::where('user_id', auth()->id())->first();
|
|
$isAdmin = auth()->user()->isHqAdmin ?? false;
|
|
|
|
$query = Tenant::withoutGlobalScopes()
|
|
->whereIn('tenant_type', self::DEMO_TYPES);
|
|
|
|
if (! $isAdmin && $partner) {
|
|
$query->where('demo_source_partner_id', $partner->id);
|
|
}
|
|
|
|
$demos = $query->orderByDesc('created_at')->get()->map(function ($t) {
|
|
$t->demo_status = $this->getDemoStatus($t);
|
|
$t->remaining_days = $this->getRemainingDays($t->demo_expires_at);
|
|
$t->parsed_options = $t->options ?? [];
|
|
|
|
return $t;
|
|
});
|
|
|
|
$stats = [
|
|
'total' => $demos->count(),
|
|
'active' => $demos->where('demo_status', 'active')->count(),
|
|
'expired' => $demos->where('demo_status', 'expired')->count(),
|
|
'converted' => $demos->where('demo_status', 'converted')->count(),
|
|
'expiring_soon' => $demos->filter(fn ($d) => $d->demo_status === 'active' && $d->remaining_days !== null && $d->remaining_days <= 7)->count(),
|
|
];
|
|
|
|
return view('sales.demo-tenants.index', compact('demos', 'stats', 'partner', 'isAdmin'));
|
|
}
|
|
|
|
/**
|
|
* 목록 부분 새로고침 (HTMX)
|
|
*/
|
|
public function refreshList(Request $request): View
|
|
{
|
|
$partner = SalesPartner::where('user_id', auth()->id())->first();
|
|
$isAdmin = auth()->user()->isHqAdmin ?? false;
|
|
|
|
$query = Tenant::withoutGlobalScopes()
|
|
->whereIn('tenant_type', self::DEMO_TYPES);
|
|
|
|
if (! $isAdmin && $partner) {
|
|
$query->where('demo_source_partner_id', $partner->id);
|
|
}
|
|
|
|
$demos = $query->orderByDesc('created_at')->get()->map(function ($t) {
|
|
$t->demo_status = $this->getDemoStatus($t);
|
|
$t->remaining_days = $this->getRemainingDays($t->demo_expires_at);
|
|
$t->parsed_options = $t->options ?? [];
|
|
|
|
return $t;
|
|
});
|
|
|
|
$stats = [
|
|
'total' => $demos->count(),
|
|
'active' => $demos->where('demo_status', 'active')->count(),
|
|
'expired' => $demos->where('demo_status', 'expired')->count(),
|
|
'converted' => $demos->where('demo_status', 'converted')->count(),
|
|
'expiring_soon' => $demos->filter(fn ($d) => $d->demo_status === 'active' && $d->remaining_days !== null && $d->remaining_days <= 7)->count(),
|
|
];
|
|
|
|
return view('sales.demo-tenants.partials.demo-list', compact('demos', 'stats'));
|
|
}
|
|
|
|
/**
|
|
* 상세 조회 모달 (HTMX)
|
|
*/
|
|
public function show(int $id): View
|
|
{
|
|
$tenant = $this->findAndAuthorize($id);
|
|
$tenant->demo_status = $this->getDemoStatus($tenant);
|
|
$tenant->remaining_days = $this->getRemainingDays($tenant->demo_expires_at);
|
|
$tenant->parsed_options = $tenant->options ?? [];
|
|
|
|
// 데이터 건수 조회
|
|
$dataCounts = [];
|
|
foreach (['clients' => '거래처', 'items' => '품목', 'orders' => '수주', 'quotes' => '견적'] as $table => $label) {
|
|
try {
|
|
$dataCounts[$table] = ['label' => $label, 'count' => DB::table($table)->where('tenant_id', $id)->count()];
|
|
} catch (\Exception $e) {
|
|
$dataCounts[$table] = ['label' => $label, 'count' => 0];
|
|
}
|
|
}
|
|
|
|
return view('sales.demo-tenants.partials.show-modal', compact('tenant', 'dataCounts'));
|
|
}
|
|
|
|
/**
|
|
* 생성 폼 모달 (HTMX)
|
|
*/
|
|
public function createForm(): View
|
|
{
|
|
return view('sales.demo-tenants.partials.create-modal');
|
|
}
|
|
|
|
/**
|
|
* 고객 체험 테넌트 생성
|
|
*/
|
|
public function store(Request $request)
|
|
{
|
|
$request->validate([
|
|
'company_name' => 'required|string|max:200',
|
|
'email' => 'nullable|email|max:100',
|
|
'duration_days' => 'required|integer|min:7|max:60',
|
|
'preset' => 'nullable|string|in:manufacturing,none',
|
|
]);
|
|
|
|
$partner = SalesPartner::where('user_id', auth()->id())->first();
|
|
$preset = $request->input('preset', 'manufacturing');
|
|
|
|
$options = [
|
|
'demo_preset' => $preset === 'none' ? null : $preset,
|
|
'demo_limits' => self::DEFAULT_LIMITS,
|
|
'demo_email' => $request->input('email'),
|
|
'created_by_user_id' => auth()->id(),
|
|
'created_by_user_name' => auth()->user()->name ?? '',
|
|
];
|
|
|
|
$tenant = new Tenant;
|
|
$tenant->forceFill([
|
|
'company_name' => $request->input('company_name'),
|
|
'tenant_type' => 'DEMO_TRIAL',
|
|
'tenant_st_code' => 'trial',
|
|
'demo_expires_at' => now()->addDays($request->input('duration_days', 30)),
|
|
'demo_source_partner_id' => $partner?->id,
|
|
'options' => $options,
|
|
'is_active' => true,
|
|
'storage_limit' => 1073741824,
|
|
'storage_used' => 0,
|
|
]);
|
|
$tenant->save();
|
|
|
|
if ($request->header('HX-Request')) {
|
|
return response('', 200)
|
|
->header('HX-Trigger', json_encode(['showToast' => '데모 체험 테넌트가 생성되었습니다.', 'refreshDemoList' => true]));
|
|
}
|
|
|
|
return redirect()->route('sales.demo-tenants.index')->with('success', '데모 체험 테넌트가 생성되었습니다.');
|
|
}
|
|
|
|
/**
|
|
* 체험 기간 연장
|
|
*/
|
|
public function extend(Request $request, int $id)
|
|
{
|
|
$tenant = $this->findAndAuthorize($id);
|
|
|
|
if ($tenant->tenant_type !== 'DEMO_TRIAL') {
|
|
return $this->htmxError('고객 체험 유형만 연장 가능합니다.');
|
|
}
|
|
|
|
$opts = $tenant->options ?? [];
|
|
if (! empty($opts['demo_extended'])) {
|
|
return $this->htmxError('이미 연장된 체험입니다. 연장은 1회만 가능합니다.');
|
|
}
|
|
|
|
$days = $request->input('days', 14);
|
|
$opts['demo_extended'] = true;
|
|
$opts['demo_extended_at'] = now()->toDateTimeString();
|
|
$opts['demo_extended_days'] = $days;
|
|
|
|
$tenant->forceFill([
|
|
'demo_expires_at' => $tenant->demo_expires_at
|
|
? $tenant->demo_expires_at->addDays($days)
|
|
: now()->addDays($days),
|
|
'options' => $opts,
|
|
'tenant_st_code' => 'trial',
|
|
]);
|
|
$tenant->save();
|
|
|
|
return response('', 200)
|
|
->header('HX-Trigger', json_encode(['showToast' => "{$days}일 연장되었습니다.", 'refreshDemoList' => true, 'closeModal' => true]));
|
|
}
|
|
|
|
/**
|
|
* 정식 전환
|
|
*/
|
|
public function convert(int $id)
|
|
{
|
|
$tenant = $this->findAndAuthorize($id);
|
|
|
|
if (! in_array($tenant->tenant_type, self::DEMO_TYPES)) {
|
|
return $this->htmxError('데모 테넌트가 아닙니다.');
|
|
}
|
|
|
|
$opts = $tenant->options ?? [];
|
|
unset($opts['demo_preset'], $opts['demo_limits'], $opts['demo_read_only']);
|
|
$opts['converted_at'] = now()->toDateTimeString();
|
|
$opts['converted_by_user_id'] = auth()->id();
|
|
|
|
$tenant->forceFill([
|
|
'tenant_type' => 'STD',
|
|
'tenant_st_code' => 'active',
|
|
'demo_expires_at' => null,
|
|
'options' => $opts,
|
|
]);
|
|
$tenant->save();
|
|
|
|
return response('', 200)
|
|
->header('HX-Trigger', json_encode(['showToast' => '정식 테넌트로 전환되었습니다.', 'refreshDemoList' => true, 'closeModal' => true]));
|
|
}
|
|
|
|
/**
|
|
* 데모 테넌트 삭제
|
|
*/
|
|
public function destroy(int $id)
|
|
{
|
|
$tenant = $this->findAndAuthorize($id);
|
|
|
|
if (! in_array($tenant->tenant_type, self::DEMO_TYPES)) {
|
|
return $this->htmxError('데모 테넌트만 삭제 가능합니다.');
|
|
}
|
|
|
|
// 데이터 존재 확인
|
|
foreach (['orders', 'clients', 'items'] as $table) {
|
|
try {
|
|
if (DB::table($table)->where('tenant_id', $id)->exists()) {
|
|
return $this->htmxError('데이터가 존재하는 테넌트는 삭제할 수 없습니다.');
|
|
}
|
|
} catch (\Exception $e) {
|
|
}
|
|
}
|
|
|
|
$tenant->forceDelete();
|
|
|
|
return response('', 200)
|
|
->header('HX-Trigger', json_encode(['showToast' => '데모 테넌트가 삭제되었습니다.', 'refreshDemoList' => true, 'closeModal' => true]));
|
|
}
|
|
|
|
// ── helpers ──
|
|
|
|
private function findAndAuthorize(int $id): Tenant
|
|
{
|
|
$tenant = Tenant::withoutGlobalScopes()
|
|
->whereIn('tenant_type', self::DEMO_TYPES)
|
|
->findOrFail($id);
|
|
|
|
$isAdmin = auth()->user()->isHqAdmin ?? false;
|
|
if (! $isAdmin) {
|
|
$partner = SalesPartner::where('user_id', auth()->id())->first();
|
|
if (! $partner || $tenant->demo_source_partner_id !== $partner->id) {
|
|
abort(403, '권한이 없습니다.');
|
|
}
|
|
}
|
|
|
|
return $tenant;
|
|
}
|
|
|
|
private function getDemoStatus(Tenant $tenant): string
|
|
{
|
|
if ($tenant->tenant_type === 'DEMO_SHOWCASE') {
|
|
return 'showcase';
|
|
}
|
|
if ($tenant->tenant_st_code === 'converted' || $tenant->tenant_type === 'STD') {
|
|
return 'converted';
|
|
}
|
|
if ($tenant->tenant_st_code === 'expired') {
|
|
return 'expired';
|
|
}
|
|
if ($tenant->demo_expires_at && $tenant->demo_expires_at->isPast()) {
|
|
return 'expired';
|
|
}
|
|
|
|
return 'active';
|
|
}
|
|
|
|
private function getRemainingDays($expiresAt): ?int
|
|
{
|
|
if (! $expiresAt) {
|
|
return null;
|
|
}
|
|
|
|
return max(0, (int) now()->diffInDays($expiresAt, false));
|
|
}
|
|
|
|
private function htmxError(string $message)
|
|
{
|
|
return response('', 200)
|
|
->header('HX-Trigger', json_encode(['showToast' => $message, 'toastType' => 'error']));
|
|
}
|
|
}
|