437 lines
12 KiB
PHP
437 lines
12 KiB
PHP
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Support\Facades\Crypt;
|
||
|
||
class Member extends Model
|
||
{
|
||
use HasFactory;
|
||
|
||
// Membership status constants
|
||
const STATUS_PENDING = 'pending';
|
||
const STATUS_ACTIVE = 'active';
|
||
const STATUS_EXPIRED = 'expired';
|
||
const STATUS_SUSPENDED = 'suspended';
|
||
|
||
// Membership type constants (per charter Article 7)
|
||
const TYPE_INDIVIDUAL = 'individual'; // 個人會員
|
||
const TYPE_SPONSOR = 'sponsor'; // 贊助會員
|
||
const TYPE_HONORARY_ACADEMIC = 'honorary_academic'; // 榮譽學術會員
|
||
|
||
// Legacy types for backward compatibility
|
||
const TYPE_REGULAR = 'individual'; // Alias for individual
|
||
const TYPE_HONORARY = 'honorary_academic'; // Alias for honorary_academic
|
||
|
||
// Disability certificate status constants
|
||
const DISABILITY_STATUS_PENDING = 'pending';
|
||
const DISABILITY_STATUS_APPROVED = 'approved';
|
||
const DISABILITY_STATUS_REJECTED = 'rejected';
|
||
|
||
// Identity type constants (病友/家長)
|
||
const IDENTITY_PATIENT = 'patient'; // 病友
|
||
const IDENTITY_PARENT = 'parent'; // 家長/父母
|
||
const IDENTITY_SOCIAL = 'social'; // 社會人士
|
||
const IDENTITY_OTHER = 'other'; // 其他
|
||
|
||
protected $fillable = [
|
||
'user_id',
|
||
'member_number',
|
||
'full_name',
|
||
'email',
|
||
'phone',
|
||
'phone_home',
|
||
'phone_fax',
|
||
'address_line_1',
|
||
'address_line_2',
|
||
'city',
|
||
'postal_code',
|
||
'birth_date',
|
||
'gender',
|
||
'occupation',
|
||
'employer',
|
||
'job_title',
|
||
'applied_at',
|
||
'emergency_contact_name',
|
||
'emergency_contact_phone',
|
||
'national_id',
|
||
'national_id_encrypted',
|
||
'national_id_hash',
|
||
'membership_started_at',
|
||
'membership_expires_at',
|
||
'membership_status',
|
||
'membership_type',
|
||
'identity_type',
|
||
'identity_other_text',
|
||
'guardian_member_id',
|
||
'disability_certificate_path',
|
||
'disability_certificate_status',
|
||
'disability_verified_by',
|
||
'disability_verified_at',
|
||
'disability_rejection_reason',
|
||
];
|
||
|
||
protected $casts = [
|
||
'membership_started_at' => 'date',
|
||
'membership_expires_at' => 'date',
|
||
'birth_date' => 'date',
|
||
'applied_at' => 'date',
|
||
'disability_verified_at' => 'datetime',
|
||
];
|
||
|
||
protected $appends = ['national_id'];
|
||
|
||
public function user()
|
||
{
|
||
return $this->belongsTo(User::class);
|
||
}
|
||
|
||
public function payments()
|
||
{
|
||
return $this->hasMany(MembershipPayment::class);
|
||
}
|
||
|
||
/**
|
||
* 監護人(父母)
|
||
*/
|
||
public function guardian()
|
||
{
|
||
return $this->belongsTo(Member::class, 'guardian_member_id');
|
||
}
|
||
|
||
/**
|
||
* 被監護人(子女)
|
||
*/
|
||
public function dependents()
|
||
{
|
||
return $this->hasMany(Member::class, 'guardian_member_id');
|
||
}
|
||
|
||
/**
|
||
* 是否為病友
|
||
*/
|
||
public function isPatient(): bool
|
||
{
|
||
return $this->identity_type === self::IDENTITY_PATIENT;
|
||
}
|
||
|
||
/**
|
||
* 是否為家長/父母
|
||
*/
|
||
public function isParent(): bool
|
||
{
|
||
return $this->identity_type === self::IDENTITY_PARENT;
|
||
}
|
||
|
||
/**
|
||
* 取得身份類型標籤
|
||
*/
|
||
public function getIdentityTypeLabelAttribute(): string
|
||
{
|
||
return match ($this->identity_type) {
|
||
self::IDENTITY_PATIENT => '病友',
|
||
self::IDENTITY_PARENT => '家長/父母',
|
||
self::IDENTITY_SOCIAL => '社會人士',
|
||
self::IDENTITY_OTHER => $this->identity_other_text ? '其他(' . $this->identity_other_text . ')' : '其他',
|
||
default => '未設定',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 關聯的收入記錄
|
||
*/
|
||
public function incomes()
|
||
{
|
||
return $this->hasMany(Income::class);
|
||
}
|
||
|
||
/**
|
||
* 取得會員的會費收入記錄
|
||
*/
|
||
public function getMembershipFeeIncomes()
|
||
{
|
||
return $this->incomes()
|
||
->whereIn('income_type', [
|
||
Income::TYPE_MEMBERSHIP_FEE,
|
||
Income::TYPE_ENTRANCE_FEE
|
||
])
|
||
->get();
|
||
}
|
||
|
||
/**
|
||
* 取得會員的總收入金額
|
||
*/
|
||
public function getTotalIncomeAttribute(): float
|
||
{
|
||
return $this->incomes()
|
||
->where('status', Income::STATUS_CONFIRMED)
|
||
->sum('amount');
|
||
}
|
||
|
||
/**
|
||
* Get the decrypted national ID
|
||
*/
|
||
public function getNationalIdAttribute(): ?string
|
||
{
|
||
if (empty($this->national_id_encrypted)) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
return Crypt::decryptString($this->national_id_encrypted);
|
||
} catch (\Exception $e) {
|
||
\Log::error('Failed to decrypt national_id', [
|
||
'member_id' => $this->id,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set the national ID (encrypt and hash)
|
||
*/
|
||
public function setNationalIdAttribute(?string $value): void
|
||
{
|
||
if (empty($value)) {
|
||
$this->attributes['national_id_encrypted'] = null;
|
||
$this->attributes['national_id_hash'] = null;
|
||
return;
|
||
}
|
||
|
||
$this->attributes['national_id_encrypted'] = Crypt::encryptString($value);
|
||
$this->attributes['national_id_hash'] = hash('sha256', $value);
|
||
}
|
||
|
||
/**
|
||
* Check if membership status is pending (not yet paid/verified)
|
||
*/
|
||
public function isPending(): bool
|
||
{
|
||
return $this->membership_status === self::STATUS_PENDING;
|
||
}
|
||
|
||
/**
|
||
* Check if membership is active (paid & activated)
|
||
*/
|
||
public function isActive(): bool
|
||
{
|
||
return $this->membership_status === self::STATUS_ACTIVE;
|
||
}
|
||
|
||
/**
|
||
* Check if membership is expired
|
||
*/
|
||
public function isExpired(): bool
|
||
{
|
||
return $this->membership_status === self::STATUS_EXPIRED;
|
||
}
|
||
|
||
/**
|
||
* Check if membership is suspended
|
||
*/
|
||
public function isSuspended(): bool
|
||
{
|
||
return $this->membership_status === self::STATUS_SUSPENDED;
|
||
}
|
||
|
||
/**
|
||
* Check if member has paid membership (active status with valid dates)
|
||
*/
|
||
public function hasPaidMembership(): bool
|
||
{
|
||
return $this->isActive()
|
||
&& $this->membership_started_at
|
||
&& $this->membership_expires_at
|
||
&& $this->membership_expires_at->isFuture();
|
||
}
|
||
|
||
/**
|
||
* Get the membership status badge class for display
|
||
*/
|
||
public function getMembershipStatusBadgeAttribute(): string
|
||
{
|
||
$label = $this->membership_status_label;
|
||
|
||
$class = match($this->membership_status) {
|
||
self::STATUS_PENDING => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||
self::STATUS_ACTIVE => 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||
self::STATUS_EXPIRED => 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||
self::STATUS_SUSPENDED => 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||
default => 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||
};
|
||
|
||
return trim("{$label} {$class}");
|
||
}
|
||
|
||
/**
|
||
* Get the membership status label in Chinese
|
||
*/
|
||
public function getMembershipStatusLabelAttribute(): string
|
||
{
|
||
return match($this->membership_status) {
|
||
self::STATUS_PENDING => '待繳費',
|
||
self::STATUS_ACTIVE => '已啟用',
|
||
self::STATUS_EXPIRED => '已過期',
|
||
self::STATUS_SUSPENDED => '已暫停',
|
||
default => $this->membership_status,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get the membership type label in Chinese
|
||
*/
|
||
public function getMembershipTypeLabelAttribute(): string
|
||
{
|
||
return match($this->membership_type) {
|
||
'individual', 'regular' => '個人會員',
|
||
'sponsor' => '贊助會員',
|
||
'honorary_academic', 'honorary' => '榮譽學術會員',
|
||
// Legacy types
|
||
'lifetime' => '終身會員',
|
||
'student' => '學生會員',
|
||
default => $this->membership_type,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get pending payment (if any)
|
||
*/
|
||
public function getPendingPayment()
|
||
{
|
||
return $this->payments()
|
||
->where('status', MembershipPayment::STATUS_PENDING)
|
||
->orWhere('status', MembershipPayment::STATUS_APPROVED_CASHIER)
|
||
->orWhere('status', MembershipPayment::STATUS_APPROVED_ACCOUNTANT)
|
||
->latest()
|
||
->first();
|
||
}
|
||
|
||
/**
|
||
* Check if member can submit payment
|
||
*/
|
||
public function canSubmitPayment(): bool
|
||
{
|
||
// Can submit if pending status and no pending payment
|
||
return $this->isPending() && !$this->getPendingPayment();
|
||
}
|
||
|
||
// ========== 身心障礙相關 ==========
|
||
|
||
/**
|
||
* 身心障礙手冊審核人
|
||
*/
|
||
public function disabilityVerifiedBy()
|
||
{
|
||
return $this->belongsTo(User::class, 'disability_verified_by');
|
||
}
|
||
|
||
/**
|
||
* 是否有上傳身心障礙手冊
|
||
*/
|
||
public function hasDisabilityCertificate(): bool
|
||
{
|
||
return !empty($this->disability_certificate_path);
|
||
}
|
||
|
||
/**
|
||
* 身心障礙手冊是否待審核
|
||
*/
|
||
public function isDisabilityPending(): bool
|
||
{
|
||
return $this->disability_certificate_status === self::DISABILITY_STATUS_PENDING;
|
||
}
|
||
|
||
/**
|
||
* 身心障礙手冊是否已通過審核
|
||
*/
|
||
public function hasApprovedDisability(): bool
|
||
{
|
||
return $this->disability_certificate_status === self::DISABILITY_STATUS_APPROVED;
|
||
}
|
||
|
||
/**
|
||
* 身心障礙手冊是否被駁回
|
||
*/
|
||
public function isDisabilityRejected(): bool
|
||
{
|
||
return $this->disability_certificate_status === self::DISABILITY_STATUS_REJECTED;
|
||
}
|
||
|
||
/**
|
||
* 取得身心障礙狀態標籤
|
||
*/
|
||
public function getDisabilityStatusLabelAttribute(): string
|
||
{
|
||
if (!$this->hasDisabilityCertificate()) {
|
||
return '未上傳';
|
||
}
|
||
|
||
return match ($this->disability_certificate_status) {
|
||
self::DISABILITY_STATUS_PENDING => '審核中',
|
||
self::DISABILITY_STATUS_APPROVED => '已通過',
|
||
self::DISABILITY_STATUS_REJECTED => '已駁回',
|
||
default => '未知',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 取得身心障礙狀態的 Badge 樣式
|
||
*/
|
||
public function getDisabilityStatusBadgeAttribute(): string
|
||
{
|
||
if (!$this->hasDisabilityCertificate()) {
|
||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||
}
|
||
|
||
return match ($this->disability_certificate_status) {
|
||
self::DISABILITY_STATUS_PENDING => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||
self::DISABILITY_STATUS_APPROVED => 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||
self::DISABILITY_STATUS_REJECTED => 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||
default => 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 審核通過身心障礙手冊
|
||
*/
|
||
public function approveDisabilityCertificate(User $verifier): void
|
||
{
|
||
$this->update([
|
||
'disability_certificate_status' => self::DISABILITY_STATUS_APPROVED,
|
||
'disability_verified_by' => $verifier->id,
|
||
'disability_verified_at' => now(),
|
||
'disability_rejection_reason' => null,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 駁回身心障礙手冊
|
||
*/
|
||
public function rejectDisabilityCertificate(User $verifier, string $reason): void
|
||
{
|
||
$this->update([
|
||
'disability_certificate_status' => self::DISABILITY_STATUS_REJECTED,
|
||
'disability_verified_by' => $verifier->id,
|
||
'disability_verified_at' => now(),
|
||
'disability_rejection_reason' => $reason,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 判斷下一次應繳哪種會費
|
||
*/
|
||
public function getNextFeeType(): string
|
||
{
|
||
// 新會員(從未啟用過)= 入會會費
|
||
if ($this->membership_started_at === null) {
|
||
return MembershipPayment::FEE_TYPE_ENTRANCE;
|
||
}
|
||
|
||
// 已有會籍 = 常年會費
|
||
return MembershipPayment::FEE_TYPE_ANNUAL;
|
||
}
|
||
}
|