Files
sam-api/app/Models/Tenants/Tenant.php
김보곤 39844a3ba0 fix: [tenant] 데모 관련 필드를 fillable에서 제거
- tenant_type, demo_expires_at, demo_source_partner_id를 fillable에서 제외
- 기존 mass assignment 동작에 영향 없도록 보호
- convertToProduction()에서 forceFill() 사용으로 변경
2026-03-14 14:41:55 +09:00

404 lines
9.5 KiB
PHP

<?php
namespace App\Models\Tenants;
use App\Models\Commons\File;
use App\Models\Members\User;
use App\Models\Members\UserRole;
use App\Models\Members\UserTenant;
use App\Models\Permissions\Role;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @mixin IdeHelperTenant
*/
class Tenant extends Model
{
use ModelTrait, SoftDeletes;
// 데모 테넌트 유형 상수
const TYPE_STD = 'STD';
const TYPE_TPL = 'TPL';
const TYPE_HQ = 'HQ';
const TYPE_DEMO_SHOWCASE = 'DEMO_SHOWCASE';
const TYPE_DEMO_PARTNER = 'DEMO_PARTNER';
const TYPE_DEMO_TRIAL = 'DEMO_TRIAL';
const DEMO_TYPES = [
self::TYPE_DEMO_SHOWCASE,
self::TYPE_DEMO_PARTNER,
self::TYPE_DEMO_TRIAL,
];
// 데모 options 키 상수
const OPTION_DEMO_PRESET = 'demo_preset';
const OPTION_DEMO_LIMITS = 'demo_limits';
const OPTION_DEMO_READ_ONLY = 'demo_read_only';
protected $fillable = [
'company_name',
'code',
'email',
'phone',
'address',
'business_num',
'corp_reg_no',
'ceo_name',
'homepage',
'fax',
'logo',
'admin_memo',
'options',
'tenant_st_code',
'billing_tp_code',
];
protected $guarded = [
'id',
'created_at',
'updated_at',
'deleted_at',
'plan_id',
'subscription_id',
];
protected $casts = [
'trial_ends_at' => '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]);
}
}