'datetime', 'demo_expires_at' => 'datetime', 'expires_at' => 'datetime', 'last_paid_at' => 'datetime', 'max_users' => 'integer', 'options' => 'array', 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', ]; protected $hidden = [ 'deleted_at', ]; /** * 활성화된 테넌트만 조회하는 스코프 */ public function scopeActive($query) { return $query->whereIn('tenant_st_code', ['trial', 'active']); } /** * 테넌트가 활성 상태인지 확인 */ public function isActive(): bool { return in_array($this->tenant_st_code, ['trial', 'active']); } /** * 테넌트가 트라이얼 상태인지 확인 */ public function isTrial(): bool { return $this->tenant_st_code === 'trial'; } // 관계 정의 (예시) public function plan() { return $this->belongsTo(Plan::class, 'plan_id'); } public function subscription() { return $this->belongsTo(Subscription::class, 'subscription_id'); } public function userTenants() { return $this->hasMany(UserTenant::class); } public function users() { return $this->belongsToMany(User::class, 'user_tenants'); } public function roles() { return $this->hasMany(Role::class); } public function userRoles() { return $this->hasMany(UserRole::class); } public function files() { return $this->morphMany(File::class, 'fileable'); } /** * Get storage usage percentage */ public function getStorageUsagePercentage(): float { if (! $this->storage_limit || $this->storage_limit == 0) { return 0; } return ($this->storage_used / $this->storage_limit) * 100; } /** * Check if storage is near limit (90%) */ public function isStorageNearLimit(): bool { return $this->getStorageUsagePercentage() >= 90; } /** * Check if storage quota exceeded */ public function isStorageExceeded(): bool { return $this->storage_used > $this->storage_limit; } /** * Check if in grace period */ public function isInGracePeriod(): bool { return $this->storage_grace_period_until && now()->lessThan($this->storage_grace_period_until); } /** * Check if upload is allowed */ public function canUpload(int $fileSize = 0): array { $newUsage = $this->storage_used + $fileSize; // Check if exceeds limit if ($newUsage > $this->storage_limit) { // Check grace period if ($this->isInGracePeriod()) { return [ 'allowed' => true, 'warning' => true, 'message' => __('file.storage_exceeded_grace_period', [ 'until' => $this->storage_grace_period_until->format('Y-m-d'), ]), ]; } // Grace period expired - block upload return [ 'allowed' => false, 'message' => __('file.storage_quota_exceeded'), ]; } // Check if near limit (90%) $percentage = ($newUsage / $this->storage_limit) * 100; if ($percentage >= 90 && ! $this->storage_warning_sent_at) { // Send warning (once) $this->update([ 'storage_warning_sent_at' => now(), 'storage_grace_period_until' => now()->addDays(7), ]); // TODO: Dispatch email notification // dispatch(new SendStorageWarningEmail($this)); } return ['allowed' => true]; } /** * Increment storage usage */ public function incrementStorage(int $bytes): void { $this->increment('storage_used', $bytes); } /** * Decrement storage usage */ public function decrementStorage(int $bytes): void { $this->decrement('storage_used', max(0, $bytes)); } /** * Get human-readable storage used */ public function getStorageUsedFormatted(): string { return $this->formatBytes($this->storage_used); } /** * Get human-readable storage limit */ public function getStorageLimitFormatted(): string { return $this->formatBytes($this->storage_limit); } /** * Format bytes to human-readable string */ private function formatBytes(int $bytes): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $bytes = max($bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); $pow = min($pow, count($units) - 1); $bytes /= (1 << (10 * $pow)); return round($bytes, 2).' '.$units[$pow]; } // ─── options 헬퍼 메서드 ─── public function getOption(string $key, mixed $default = null): mixed { return data_get($this->options, $key, $default); } public function setOption(string $key, mixed $value): self { $options = $this->options ?? []; data_set($options, $key, $value); $this->options = $options; return $this; } // ─── 데모 테넌트 관련 메서드 ─── /** * 데모 테넌트인지 확인 (모든 데모 유형) */ public function isDemoTenant(): bool { return in_array($this->tenant_type, self::DEMO_TYPES); } /** * 데모 쇼케이스(공용 읽기전용)인지 확인 */ public function isDemoShowcase(): bool { return $this->tenant_type === self::TYPE_DEMO_SHOWCASE; } /** * 파트너 데모인지 확인 */ public function isDemoPartner(): bool { return $this->tenant_type === self::TYPE_DEMO_PARTNER; } /** * 고객 체험 데모인지 확인 */ public function isDemoTrial(): bool { return $this->tenant_type === self::TYPE_DEMO_TRIAL; } /** * 데모 만료 여부 확인 */ public function isDemoExpired(): bool { if (! $this->isDemoTenant()) { return false; } // 쇼케이스는 만료 없음 if ($this->isDemoShowcase()) { return false; } return $this->demo_expires_at && now()->gt($this->demo_expires_at); } /** * 데모 읽기전용인지 확인 */ public function isDemoReadOnly(): bool { return $this->isDemoShowcase() || $this->getOption(self::OPTION_DEMO_READ_ONLY, false); } /** * 데모 테넌트만 조회하는 스코프 */ public function scopeDemo($query) { return $query->whereIn('tenant_type', self::DEMO_TYPES); } /** * 데모 프리셋 조회 */ public function getDemoPreset(): ?string { return $this->getOption(self::OPTION_DEMO_PRESET); } /** * 데모 수량 제한 조회 */ public function getDemoLimits(): array { return $this->getOption(self::OPTION_DEMO_LIMITS, [ 'max_items' => 100, 'max_orders' => 50, 'max_productions' => 30, 'max_users' => 5, 'max_storage_gb' => 1, 'max_ai_tokens' => 100000, ]); } /** * 데모 → 정식 테넌트로 전환 * fillable 밖의 컬럼이므로 forceFill 사용 */ public function convertToProduction(): void { $this->forceFill([ 'tenant_type' => self::TYPE_STD, 'demo_expires_at' => null, 'demo_source_partner_id' => null, ])->save(); // options에서 데모 관련 키 제거 $options = $this->options ?? []; unset( $options[self::OPTION_DEMO_PRESET], $options[self::OPTION_DEMO_LIMITS], $options[self::OPTION_DEMO_READ_ONLY] ); $this->update(['options' => $options ?: null]); } }