Initial commit
This commit is contained in:
28
app/Models/AuditLog.php
Normal file
28
app/Models/AuditLog.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AuditLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'action',
|
||||
'auditable_type',
|
||||
'auditable_id',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
213
app/Models/BankReconciliation.php
Normal file
213
app/Models/BankReconciliation.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BankReconciliation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'reconciliation_month',
|
||||
'bank_statement_balance',
|
||||
'bank_statement_date',
|
||||
'bank_statement_file_path',
|
||||
'system_book_balance',
|
||||
'outstanding_checks',
|
||||
'deposits_in_transit',
|
||||
'bank_charges',
|
||||
'adjusted_balance',
|
||||
'discrepancy_amount',
|
||||
'reconciliation_status',
|
||||
'prepared_by_cashier_id',
|
||||
'reviewed_by_accountant_id',
|
||||
'approved_by_manager_id',
|
||||
'prepared_at',
|
||||
'reviewed_at',
|
||||
'approved_at',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'reconciliation_month' => 'date',
|
||||
'bank_statement_balance' => 'decimal:2',
|
||||
'bank_statement_date' => 'date',
|
||||
'system_book_balance' => 'decimal:2',
|
||||
'outstanding_checks' => 'array',
|
||||
'deposits_in_transit' => 'array',
|
||||
'bank_charges' => 'array',
|
||||
'adjusted_balance' => 'decimal:2',
|
||||
'discrepancy_amount' => 'decimal:2',
|
||||
'prepared_at' => 'datetime',
|
||||
'reviewed_at' => 'datetime',
|
||||
'approved_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 狀態常數
|
||||
*/
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_COMPLETED = 'completed';
|
||||
const STATUS_DISCREPANCY = 'discrepancy';
|
||||
|
||||
/**
|
||||
* 製作調節表的出納人員
|
||||
*/
|
||||
public function preparedByCashier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'prepared_by_cashier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 覆核的會計人員
|
||||
*/
|
||||
public function reviewedByAccountant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'reviewed_by_accountant_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 核准的主管
|
||||
*/
|
||||
public function approvedByManager(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by_manager_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算調整後餘額
|
||||
*/
|
||||
public function calculateAdjustedBalance(): float
|
||||
{
|
||||
$adjusted = $this->system_book_balance;
|
||||
|
||||
// 加上在途存款
|
||||
if ($this->deposits_in_transit) {
|
||||
foreach ($this->deposits_in_transit as $deposit) {
|
||||
$adjusted += floatval($deposit['amount'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 減去未兌現支票
|
||||
if ($this->outstanding_checks) {
|
||||
foreach ($this->outstanding_checks as $check) {
|
||||
$adjusted -= floatval($check['amount'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 減去銀行手續費
|
||||
if ($this->bank_charges) {
|
||||
foreach ($this->bank_charges as $charge) {
|
||||
$adjusted -= floatval($charge['amount'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $adjusted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算差異金額
|
||||
*/
|
||||
public function calculateDiscrepancy(): float
|
||||
{
|
||||
return abs($this->adjusted_balance - $this->bank_statement_balance);
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查是否有差異
|
||||
*/
|
||||
public function hasDiscrepancy(float $tolerance = 0.01): bool
|
||||
{
|
||||
return $this->calculateDiscrepancy() > $tolerance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否待覆核
|
||||
*/
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->reconciliation_status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已完成
|
||||
*/
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->reconciliation_status === self::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有差異待處理
|
||||
*/
|
||||
public function hasUnresolvedDiscrepancy(): bool
|
||||
{
|
||||
return $this->reconciliation_status === self::STATUS_DISCREPANCY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否可以被會計覆核
|
||||
*/
|
||||
public function canBeReviewed(): bool
|
||||
{
|
||||
return $this->isPending() && $this->prepared_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否可以被主管核准
|
||||
*/
|
||||
public function canBeApproved(): bool
|
||||
{
|
||||
return $this->reviewed_at !== null && $this->approved_at === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得狀態文字
|
||||
*/
|
||||
public function getStatusText(): string
|
||||
{
|
||||
return match ($this->reconciliation_status) {
|
||||
self::STATUS_PENDING => '待覆核',
|
||||
self::STATUS_COMPLETED => '已完成',
|
||||
self::STATUS_DISCREPANCY => '有差異',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得未達帳項總計
|
||||
*/
|
||||
public function getOutstandingItemsSummary(): array
|
||||
{
|
||||
$checksTotal = 0;
|
||||
if ($this->outstanding_checks) {
|
||||
foreach ($this->outstanding_checks as $check) {
|
||||
$checksTotal += floatval($check['amount'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$depositsTotal = 0;
|
||||
if ($this->deposits_in_transit) {
|
||||
foreach ($this->deposits_in_transit as $deposit) {
|
||||
$depositsTotal += floatval($deposit['amount'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$chargesTotal = 0;
|
||||
if ($this->bank_charges) {
|
||||
foreach ($this->bank_charges as $charge) {
|
||||
$chargesTotal += floatval($charge['amount'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'outstanding_checks_total' => $checksTotal,
|
||||
'deposits_in_transit_total' => $depositsTotal,
|
||||
'bank_charges_total' => $chargesTotal,
|
||||
'net_adjustment' => $depositsTotal - $checksTotal - $chargesTotal,
|
||||
];
|
||||
}
|
||||
}
|
||||
121
app/Models/Budget.php
Normal file
121
app/Models/Budget.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Budget extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_SUBMITTED = 'submitted';
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_CLOSED = 'closed';
|
||||
|
||||
protected $fillable = [
|
||||
'fiscal_year',
|
||||
'name',
|
||||
'period_type',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'status',
|
||||
'created_by_user_id',
|
||||
'approved_by_user_id',
|
||||
'approved_at',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'fiscal_year' => 'integer',
|
||||
'period_start' => 'date',
|
||||
'period_end' => 'date',
|
||||
'approved_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function approvedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by_user_id');
|
||||
}
|
||||
|
||||
public function budgetItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(BudgetItem::class);
|
||||
}
|
||||
|
||||
public function financialReports(): HasMany
|
||||
{
|
||||
return $this->hasMany(FinancialReport::class);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function isDraft(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
public function isApproved(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_APPROVED;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
public function isClosed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_CLOSED;
|
||||
}
|
||||
|
||||
public function canBeEdited(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_SUBMITTED]);
|
||||
}
|
||||
|
||||
public function canBeApproved(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_SUBMITTED;
|
||||
}
|
||||
|
||||
public function getTotalBudgetedIncomeAttribute(): float
|
||||
{
|
||||
return $this->budgetItems()
|
||||
->whereHas('chartOfAccount', fn($q) => $q->where('account_type', 'income'))
|
||||
->sum('budgeted_amount');
|
||||
}
|
||||
|
||||
public function getTotalBudgetedExpenseAttribute(): float
|
||||
{
|
||||
return $this->budgetItems()
|
||||
->whereHas('chartOfAccount', fn($q) => $q->where('account_type', 'expense'))
|
||||
->sum('budgeted_amount');
|
||||
}
|
||||
|
||||
public function getTotalActualIncomeAttribute(): float
|
||||
{
|
||||
return $this->budgetItems()
|
||||
->whereHas('chartOfAccount', fn($q) => $q->where('account_type', 'income'))
|
||||
->sum('actual_amount');
|
||||
}
|
||||
|
||||
public function getTotalActualExpenseAttribute(): float
|
||||
{
|
||||
return $this->budgetItems()
|
||||
->whereHas('chartOfAccount', fn($q) => $q->where('account_type', 'expense'))
|
||||
->sum('actual_amount');
|
||||
}
|
||||
}
|
||||
76
app/Models/BudgetItem.php
Normal file
76
app/Models/BudgetItem.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class BudgetItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'budget_id',
|
||||
'chart_of_account_id',
|
||||
'budgeted_amount',
|
||||
'actual_amount',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'budgeted_amount' => 'decimal:2',
|
||||
'actual_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function budget(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Budget::class);
|
||||
}
|
||||
|
||||
public function chartOfAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChartOfAccount::class);
|
||||
}
|
||||
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Transaction::class);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function getVarianceAttribute(): float
|
||||
{
|
||||
return $this->actual_amount - $this->budgeted_amount;
|
||||
}
|
||||
|
||||
public function getVariancePercentageAttribute(): float
|
||||
{
|
||||
if ($this->budgeted_amount == 0) {
|
||||
return 0;
|
||||
}
|
||||
return ($this->variance / $this->budgeted_amount) * 100;
|
||||
}
|
||||
|
||||
public function getRemainingBudgetAttribute(): float
|
||||
{
|
||||
return $this->budgeted_amount - $this->actual_amount;
|
||||
}
|
||||
|
||||
public function isOverBudget(): bool
|
||||
{
|
||||
return $this->actual_amount > $this->budgeted_amount;
|
||||
}
|
||||
|
||||
public function getUtilizationPercentageAttribute(): float
|
||||
{
|
||||
if ($this->budgeted_amount == 0) {
|
||||
return 0;
|
||||
}
|
||||
return ($this->actual_amount / $this->budgeted_amount) * 100;
|
||||
}
|
||||
}
|
||||
132
app/Models/CashierLedgerEntry.php
Normal file
132
app/Models/CashierLedgerEntry.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CashierLedgerEntry extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'finance_document_id',
|
||||
'entry_date',
|
||||
'entry_type',
|
||||
'payment_method',
|
||||
'bank_account',
|
||||
'amount',
|
||||
'balance_before',
|
||||
'balance_after',
|
||||
'receipt_number',
|
||||
'transaction_reference',
|
||||
'recorded_by_cashier_id',
|
||||
'recorded_at',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'entry_date' => 'date',
|
||||
'amount' => 'decimal:2',
|
||||
'balance_before' => 'decimal:2',
|
||||
'balance_after' => 'decimal:2',
|
||||
'recorded_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 類型常數
|
||||
*/
|
||||
const ENTRY_TYPE_RECEIPT = 'receipt';
|
||||
const ENTRY_TYPE_PAYMENT = 'payment';
|
||||
|
||||
const PAYMENT_METHOD_BANK_TRANSFER = 'bank_transfer';
|
||||
const PAYMENT_METHOD_CHECK = 'check';
|
||||
const PAYMENT_METHOD_CASH = 'cash';
|
||||
|
||||
/**
|
||||
* 關聯到財務申請單
|
||||
*/
|
||||
public function financeDocument(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FinanceDocument::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 記錄的出納人員
|
||||
*/
|
||||
public function recordedByCashier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'recorded_by_cashier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算交易後餘額
|
||||
*/
|
||||
public function calculateBalanceAfter(float $currentBalance): float
|
||||
{
|
||||
if ($this->entry_type === self::ENTRY_TYPE_RECEIPT) {
|
||||
return $currentBalance + $this->amount;
|
||||
} else {
|
||||
return $currentBalance - $this->amount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得最新餘額(從最後一筆記錄)
|
||||
*/
|
||||
public static function getLatestBalance(string $bankAccount = null): float
|
||||
{
|
||||
$query = self::orderBy('entry_date', 'desc')
|
||||
->orderBy('id', 'desc');
|
||||
|
||||
if ($bankAccount) {
|
||||
$query->where('bank_account', $bankAccount);
|
||||
}
|
||||
|
||||
$latest = $query->first();
|
||||
|
||||
return $latest ? $latest->balance_after : 0.00;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得類型文字
|
||||
*/
|
||||
public function getEntryTypeText(): string
|
||||
{
|
||||
return match ($this->entry_type) {
|
||||
self::ENTRY_TYPE_RECEIPT => '收入',
|
||||
self::ENTRY_TYPE_PAYMENT => '支出',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得付款方式文字
|
||||
*/
|
||||
public function getPaymentMethodText(): string
|
||||
{
|
||||
return match ($this->payment_method) {
|
||||
self::PAYMENT_METHOD_BANK_TRANSFER => '銀行轉帳',
|
||||
self::PAYMENT_METHOD_CHECK => '支票',
|
||||
self::PAYMENT_METHOD_CASH => '現金',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否為收入記錄
|
||||
*/
|
||||
public function isReceipt(): bool
|
||||
{
|
||||
return $this->entry_type === self::ENTRY_TYPE_RECEIPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否為支出記錄
|
||||
*/
|
||||
public function isPayment(): bool
|
||||
{
|
||||
return $this->entry_type === self::ENTRY_TYPE_PAYMENT;
|
||||
}
|
||||
}
|
||||
84
app/Models/ChartOfAccount.php
Normal file
84
app/Models/ChartOfAccount.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ChartOfAccount extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'account_code',
|
||||
'account_name_zh',
|
||||
'account_name_en',
|
||||
'account_type',
|
||||
'category',
|
||||
'parent_account_id',
|
||||
'is_active',
|
||||
'display_order',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'display_order' => 'integer',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function parentAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChartOfAccount::class, 'parent_account_id');
|
||||
}
|
||||
|
||||
public function childAccounts(): HasMany
|
||||
{
|
||||
return $this->hasMany(ChartOfAccount::class, 'parent_account_id')->orderBy('display_order');
|
||||
}
|
||||
|
||||
public function budgetItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(BudgetItem::class);
|
||||
}
|
||||
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Transaction::class);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function getFullNameAttribute(): string
|
||||
{
|
||||
return "{$this->account_code} - {$this->account_name_zh}";
|
||||
}
|
||||
|
||||
public function isIncome(): bool
|
||||
{
|
||||
return $this->account_type === 'income';
|
||||
}
|
||||
|
||||
public function isExpense(): bool
|
||||
{
|
||||
return $this->account_type === 'expense';
|
||||
}
|
||||
|
||||
public function isAsset(): bool
|
||||
{
|
||||
return $this->account_type === 'asset';
|
||||
}
|
||||
|
||||
public function isLiability(): bool
|
||||
{
|
||||
return $this->account_type === 'liability';
|
||||
}
|
||||
|
||||
public function isNetAsset(): bool
|
||||
{
|
||||
return $this->account_type === 'net_asset';
|
||||
}
|
||||
}
|
||||
42
app/Models/CustomField.php
Normal file
42
app/Models/CustomField.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class CustomField extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_TEXT = 'text';
|
||||
public const TYPE_NUMBER = 'number';
|
||||
public const TYPE_DATE = 'date';
|
||||
public const TYPE_SELECT = 'select';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'field_type',
|
||||
'options',
|
||||
'applies_to_issue_types',
|
||||
'is_required',
|
||||
'display_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'options' => 'array',
|
||||
'applies_to_issue_types' => 'array',
|
||||
'is_required' => 'boolean',
|
||||
];
|
||||
|
||||
public function values(): HasMany
|
||||
{
|
||||
return $this->hasMany(CustomFieldValue::class);
|
||||
}
|
||||
|
||||
public function appliesToIssueType(string $issueType): bool
|
||||
{
|
||||
return in_array($issueType, $this->applies_to_issue_types ?? []);
|
||||
}
|
||||
}
|
||||
45
app/Models/CustomFieldValue.php
Normal file
45
app/Models/CustomFieldValue.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class CustomFieldValue extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'custom_field_id',
|
||||
'customizable_type',
|
||||
'customizable_id',
|
||||
'value',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'value' => 'array',
|
||||
];
|
||||
|
||||
public function customField(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomField::class);
|
||||
}
|
||||
|
||||
public function customizable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function getDisplayValueAttribute(): string
|
||||
{
|
||||
$value = $this->value;
|
||||
|
||||
return match($this->customField->field_type) {
|
||||
CustomField::TYPE_DATE => \Carbon\Carbon::parse($value)->format('Y-m-d'),
|
||||
CustomField::TYPE_SELECT => is_array($value) ? implode(', ', $value) : $value,
|
||||
default => (string) $value,
|
||||
};
|
||||
}
|
||||
}
|
||||
446
app/Models/Document.php
Normal file
446
app/Models/Document.php
Normal file
@@ -0,0 +1,446 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Document extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'document_category_id',
|
||||
'title',
|
||||
'document_number',
|
||||
'description',
|
||||
'public_uuid',
|
||||
'access_level',
|
||||
'current_version_id',
|
||||
'status',
|
||||
'archived_at',
|
||||
'created_by_user_id',
|
||||
'last_updated_by_user_id',
|
||||
'view_count',
|
||||
'download_count',
|
||||
'version_count',
|
||||
'expires_at',
|
||||
'auto_archive_on_expiry',
|
||||
'expiry_notice',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'archived_at' => 'datetime',
|
||||
'expires_at' => 'date',
|
||||
'auto_archive_on_expiry' => 'boolean',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// Auto-generate UUID for public sharing
|
||||
static::creating(function ($document) {
|
||||
if (empty($document->public_uuid)) {
|
||||
$document->public_uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
/**
|
||||
* Get the category this document belongs to
|
||||
*/
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(DocumentCategory::class, 'document_category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all versions of this document
|
||||
*/
|
||||
public function versions()
|
||||
{
|
||||
return $this->hasMany(DocumentVersion::class)->orderBy('uploaded_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current published version
|
||||
*/
|
||||
public function currentVersion()
|
||||
{
|
||||
return $this->belongsTo(DocumentVersion::class, 'current_version_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user who created this document
|
||||
*/
|
||||
public function createdBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user who last updated this document
|
||||
*/
|
||||
public function lastUpdatedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'last_updated_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tags for this document
|
||||
*/
|
||||
public function tags()
|
||||
{
|
||||
return $this->belongsToMany(DocumentTag::class, 'document_document_tag')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access logs for this document
|
||||
*/
|
||||
public function accessLogs()
|
||||
{
|
||||
return $this->hasMany(DocumentAccessLog::class)->orderBy('accessed_at', 'desc');
|
||||
}
|
||||
|
||||
// ==================== Status Check Methods ====================
|
||||
|
||||
/**
|
||||
* Check if document is active
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document is archived
|
||||
*/
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->status === 'archived';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document is publicly accessible
|
||||
*/
|
||||
public function isPublic(): bool
|
||||
{
|
||||
return $this->access_level === 'public';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document requires membership
|
||||
*/
|
||||
public function requiresMembership(): bool
|
||||
{
|
||||
return $this->access_level === 'members';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document is admin-only
|
||||
*/
|
||||
public function isAdminOnly(): bool
|
||||
{
|
||||
return in_array($this->access_level, ['admin', 'board']);
|
||||
}
|
||||
|
||||
// ==================== Version Control Methods ====================
|
||||
|
||||
/**
|
||||
* Add a new version to this document
|
||||
*/
|
||||
public function addVersion(
|
||||
string $filePath,
|
||||
string $originalFilename,
|
||||
string $mimeType,
|
||||
int $fileSize,
|
||||
User $uploadedBy,
|
||||
?string $versionNotes = null
|
||||
): DocumentVersion {
|
||||
// Calculate next version number
|
||||
$nextVersionNumber = $this->calculateNextVersionNumber();
|
||||
|
||||
// Unset current version flag on existing versions
|
||||
$this->versions()->update(['is_current' => false]);
|
||||
|
||||
// Create new version
|
||||
$version = $this->versions()->create([
|
||||
'version_number' => $nextVersionNumber,
|
||||
'version_notes' => $versionNotes,
|
||||
'is_current' => true,
|
||||
'file_path' => $filePath,
|
||||
'original_filename' => $originalFilename,
|
||||
'mime_type' => $mimeType,
|
||||
'file_size' => $fileSize,
|
||||
'file_hash' => hash_file('sha256', storage_path('app/' . $filePath)),
|
||||
'uploaded_by_user_id' => $uploadedBy->id,
|
||||
'uploaded_at' => now(),
|
||||
]);
|
||||
|
||||
// Update document's current_version_id and increment version count
|
||||
$this->update([
|
||||
'current_version_id' => $version->id,
|
||||
'version_count' => $this->version_count + 1,
|
||||
'last_updated_by_user_id' => $uploadedBy->id,
|
||||
]);
|
||||
|
||||
return $version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next version number
|
||||
*/
|
||||
private function calculateNextVersionNumber(): string
|
||||
{
|
||||
$latestVersion = $this->versions()->orderBy('id', 'desc')->first();
|
||||
|
||||
if (!$latestVersion) {
|
||||
return '1.0';
|
||||
}
|
||||
|
||||
// Parse current version (e.g., "1.5" -> major: 1, minor: 5)
|
||||
$parts = explode('.', $latestVersion->version_number);
|
||||
$major = (int) ($parts[0] ?? 1);
|
||||
$minor = (int) ($parts[1] ?? 0);
|
||||
|
||||
// Increment minor version
|
||||
$minor++;
|
||||
|
||||
return "{$major}.{$minor}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote an old version to be the current version
|
||||
*/
|
||||
public function promoteVersion(DocumentVersion $version, User $user): void
|
||||
{
|
||||
if ($version->document_id !== $this->id) {
|
||||
throw new \Exception('Version does not belong to this document');
|
||||
}
|
||||
|
||||
// Unset current flag on all versions
|
||||
$this->versions()->update(['is_current' => false]);
|
||||
|
||||
// Set this version as current
|
||||
$version->update(['is_current' => true]);
|
||||
|
||||
// Update document's current_version_id
|
||||
$this->update([
|
||||
'current_version_id' => $version->id,
|
||||
'last_updated_by_user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version history with comparison data
|
||||
*/
|
||||
public function getVersionHistory(): array
|
||||
{
|
||||
$versions = $this->versions()->with('uploadedBy')->get();
|
||||
$history = [];
|
||||
|
||||
foreach ($versions as $index => $version) {
|
||||
$previousVersion = $versions->get($index + 1);
|
||||
|
||||
$history[] = [
|
||||
'version' => $version,
|
||||
'size_change' => $previousVersion ? $version->file_size - $previousVersion->file_size : 0,
|
||||
'days_since_previous' => $previousVersion ? $version->uploaded_at->diffInDays($previousVersion->uploaded_at) : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $history;
|
||||
}
|
||||
|
||||
// ==================== Access Control Methods ====================
|
||||
|
||||
/**
|
||||
* Check if a user can view this document
|
||||
*/
|
||||
public function canBeViewedBy(?User $user): bool
|
||||
{
|
||||
if ($this->isPublic()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->is_admin || $user->hasRole('admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->access_level === 'members') {
|
||||
return $user->member && $user->member->hasPaidMembership();
|
||||
}
|
||||
|
||||
if ($this->access_level === 'board') {
|
||||
return $user->hasRole(['admin', 'chair', 'board']);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log access to this document
|
||||
*/
|
||||
public function logAccess(string $action, ?User $user = null): void
|
||||
{
|
||||
$this->accessLogs()->create([
|
||||
'document_version_id' => $this->current_version_id,
|
||||
'action' => $action,
|
||||
'user_id' => $user?->id,
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
'accessed_at' => now(),
|
||||
]);
|
||||
|
||||
// Increment counters
|
||||
if ($action === 'view') {
|
||||
$this->increment('view_count');
|
||||
} elseif ($action === 'download') {
|
||||
$this->increment('download_count');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
* Get the public URL for this document
|
||||
*/
|
||||
public function getPublicUrl(): string
|
||||
{
|
||||
return route('documents.public.show', $this->public_uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access level label in Chinese
|
||||
*/
|
||||
public function getAccessLevelLabel(): string
|
||||
{
|
||||
return match($this->access_level) {
|
||||
'public' => '公開',
|
||||
'members' => '會員',
|
||||
'admin' => '管理員',
|
||||
'board' => '理事會',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status label in Chinese
|
||||
*/
|
||||
public function getStatusLabel(): string
|
||||
{
|
||||
return match($this->status) {
|
||||
'active' => '啟用',
|
||||
'archived' => '封存',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive this document
|
||||
*/
|
||||
public function archive(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'archived',
|
||||
'archived_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore archived document
|
||||
*/
|
||||
public function unarchive(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'active',
|
||||
'archived_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document is expired
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
if (!$this->expires_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document is expiring soon (within 30 days)
|
||||
*/
|
||||
public function isExpiringSoon(int $days = 30): bool
|
||||
{
|
||||
if (!$this->expires_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->expires_at->isFuture() &&
|
||||
$this->expires_at->diffInDays(now()) <= $days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expiration status label
|
||||
*/
|
||||
public function getExpirationStatusLabel(): ?string
|
||||
{
|
||||
if (!$this->expires_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->isExpired()) {
|
||||
return '已過期';
|
||||
}
|
||||
|
||||
if ($this->isExpiringSoon(7)) {
|
||||
return '即將過期';
|
||||
}
|
||||
|
||||
if ($this->isExpiringSoon(30)) {
|
||||
return '接近過期';
|
||||
}
|
||||
|
||||
return '有效';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code for this document
|
||||
*/
|
||||
public function generateQRCode(?int $size = null, ?string $format = null): string
|
||||
{
|
||||
$settings = app(\App\Services\SettingsService::class);
|
||||
$size = $size ?? $settings->getQRCodeSize();
|
||||
$format = $format ?? $settings->getQRCodeFormat();
|
||||
|
||||
return \SimpleSoftwareIO\QrCode\Facades\QrCode::size($size)
|
||||
->format($format)
|
||||
->generate($this->getPublicUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code as PNG
|
||||
*/
|
||||
public function generateQRCodePNG(?int $size = null): string
|
||||
{
|
||||
$settings = app(\App\Services\SettingsService::class);
|
||||
$size = $size ?? $settings->getQRCodeSize();
|
||||
|
||||
return \SimpleSoftwareIO\QrCode\Facades\QrCode::size($size)
|
||||
->format('png')
|
||||
->generate($this->getPublicUrl());
|
||||
}
|
||||
}
|
||||
106
app/Models/DocumentAccessLog.php
Normal file
106
app/Models/DocumentAccessLog.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DocumentAccessLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'document_id',
|
||||
'document_version_id',
|
||||
'action',
|
||||
'user_id',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'accessed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'accessed_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
/**
|
||||
* Get the document this log belongs to
|
||||
*/
|
||||
public function document()
|
||||
{
|
||||
return $this->belongsTo(Document::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the document version accessed
|
||||
*/
|
||||
public function version()
|
||||
{
|
||||
return $this->belongsTo(DocumentVersion::class, 'document_version_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user who accessed (null if anonymous)
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
* Get action label in Chinese
|
||||
*/
|
||||
public function getActionLabel(): string
|
||||
{
|
||||
return match($this->action) {
|
||||
'view' => '檢視',
|
||||
'download' => '下載',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user display name (anonymous if no user)
|
||||
*/
|
||||
public function getUserDisplay(): string
|
||||
{
|
||||
return $this->user ? $this->user->name : '匿名訪客';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser from user agent
|
||||
*/
|
||||
public function getBrowser(): string
|
||||
{
|
||||
if (!$this->user_agent) {
|
||||
return '未知';
|
||||
}
|
||||
|
||||
if (str_contains($this->user_agent, 'Chrome')) {
|
||||
return 'Chrome';
|
||||
}
|
||||
if (str_contains($this->user_agent, 'Safari')) {
|
||||
return 'Safari';
|
||||
}
|
||||
if (str_contains($this->user_agent, 'Firefox')) {
|
||||
return 'Firefox';
|
||||
}
|
||||
if (str_contains($this->user_agent, 'Edge')) {
|
||||
return 'Edge';
|
||||
}
|
||||
|
||||
return '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if access was by authenticated user
|
||||
*/
|
||||
public function isAuthenticated(): bool
|
||||
{
|
||||
return $this->user_id !== null;
|
||||
}
|
||||
}
|
||||
85
app/Models/DocumentCategory.php
Normal file
85
app/Models/DocumentCategory.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DocumentCategory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'icon',
|
||||
'sort_order',
|
||||
'default_access_level',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// Auto-generate slug from name if not provided
|
||||
static::creating(function ($category) {
|
||||
if (empty($category->slug)) {
|
||||
$category->slug = Str::slug($category->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
/**
|
||||
* Get all documents in this category
|
||||
*/
|
||||
public function documents()
|
||||
{
|
||||
return $this->hasMany(Document::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active (non-archived) documents in this category
|
||||
*/
|
||||
public function activeDocuments()
|
||||
{
|
||||
return $this->hasMany(Document::class)->where('status', 'active');
|
||||
}
|
||||
|
||||
// ==================== Accessors ====================
|
||||
|
||||
/**
|
||||
* Get the count of active documents in this category
|
||||
*/
|
||||
public function getDocumentCountAttribute(): int
|
||||
{
|
||||
return $this->activeDocuments()->count();
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
* Get the icon with fallback
|
||||
*/
|
||||
public function getIconDisplay(): string
|
||||
{
|
||||
return $this->icon ?? '📄';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access level label
|
||||
*/
|
||||
public function getAccessLevelLabel(): string
|
||||
{
|
||||
return match($this->default_access_level) {
|
||||
'public' => '公開',
|
||||
'members' => '會員',
|
||||
'admin' => '管理員',
|
||||
'board' => '理事會',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
}
|
||||
50
app/Models/DocumentTag.php
Normal file
50
app/Models/DocumentTag.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DocumentTag extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'color',
|
||||
'description',
|
||||
];
|
||||
|
||||
/**
|
||||
* Boot the model
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($tag) {
|
||||
if (empty($tag->slug)) {
|
||||
$tag->slug = Str::slug($tag->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the documents that have this tag
|
||||
*/
|
||||
public function documents()
|
||||
{
|
||||
return $this->belongsToMany(Document::class, 'document_document_tag')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of active documents with this tag
|
||||
*/
|
||||
public function activeDocuments()
|
||||
{
|
||||
return $this->belongsToMany(Document::class, 'document_document_tag')
|
||||
->where('status', 'active')
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
167
app/Models/DocumentVersion.php
Normal file
167
app/Models/DocumentVersion.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DocumentVersion extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'document_id',
|
||||
'version_number',
|
||||
'version_notes',
|
||||
'is_current',
|
||||
'file_path',
|
||||
'original_filename',
|
||||
'mime_type',
|
||||
'file_size',
|
||||
'file_hash',
|
||||
'uploaded_by_user_id',
|
||||
'uploaded_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_current' => 'boolean',
|
||||
'uploaded_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Versions are immutable - disable updating
|
||||
protected static function booted()
|
||||
{
|
||||
static::updating(function ($version) {
|
||||
// Only allow updating is_current flag
|
||||
$dirty = $version->getDirty();
|
||||
if (count($dirty) > 1 || !isset($dirty['is_current'])) {
|
||||
throw new \Exception('Document versions are immutable and cannot be modified');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
/**
|
||||
* Get the document this version belongs to
|
||||
*/
|
||||
public function document()
|
||||
{
|
||||
return $this->belongsTo(Document::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user who uploaded this version
|
||||
*/
|
||||
public function uploadedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by_user_id');
|
||||
}
|
||||
|
||||
// ==================== File Methods ====================
|
||||
|
||||
/**
|
||||
* Get the full file path
|
||||
*/
|
||||
public function getFullPath(): string
|
||||
{
|
||||
return storage_path('app/' . $this->file_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file exists
|
||||
*/
|
||||
public function fileExists(): bool
|
||||
{
|
||||
return Storage::exists($this->file_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file download URL
|
||||
*/
|
||||
public function getDownloadUrl(): string
|
||||
{
|
||||
return route('admin.documents.download-version', [
|
||||
'document' => $this->document_id,
|
||||
'version' => $this->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify file integrity
|
||||
*/
|
||||
public function verifyIntegrity(): bool
|
||||
{
|
||||
if (!$this->fileExists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentHash = hash_file('sha256', $this->getFullPath());
|
||||
return $currentHash === $this->file_hash;
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
* Get human-readable file size
|
||||
*/
|
||||
public function getFileSizeHuman(): string
|
||||
{
|
||||
$bytes = $this->file_size;
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
|
||||
for ($i = 0; $bytes > 1024; $i++) {
|
||||
$bytes /= 1024;
|
||||
}
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension
|
||||
*/
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return pathinfo($this->original_filename, PATHINFO_EXTENSION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file icon based on mime type
|
||||
*/
|
||||
public function getFileIcon(): string
|
||||
{
|
||||
return match(true) {
|
||||
str_contains($this->mime_type, 'pdf') => '📄',
|
||||
str_contains($this->mime_type, 'word') || str_contains($this->mime_type, 'document') => '📝',
|
||||
str_contains($this->mime_type, 'sheet') || str_contains($this->mime_type, 'excel') => '📊',
|
||||
str_contains($this->mime_type, 'image') => '🖼️',
|
||||
str_contains($this->mime_type, 'zip') || str_contains($this->mime_type, 'compressed') => '🗜️',
|
||||
default => '📎',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if version is current
|
||||
*/
|
||||
public function isCurrent(): bool
|
||||
{
|
||||
return $this->is_current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version badge class for UI
|
||||
*/
|
||||
public function getBadgeClass(): string
|
||||
{
|
||||
return $this->is_current ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version badge text
|
||||
*/
|
||||
public function getBadgeText(): string
|
||||
{
|
||||
return $this->is_current ? '當前版本' : '歷史版本';
|
||||
}
|
||||
}
|
||||
435
app/Models/FinanceDocument.php
Normal file
435
app/Models/FinanceDocument.php
Normal file
@@ -0,0 +1,435 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class FinanceDocument extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
// Status constants
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_APPROVED_CASHIER = 'approved_cashier';
|
||||
public const STATUS_APPROVED_ACCOUNTANT = 'approved_accountant';
|
||||
public const STATUS_APPROVED_CHAIR = 'approved_chair';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
// Request type constants
|
||||
public const REQUEST_TYPE_EXPENSE_REIMBURSEMENT = 'expense_reimbursement';
|
||||
public const REQUEST_TYPE_ADVANCE_PAYMENT = 'advance_payment';
|
||||
public const REQUEST_TYPE_PURCHASE_REQUEST = 'purchase_request';
|
||||
public const REQUEST_TYPE_PETTY_CASH = 'petty_cash';
|
||||
|
||||
// Amount tier constants
|
||||
public const AMOUNT_TIER_SMALL = 'small'; // < 5,000
|
||||
public const AMOUNT_TIER_MEDIUM = 'medium'; // 5,000 - 50,000
|
||||
public const AMOUNT_TIER_LARGE = 'large'; // > 50,000
|
||||
|
||||
// Reconciliation status constants
|
||||
public const RECONCILIATION_PENDING = 'pending';
|
||||
public const RECONCILIATION_MATCHED = 'matched';
|
||||
public const RECONCILIATION_DISCREPANCY = 'discrepancy';
|
||||
public const RECONCILIATION_RESOLVED = 'resolved';
|
||||
|
||||
// Payment method constants
|
||||
public const PAYMENT_METHOD_BANK_TRANSFER = 'bank_transfer';
|
||||
public const PAYMENT_METHOD_CHECK = 'check';
|
||||
public const PAYMENT_METHOD_CASH = 'cash';
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'submitted_by_user_id',
|
||||
'title',
|
||||
'amount',
|
||||
'status',
|
||||
'description',
|
||||
'attachment_path',
|
||||
'submitted_at',
|
||||
'approved_by_cashier_id',
|
||||
'cashier_approved_at',
|
||||
'approved_by_accountant_id',
|
||||
'accountant_approved_at',
|
||||
'approved_by_chair_id',
|
||||
'chair_approved_at',
|
||||
'rejected_by_user_id',
|
||||
'rejected_at',
|
||||
'rejection_reason',
|
||||
// New payment stage fields
|
||||
'request_type',
|
||||
'amount_tier',
|
||||
'chart_of_account_id',
|
||||
'budget_item_id',
|
||||
'requires_board_meeting',
|
||||
'approved_by_board_meeting_id',
|
||||
'board_meeting_approved_at',
|
||||
'payment_order_created_by_accountant_id',
|
||||
'payment_order_created_at',
|
||||
'payment_method',
|
||||
'payee_name',
|
||||
'payee_account_number',
|
||||
'payee_bank_name',
|
||||
'payment_verified_by_cashier_id',
|
||||
'payment_verified_at',
|
||||
'payment_executed_by_cashier_id',
|
||||
'payment_executed_at',
|
||||
'payment_transaction_id',
|
||||
'payment_receipt_path',
|
||||
'actual_payment_amount',
|
||||
'cashier_ledger_entry_id',
|
||||
'accounting_transaction_id',
|
||||
'reconciliation_status',
|
||||
'reconciled_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'decimal:2',
|
||||
'submitted_at' => 'datetime',
|
||||
'cashier_approved_at' => 'datetime',
|
||||
'accountant_approved_at' => 'datetime',
|
||||
'chair_approved_at' => 'datetime',
|
||||
'rejected_at' => 'datetime',
|
||||
// New payment stage casts
|
||||
'requires_board_meeting' => 'boolean',
|
||||
'board_meeting_approved_at' => 'datetime',
|
||||
'payment_order_created_at' => 'datetime',
|
||||
'payment_verified_at' => 'datetime',
|
||||
'payment_executed_at' => 'datetime',
|
||||
'actual_payment_amount' => 'decimal:2',
|
||||
'reconciled_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function member()
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
public function submittedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'submitted_by_user_id');
|
||||
}
|
||||
|
||||
public function approvedByCashier()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by_cashier_id');
|
||||
}
|
||||
|
||||
public function approvedByAccountant()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by_accountant_id');
|
||||
}
|
||||
|
||||
public function approvedByChair()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by_chair_id');
|
||||
}
|
||||
|
||||
public function rejectedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'rejected_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* New payment stage relationships
|
||||
*/
|
||||
public function chartOfAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChartOfAccount::class);
|
||||
}
|
||||
|
||||
public function budgetItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BudgetItem::class);
|
||||
}
|
||||
|
||||
public function approvedByBoardMeeting(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BoardMeeting::class, 'approved_by_board_meeting_id');
|
||||
}
|
||||
|
||||
public function paymentOrderCreatedByAccountant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'payment_order_created_by_accountant_id');
|
||||
}
|
||||
|
||||
public function paymentVerifiedByCashier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'payment_verified_by_cashier_id');
|
||||
}
|
||||
|
||||
public function paymentExecutedByCashier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'payment_executed_by_cashier_id');
|
||||
}
|
||||
|
||||
public function cashierLedgerEntry(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CashierLedgerEntry::class);
|
||||
}
|
||||
|
||||
public function accountingTransaction(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Transaction::class, 'accounting_transaction_id');
|
||||
}
|
||||
|
||||
public function paymentOrder(): HasOne
|
||||
{
|
||||
return $this->hasOne(PaymentOrder::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document can be approved by cashier
|
||||
*/
|
||||
public function canBeApprovedByCashier(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document can be approved by accountant
|
||||
*/
|
||||
public function canBeApprovedByAccountant(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_APPROVED_CASHIER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document can be approved by chair
|
||||
*/
|
||||
public function canBeApprovedByChair(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_APPROVED_ACCOUNTANT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document is fully approved
|
||||
*/
|
||||
public function isFullyApproved(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_APPROVED_CHAIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document is rejected
|
||||
*/
|
||||
public function isRejected(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_REJECTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable status
|
||||
*/
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return match($this->status) {
|
||||
self::STATUS_PENDING => 'Pending Cashier Approval',
|
||||
self::STATUS_APPROVED_CASHIER => 'Pending Accountant Approval',
|
||||
self::STATUS_APPROVED_ACCOUNTANT => 'Pending Chair Approval',
|
||||
self::STATUS_APPROVED_CHAIR => 'Fully Approved',
|
||||
self::STATUS_REJECTED => 'Rejected',
|
||||
default => ucfirst($this->status),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* New payment stage business logic methods
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determine amount tier based on amount
|
||||
*/
|
||||
public function determineAmountTier(): string
|
||||
{
|
||||
if ($this->amount < 5000) {
|
||||
return self::AMOUNT_TIER_SMALL;
|
||||
} elseif ($this->amount <= 50000) {
|
||||
return self::AMOUNT_TIER_MEDIUM;
|
||||
} else {
|
||||
return self::AMOUNT_TIER_LARGE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document needs board meeting approval
|
||||
*/
|
||||
public function needsBoardMeetingApproval(): bool
|
||||
{
|
||||
return $this->amount_tier === self::AMOUNT_TIER_LARGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if approval stage is complete (ready for payment order creation)
|
||||
*/
|
||||
public function isApprovalStageComplete(): bool
|
||||
{
|
||||
// For small amounts: cashier + accountant
|
||||
if ($this->amount_tier === self::AMOUNT_TIER_SMALL) {
|
||||
return $this->status === self::STATUS_APPROVED_ACCOUNTANT;
|
||||
}
|
||||
|
||||
// For medium amounts: cashier + accountant + chair
|
||||
if ($this->amount_tier === self::AMOUNT_TIER_MEDIUM) {
|
||||
return $this->status === self::STATUS_APPROVED_CHAIR;
|
||||
}
|
||||
|
||||
// For large amounts: cashier + accountant + chair + board meeting
|
||||
if ($this->amount_tier === self::AMOUNT_TIER_LARGE) {
|
||||
return $this->status === self::STATUS_APPROVED_CHAIR &&
|
||||
$this->board_meeting_approved_at !== null;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if accountant can create payment order
|
||||
*/
|
||||
public function canCreatePaymentOrder(): bool
|
||||
{
|
||||
return $this->isApprovalStageComplete() &&
|
||||
$this->payment_order_created_at === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cashier can verify payment
|
||||
*/
|
||||
public function canVerifyPayment(): bool
|
||||
{
|
||||
return $this->payment_order_created_at !== null &&
|
||||
$this->payment_verified_at === null &&
|
||||
$this->paymentOrder !== null &&
|
||||
$this->paymentOrder->canBeVerifiedByCashier();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cashier can execute payment
|
||||
*/
|
||||
public function canExecutePayment(): bool
|
||||
{
|
||||
return $this->payment_verified_at !== null &&
|
||||
$this->payment_executed_at === null &&
|
||||
$this->paymentOrder !== null &&
|
||||
$this->paymentOrder->canBeExecuted();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if payment is completed
|
||||
*/
|
||||
public function isPaymentCompleted(): bool
|
||||
{
|
||||
return $this->payment_executed_at !== null &&
|
||||
$this->paymentOrder !== null &&
|
||||
$this->paymentOrder->isExecuted();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if recording stage is complete
|
||||
*/
|
||||
public function isRecordingComplete(): bool
|
||||
{
|
||||
return $this->cashier_ledger_entry_id !== null &&
|
||||
$this->accounting_transaction_id !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document is fully processed (all stages complete)
|
||||
*/
|
||||
public function isFullyProcessed(): bool
|
||||
{
|
||||
return $this->isApprovalStageComplete() &&
|
||||
$this->isPaymentCompleted() &&
|
||||
$this->isRecordingComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if reconciliation is complete
|
||||
*/
|
||||
public function isReconciled(): bool
|
||||
{
|
||||
return $this->reconciliation_status === self::RECONCILIATION_MATCHED ||
|
||||
$this->reconciliation_status === self::RECONCILIATION_RESOLVED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request type text
|
||||
*/
|
||||
public function getRequestTypeText(): string
|
||||
{
|
||||
return match ($this->request_type) {
|
||||
self::REQUEST_TYPE_EXPENSE_REIMBURSEMENT => '事後報銷',
|
||||
self::REQUEST_TYPE_ADVANCE_PAYMENT => '預支/借款',
|
||||
self::REQUEST_TYPE_PURCHASE_REQUEST => '採購申請',
|
||||
self::REQUEST_TYPE_PETTY_CASH => '零用金領取',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get amount tier text
|
||||
*/
|
||||
public function getAmountTierText(): string
|
||||
{
|
||||
return match ($this->amount_tier) {
|
||||
self::AMOUNT_TIER_SMALL => '小額 (< 5,000)',
|
||||
self::AMOUNT_TIER_MEDIUM => '中額 (5,000-50,000)',
|
||||
self::AMOUNT_TIER_LARGE => '大額 (> 50,000)',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payment method text
|
||||
*/
|
||||
public function getPaymentMethodText(): string
|
||||
{
|
||||
return match ($this->payment_method) {
|
||||
self::PAYMENT_METHOD_BANK_TRANSFER => '銀行轉帳',
|
||||
self::PAYMENT_METHOD_CHECK => '支票',
|
||||
self::PAYMENT_METHOD_CASH => '現金',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reconciliation status text
|
||||
*/
|
||||
public function getReconciliationStatusText(): string
|
||||
{
|
||||
return match ($this->reconciliation_status) {
|
||||
self::RECONCILIATION_PENDING => '待調節',
|
||||
self::RECONCILIATION_MATCHED => '已調節',
|
||||
self::RECONCILIATION_DISCREPANCY => '有差異',
|
||||
self::RECONCILIATION_RESOLVED => '已解決',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current workflow stage
|
||||
*/
|
||||
public function getCurrentWorkflowStage(): string
|
||||
{
|
||||
if (!$this->isApprovalStageComplete()) {
|
||||
return 'approval';
|
||||
}
|
||||
|
||||
if (!$this->isPaymentCompleted()) {
|
||||
return 'payment';
|
||||
}
|
||||
|
||||
if (!$this->isRecordingComplete()) {
|
||||
return 'recording';
|
||||
}
|
||||
|
||||
if (!$this->isReconciled()) {
|
||||
return 'reconciliation';
|
||||
}
|
||||
|
||||
return 'completed';
|
||||
}
|
||||
}
|
||||
|
||||
98
app/Models/FinancialReport.php
Normal file
98
app/Models/FinancialReport.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class FinancialReport extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_REVENUE_EXPENDITURE = 'revenue_expenditure';
|
||||
public const TYPE_BALANCE_SHEET = 'balance_sheet';
|
||||
public const TYPE_PROPERTY_INVENTORY = 'property_inventory';
|
||||
public const TYPE_INTERNAL_MANAGEMENT = 'internal_management';
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_FINALIZED = 'finalized';
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
public const STATUS_SUBMITTED = 'submitted';
|
||||
|
||||
protected $fillable = [
|
||||
'report_type',
|
||||
'fiscal_year',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'status',
|
||||
'budget_id',
|
||||
'generated_by_user_id',
|
||||
'approved_by_user_id',
|
||||
'approved_at',
|
||||
'file_path',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'fiscal_year' => 'integer',
|
||||
'period_start' => 'date',
|
||||
'period_end' => 'date',
|
||||
'approved_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function budget(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Budget::class);
|
||||
}
|
||||
|
||||
public function generatedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'generated_by_user_id');
|
||||
}
|
||||
|
||||
public function approvedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by_user_id');
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function isDraft(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
public function isFinalized(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_FINALIZED;
|
||||
}
|
||||
|
||||
public function isApproved(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_APPROVED;
|
||||
}
|
||||
|
||||
public function isSubmitted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_SUBMITTED;
|
||||
}
|
||||
|
||||
public function canBeEdited(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
public function getReportTypeNameAttribute(): string
|
||||
{
|
||||
return match($this->report_type) {
|
||||
self::TYPE_REVENUE_EXPENDITURE => '收支決算表',
|
||||
self::TYPE_BALANCE_SHEET => '資產負債表',
|
||||
self::TYPE_PROPERTY_INVENTORY => '財產目錄',
|
||||
self::TYPE_INTERNAL_MANAGEMENT => '內部管理報表',
|
||||
default => $this->report_type,
|
||||
};
|
||||
}
|
||||
}
|
||||
363
app/Models/Issue.php
Normal file
363
app/Models/Issue.php
Normal file
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Issue extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
// Status constants
|
||||
public const STATUS_NEW = 'new';
|
||||
public const STATUS_ASSIGNED = 'assigned';
|
||||
public const STATUS_IN_PROGRESS = 'in_progress';
|
||||
public const STATUS_REVIEW = 'review';
|
||||
public const STATUS_CLOSED = 'closed';
|
||||
|
||||
// Issue type constants
|
||||
public const TYPE_WORK_ITEM = 'work_item';
|
||||
public const TYPE_PROJECT_TASK = 'project_task';
|
||||
public const TYPE_MAINTENANCE = 'maintenance';
|
||||
public const TYPE_MEMBER_REQUEST = 'member_request';
|
||||
|
||||
// Priority constants
|
||||
public const PRIORITY_LOW = 'low';
|
||||
public const PRIORITY_MEDIUM = 'medium';
|
||||
public const PRIORITY_HIGH = 'high';
|
||||
public const PRIORITY_URGENT = 'urgent';
|
||||
|
||||
protected $fillable = [
|
||||
'issue_number',
|
||||
'title',
|
||||
'description',
|
||||
'issue_type',
|
||||
'status',
|
||||
'priority',
|
||||
'created_by_user_id',
|
||||
'assigned_to_user_id',
|
||||
'reviewer_id',
|
||||
'member_id',
|
||||
'parent_issue_id',
|
||||
'due_date',
|
||||
'closed_at',
|
||||
'estimated_hours',
|
||||
'actual_hours',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'due_date' => 'date',
|
||||
'closed_at' => 'datetime',
|
||||
'estimated_hours' => 'decimal:2',
|
||||
'actual_hours' => 'decimal:2',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// Auto-generate issue number on create
|
||||
static::creating(function ($issue) {
|
||||
if (!$issue->issue_number) {
|
||||
$year = now()->year;
|
||||
$lastIssue = static::whereYear('created_at', $year)
|
||||
->orderBy('id', 'desc')
|
||||
->first();
|
||||
|
||||
$nextNumber = $lastIssue ? ((int) substr($lastIssue->issue_number, -3)) + 1 : 1;
|
||||
$issue->issue_number = sprintf('ISS-%d-%03d', $year, $nextNumber);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function assignee(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assigned_to_user_id');
|
||||
}
|
||||
|
||||
public function reviewer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'reviewer_id');
|
||||
}
|
||||
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
public function parentIssue(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Issue::class, 'parent_issue_id');
|
||||
}
|
||||
|
||||
public function subTasks(): HasMany
|
||||
{
|
||||
return $this->hasMany(Issue::class, 'parent_issue_id');
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(IssueComment::class);
|
||||
}
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(IssueAttachment::class);
|
||||
}
|
||||
|
||||
public function labels(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(IssueLabel::class, 'issue_label_pivot');
|
||||
}
|
||||
|
||||
public function watchers(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'issue_watchers');
|
||||
}
|
||||
|
||||
public function timeLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(IssueTimeLog::class);
|
||||
}
|
||||
|
||||
public function relationships(): HasMany
|
||||
{
|
||||
return $this->hasMany(IssueRelationship::class);
|
||||
}
|
||||
|
||||
public function relatedIssues()
|
||||
{
|
||||
return $this->belongsToMany(Issue::class, 'issue_relationships', 'issue_id', 'related_issue_id')
|
||||
->withPivot('relationship_type')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function customFieldValues(): MorphMany
|
||||
{
|
||||
return $this->morphMany(CustomFieldValue::class, 'customizable');
|
||||
}
|
||||
|
||||
// ==================== Status Helpers ====================
|
||||
|
||||
public function isNew(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_NEW;
|
||||
}
|
||||
|
||||
public function isAssigned(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ASSIGNED;
|
||||
}
|
||||
|
||||
public function isInProgress(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_IN_PROGRESS;
|
||||
}
|
||||
|
||||
public function inReview(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_REVIEW;
|
||||
}
|
||||
|
||||
public function isClosed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_CLOSED;
|
||||
}
|
||||
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return !$this->isClosed();
|
||||
}
|
||||
|
||||
// ==================== Workflow Methods ====================
|
||||
|
||||
public function canBeAssigned(): bool
|
||||
{
|
||||
return $this->isNew() || $this->isAssigned();
|
||||
}
|
||||
|
||||
public function canMoveToInProgress(): bool
|
||||
{
|
||||
return $this->isAssigned() && $this->assigned_to_user_id !== null;
|
||||
}
|
||||
|
||||
public function canMoveToReview(): bool
|
||||
{
|
||||
return $this->isInProgress();
|
||||
}
|
||||
|
||||
public function canBeClosed(): bool
|
||||
{
|
||||
return in_array($this->status, [
|
||||
self::STATUS_REVIEW,
|
||||
self::STATUS_IN_PROGRESS,
|
||||
self::STATUS_ASSIGNED,
|
||||
]);
|
||||
}
|
||||
|
||||
public function canBeReopened(): bool
|
||||
{
|
||||
return $this->isClosed();
|
||||
}
|
||||
|
||||
// ==================== Accessors ====================
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return match($this->status) {
|
||||
self::STATUS_NEW => __('New'),
|
||||
self::STATUS_ASSIGNED => __('Assigned'),
|
||||
self::STATUS_IN_PROGRESS => __('In Progress'),
|
||||
self::STATUS_REVIEW => __('Review'),
|
||||
self::STATUS_CLOSED => __('Closed'),
|
||||
default => $this->status,
|
||||
};
|
||||
}
|
||||
|
||||
public function getIssueTypeLabelAttribute(): string
|
||||
{
|
||||
return match($this->issue_type) {
|
||||
self::TYPE_WORK_ITEM => __('Work Item'),
|
||||
self::TYPE_PROJECT_TASK => __('Project Task'),
|
||||
self::TYPE_MAINTENANCE => __('Maintenance'),
|
||||
self::TYPE_MEMBER_REQUEST => __('Member Request'),
|
||||
default => $this->issue_type,
|
||||
};
|
||||
}
|
||||
|
||||
public function getPriorityLabelAttribute(): string
|
||||
{
|
||||
return match($this->priority) {
|
||||
self::PRIORITY_LOW => __('Low'),
|
||||
self::PRIORITY_MEDIUM => __('Medium'),
|
||||
self::PRIORITY_HIGH => __('High'),
|
||||
self::PRIORITY_URGENT => __('Urgent'),
|
||||
default => $this->priority,
|
||||
};
|
||||
}
|
||||
|
||||
public function getPriorityBadgeColorAttribute(): string
|
||||
{
|
||||
return match($this->priority) {
|
||||
self::PRIORITY_LOW => 'gray',
|
||||
self::PRIORITY_MEDIUM => 'blue',
|
||||
self::PRIORITY_HIGH => 'orange',
|
||||
self::PRIORITY_URGENT => 'red',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
public function getStatusBadgeColorAttribute(): string
|
||||
{
|
||||
return match($this->status) {
|
||||
self::STATUS_NEW => 'blue',
|
||||
self::STATUS_ASSIGNED => 'purple',
|
||||
self::STATUS_IN_PROGRESS => 'yellow',
|
||||
self::STATUS_REVIEW => 'orange',
|
||||
self::STATUS_CLOSED => 'green',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
public function getProgressPercentageAttribute(): int
|
||||
{
|
||||
return match($this->status) {
|
||||
self::STATUS_NEW => 0,
|
||||
self::STATUS_ASSIGNED => 20,
|
||||
self::STATUS_IN_PROGRESS => 50,
|
||||
self::STATUS_REVIEW => 80,
|
||||
self::STATUS_CLOSED => 100,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
public function getIsOverdueAttribute(): bool
|
||||
{
|
||||
return $this->due_date &&
|
||||
$this->due_date->isPast() &&
|
||||
!$this->isClosed();
|
||||
}
|
||||
|
||||
public function getDaysUntilDueAttribute(): ?int
|
||||
{
|
||||
if (!$this->due_date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return now()->startOfDay()->diffInDays($this->due_date->startOfDay(), false);
|
||||
}
|
||||
|
||||
public function getTotalTimeLoggedAttribute(): float
|
||||
{
|
||||
return (float) $this->timeLogs()->sum('hours');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeOpen($query)
|
||||
{
|
||||
return $query->where('status', '!=', self::STATUS_CLOSED);
|
||||
}
|
||||
|
||||
public function scopeClosed($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_CLOSED);
|
||||
}
|
||||
|
||||
public function scopeByType($query, string $type)
|
||||
{
|
||||
return $query->where('issue_type', $type);
|
||||
}
|
||||
|
||||
public function scopeByPriority($query, string $priority)
|
||||
{
|
||||
return $query->where('priority', $priority);
|
||||
}
|
||||
|
||||
public function scopeByStatus($query, string $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
public function scopeOverdue($query)
|
||||
{
|
||||
return $query->where('due_date', '<', now())
|
||||
->where('status', '!=', self::STATUS_CLOSED);
|
||||
}
|
||||
|
||||
public function scopeAssignedTo($query, int $userId)
|
||||
{
|
||||
return $query->where('assigned_to_user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeCreatedBy($query, int $userId)
|
||||
{
|
||||
return $query->where('created_by_user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeDueWithin($query, int $days)
|
||||
{
|
||||
return $query->whereBetween('due_date', [now(), now()->addDays($days)])
|
||||
->where('status', '!=', self::STATUS_CLOSED);
|
||||
}
|
||||
|
||||
public function scopeWithLabel($query, int $labelId)
|
||||
{
|
||||
return $query->whereHas('labels', function ($q) use ($labelId) {
|
||||
$q->where('issue_labels.id', $labelId);
|
||||
});
|
||||
}
|
||||
}
|
||||
61
app/Models/IssueAttachment.php
Normal file
61
app/Models/IssueAttachment.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class IssueAttachment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'issue_id',
|
||||
'user_id',
|
||||
'file_name',
|
||||
'file_path',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
];
|
||||
|
||||
public function issue(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Issue::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function getFileSizeHumanAttribute(): string
|
||||
{
|
||||
$bytes = $this->file_size;
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
|
||||
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
|
||||
$bytes /= 1024;
|
||||
}
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$i];
|
||||
}
|
||||
|
||||
public function getDownloadUrlAttribute(): string
|
||||
{
|
||||
return route('admin.issues.attachments.download', $this);
|
||||
}
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::deleting(function ($attachment) {
|
||||
// Delete file from storage when attachment record is deleted
|
||||
if (Storage::exists($attachment->file_path)) {
|
||||
Storage::delete($attachment->file_path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
33
app/Models/IssueComment.php
Normal file
33
app/Models/IssueComment.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class IssueComment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'issue_id',
|
||||
'user_id',
|
||||
'comment_text',
|
||||
'is_internal',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_internal' => 'boolean',
|
||||
];
|
||||
|
||||
public function issue(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Issue::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
37
app/Models/IssueLabel.php
Normal file
37
app/Models/IssueLabel.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class IssueLabel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'color',
|
||||
'description',
|
||||
];
|
||||
|
||||
public function issues(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Issue::class, 'issue_label_pivot');
|
||||
}
|
||||
|
||||
public function getTextColorAttribute(): string
|
||||
{
|
||||
// Calculate if we should use black or white text based on background color
|
||||
$color = $this->color;
|
||||
$r = hexdec(substr($color, 1, 2));
|
||||
$g = hexdec(substr($color, 3, 2));
|
||||
$b = hexdec(substr($color, 5, 2));
|
||||
|
||||
// Calculate perceived brightness
|
||||
$brightness = (($r * 299) + ($g * 587) + ($b * 114)) / 1000;
|
||||
|
||||
return $brightness > 128 ? '#000000' : '#FFFFFF';
|
||||
}
|
||||
}
|
||||
44
app/Models/IssueRelationship.php
Normal file
44
app/Models/IssueRelationship.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class IssueRelationship extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_BLOCKS = 'blocks';
|
||||
public const TYPE_BLOCKED_BY = 'blocked_by';
|
||||
public const TYPE_RELATED_TO = 'related_to';
|
||||
public const TYPE_DUPLICATE_OF = 'duplicate_of';
|
||||
|
||||
protected $fillable = [
|
||||
'issue_id',
|
||||
'related_issue_id',
|
||||
'relationship_type',
|
||||
];
|
||||
|
||||
public function issue(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Issue::class);
|
||||
}
|
||||
|
||||
public function relatedIssue(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Issue::class, 'related_issue_id');
|
||||
}
|
||||
|
||||
public function getRelationshipLabelAttribute(): string
|
||||
{
|
||||
return match($this->relationship_type) {
|
||||
self::TYPE_BLOCKS => __('Blocks'),
|
||||
self::TYPE_BLOCKED_BY => __('Blocked by'),
|
||||
self::TYPE_RELATED_TO => __('Related to'),
|
||||
self::TYPE_DUPLICATE_OF => __('Duplicate of'),
|
||||
default => $this->relationship_type,
|
||||
};
|
||||
}
|
||||
}
|
||||
59
app/Models/IssueTimeLog.php
Normal file
59
app/Models/IssueTimeLog.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class IssueTimeLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'issue_id',
|
||||
'user_id',
|
||||
'hours',
|
||||
'description',
|
||||
'logged_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'hours' => 'decimal:2',
|
||||
'logged_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function issue(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Issue::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// Update issue actual_hours when time log is created, updated, or deleted
|
||||
static::created(function ($timeLog) {
|
||||
$timeLog->updateIssueActualHours();
|
||||
});
|
||||
|
||||
static::updated(function ($timeLog) {
|
||||
$timeLog->updateIssueActualHours();
|
||||
});
|
||||
|
||||
static::deleted(function ($timeLog) {
|
||||
$timeLog->updateIssueActualHours();
|
||||
});
|
||||
}
|
||||
|
||||
protected function updateIssueActualHours(): void
|
||||
{
|
||||
$totalHours = $this->issue->timeLogs()->sum('hours');
|
||||
$this->issue->update(['actual_hours' => $totalHours]);
|
||||
}
|
||||
}
|
||||
202
app/Models/Member.php
Normal file
202
app/Models/Member.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?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
|
||||
const TYPE_REGULAR = 'regular';
|
||||
const TYPE_HONORARY = 'honorary';
|
||||
const TYPE_LIFETIME = 'lifetime';
|
||||
const TYPE_STUDENT = 'student';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'full_name',
|
||||
'email',
|
||||
'phone',
|
||||
'address_line_1',
|
||||
'address_line_2',
|
||||
'city',
|
||||
'postal_code',
|
||||
'emergency_contact_name',
|
||||
'emergency_contact_phone',
|
||||
'national_id_encrypted',
|
||||
'national_id_hash',
|
||||
'membership_started_at',
|
||||
'membership_expires_at',
|
||||
'membership_status',
|
||||
'membership_type',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'membership_started_at' => 'date',
|
||||
'membership_expires_at' => 'date',
|
||||
];
|
||||
|
||||
protected $appends = ['national_id'];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function payments()
|
||||
{
|
||||
return $this->hasMany(MembershipPayment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
return 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',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
self::TYPE_REGULAR => '一般會員',
|
||||
self::TYPE_HONORARY => '榮譽會員',
|
||||
self::TYPE_LIFETIME => '終身會員',
|
||||
self::TYPE_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();
|
||||
}
|
||||
}
|
||||
166
app/Models/MembershipPayment.php
Normal file
166
app/Models/MembershipPayment.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class MembershipPayment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
// Status constants
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_APPROVED_CASHIER = 'approved_cashier';
|
||||
const STATUS_APPROVED_ACCOUNTANT = 'approved_accountant';
|
||||
const STATUS_APPROVED_CHAIR = 'approved_chair';
|
||||
const STATUS_REJECTED = 'rejected';
|
||||
|
||||
// Payment method constants
|
||||
const METHOD_BANK_TRANSFER = 'bank_transfer';
|
||||
const METHOD_CONVENIENCE_STORE = 'convenience_store';
|
||||
const METHOD_CASH = 'cash';
|
||||
const METHOD_CREDIT_CARD = 'credit_card';
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'paid_at',
|
||||
'amount',
|
||||
'method',
|
||||
'reference',
|
||||
'status',
|
||||
'payment_method',
|
||||
'receipt_path',
|
||||
'submitted_by_user_id',
|
||||
'verified_by_cashier_id',
|
||||
'cashier_verified_at',
|
||||
'verified_by_accountant_id',
|
||||
'accountant_verified_at',
|
||||
'verified_by_chair_id',
|
||||
'chair_verified_at',
|
||||
'rejected_by_user_id',
|
||||
'rejected_at',
|
||||
'rejection_reason',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'paid_at' => 'date',
|
||||
'cashier_verified_at' => 'datetime',
|
||||
'accountant_verified_at' => 'datetime',
|
||||
'chair_verified_at' => 'datetime',
|
||||
'rejected_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
public function member()
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
public function submittedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'submitted_by_user_id');
|
||||
}
|
||||
|
||||
public function verifiedByCashier()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'verified_by_cashier_id');
|
||||
}
|
||||
|
||||
public function verifiedByAccountant()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'verified_by_accountant_id');
|
||||
}
|
||||
|
||||
public function verifiedByChair()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'verified_by_chair_id');
|
||||
}
|
||||
|
||||
public function rejectedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'rejected_by_user_id');
|
||||
}
|
||||
|
||||
// Status check methods
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isApprovedByCashier(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_APPROVED_CASHIER;
|
||||
}
|
||||
|
||||
public function isApprovedByAccountant(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_APPROVED_ACCOUNTANT;
|
||||
}
|
||||
|
||||
public function isFullyApproved(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_APPROVED_CHAIR;
|
||||
}
|
||||
|
||||
public function isRejected(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_REJECTED;
|
||||
}
|
||||
|
||||
// Workflow validation methods
|
||||
public function canBeApprovedByCashier(): bool
|
||||
{
|
||||
return $this->isPending();
|
||||
}
|
||||
|
||||
public function canBeApprovedByAccountant(): bool
|
||||
{
|
||||
return $this->isApprovedByCashier();
|
||||
}
|
||||
|
||||
public function canBeApprovedByChair(): bool
|
||||
{
|
||||
return $this->isApprovedByAccountant();
|
||||
}
|
||||
|
||||
// Accessor for status label
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return match($this->status) {
|
||||
self::STATUS_PENDING => '待審核',
|
||||
self::STATUS_APPROVED_CASHIER => '出納已審',
|
||||
self::STATUS_APPROVED_ACCOUNTANT => '會計已審',
|
||||
self::STATUS_APPROVED_CHAIR => '主席已審',
|
||||
self::STATUS_REJECTED => '已拒絕',
|
||||
default => $this->status,
|
||||
};
|
||||
}
|
||||
|
||||
// Accessor for payment method label
|
||||
public function getPaymentMethodLabelAttribute(): string
|
||||
{
|
||||
return match($this->payment_method) {
|
||||
self::METHOD_BANK_TRANSFER => '銀行轉帳',
|
||||
self::METHOD_CONVENIENCE_STORE => '便利商店繳費',
|
||||
self::METHOD_CASH => '現金',
|
||||
self::METHOD_CREDIT_CARD => '信用卡',
|
||||
default => $this->payment_method ?? '未指定',
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up receipt file when payment is deleted
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::deleting(function ($payment) {
|
||||
if ($payment->receipt_path && Storage::exists($payment->receipt_path)) {
|
||||
Storage::delete($payment->receipt_path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
168
app/Models/PaymentOrder.php
Normal file
168
app/Models/PaymentOrder.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PaymentOrder extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'finance_document_id',
|
||||
'payee_name',
|
||||
'payee_bank_code',
|
||||
'payee_account_number',
|
||||
'payee_bank_name',
|
||||
'payment_amount',
|
||||
'payment_method',
|
||||
'created_by_accountant_id',
|
||||
'payment_order_number',
|
||||
'notes',
|
||||
'verified_by_cashier_id',
|
||||
'verified_at',
|
||||
'verification_status',
|
||||
'verification_notes',
|
||||
'executed_by_cashier_id',
|
||||
'executed_at',
|
||||
'execution_status',
|
||||
'transaction_reference',
|
||||
'payment_receipt_path',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payment_amount' => 'decimal:2',
|
||||
'verified_at' => 'datetime',
|
||||
'executed_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 狀態常數
|
||||
*/
|
||||
const STATUS_DRAFT = 'draft';
|
||||
const STATUS_PENDING_VERIFICATION = 'pending_verification';
|
||||
const STATUS_VERIFIED = 'verified';
|
||||
const STATUS_EXECUTED = 'executed';
|
||||
const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
const VERIFICATION_PENDING = 'pending';
|
||||
const VERIFICATION_APPROVED = 'approved';
|
||||
const VERIFICATION_REJECTED = 'rejected';
|
||||
|
||||
const EXECUTION_PENDING = 'pending';
|
||||
const EXECUTION_COMPLETED = 'completed';
|
||||
const EXECUTION_FAILED = 'failed';
|
||||
|
||||
const PAYMENT_METHOD_BANK_TRANSFER = 'bank_transfer';
|
||||
const PAYMENT_METHOD_CHECK = 'check';
|
||||
const PAYMENT_METHOD_CASH = 'cash';
|
||||
|
||||
/**
|
||||
* 關聯到財務申請單
|
||||
*/
|
||||
public function financeDocument(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FinanceDocument::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 會計製單人
|
||||
*/
|
||||
public function createdByAccountant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_accountant_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 出納覆核人
|
||||
*/
|
||||
public function verifiedByCashier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'verified_by_cashier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 出納執行人
|
||||
*/
|
||||
public function executedByCashier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'executed_by_cashier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生付款單號
|
||||
*/
|
||||
public static function generatePaymentOrderNumber(): string
|
||||
{
|
||||
$date = now()->format('Ymd');
|
||||
$latest = self::where('payment_order_number', 'like', "PO-{$date}%")->latest('id')->first();
|
||||
|
||||
if ($latest) {
|
||||
$lastNumber = (int) substr($latest->payment_order_number, -4);
|
||||
$newNumber = $lastNumber + 1;
|
||||
} else {
|
||||
$newNumber = 1;
|
||||
}
|
||||
|
||||
return sprintf('PO-%s%04d', $date, $newNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查是否可以被出納覆核
|
||||
*/
|
||||
public function canBeVerifiedByCashier(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING_VERIFICATION &&
|
||||
$this->verification_status === self::VERIFICATION_PENDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查是否可以執行付款
|
||||
*/
|
||||
public function canBeExecuted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_VERIFIED &&
|
||||
$this->verification_status === self::VERIFICATION_APPROVED &&
|
||||
$this->execution_status === self::EXECUTION_PENDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已執行
|
||||
*/
|
||||
public function isExecuted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_EXECUTED &&
|
||||
$this->execution_status === self::EXECUTION_COMPLETED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得付款方式文字
|
||||
*/
|
||||
public function getPaymentMethodText(): string
|
||||
{
|
||||
return match ($this->payment_method) {
|
||||
self::PAYMENT_METHOD_BANK_TRANSFER => '銀行轉帳',
|
||||
self::PAYMENT_METHOD_CHECK => '支票',
|
||||
self::PAYMENT_METHOD_CASH => '現金',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得狀態文字
|
||||
*/
|
||||
public function getStatusText(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_DRAFT => '草稿',
|
||||
self::STATUS_PENDING_VERIFICATION => '待出納覆核',
|
||||
self::STATUS_VERIFIED => '已覆核',
|
||||
self::STATUS_EXECUTED => '已執行付款',
|
||||
self::STATUS_CANCELLED => '已取消',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
}
|
||||
223
app/Models/SystemSetting.php
Normal file
223
app/Models/SystemSetting.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class SystemSetting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'value',
|
||||
'type',
|
||||
'group',
|
||||
'description',
|
||||
];
|
||||
|
||||
/**
|
||||
* Cache key prefix for settings
|
||||
*/
|
||||
const CACHE_PREFIX = 'system_setting_';
|
||||
|
||||
/**
|
||||
* Cache duration in seconds (1 hour)
|
||||
*/
|
||||
const CACHE_DURATION = 3600;
|
||||
|
||||
/**
|
||||
* Boot the model
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
// Clear cache when setting is updated or deleted
|
||||
static::saved(function ($setting) {
|
||||
Cache::forget(self::CACHE_PREFIX . $setting->key);
|
||||
Cache::forget('all_system_settings');
|
||||
});
|
||||
|
||||
static::deleted(function ($setting) {
|
||||
Cache::forget(self::CACHE_PREFIX . $setting->key);
|
||||
Cache::forget('all_system_settings');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a setting value by key with caching
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $default
|
||||
* @return mixed
|
||||
*/
|
||||
public static function get(string $key, $default = null)
|
||||
{
|
||||
return Cache::remember(
|
||||
self::CACHE_PREFIX . $key,
|
||||
self::CACHE_DURATION,
|
||||
function () use ($key, $default) {
|
||||
$setting = self::where('key', $key)->first();
|
||||
|
||||
if (!$setting) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $setting->getCastedValue();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a setting value (creates if not exists)
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
* @param string $type
|
||||
* @param string|null $group
|
||||
* @param string|null $description
|
||||
* @return SystemSetting
|
||||
*/
|
||||
public static function set(string $key, $value, string $type = 'string', ?string $group = null, ?string $description = null): SystemSetting
|
||||
{
|
||||
$setting = self::updateOrCreate(
|
||||
['key' => $key],
|
||||
[
|
||||
'value' => self::encodeValue($value, $type),
|
||||
'type' => $type,
|
||||
'group' => $group,
|
||||
'description' => $description,
|
||||
]
|
||||
);
|
||||
|
||||
return $setting;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a setting exists
|
||||
*
|
||||
* @param string $key
|
||||
* @return bool
|
||||
*/
|
||||
public static function has(string $key): bool
|
||||
{
|
||||
return Cache::remember(
|
||||
self::CACHE_PREFIX . $key . '_exists',
|
||||
self::CACHE_DURATION,
|
||||
fn() => self::where('key', $key)->exists()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a setting by key
|
||||
*
|
||||
* @param string $key
|
||||
* @return bool
|
||||
*/
|
||||
public static function forget(string $key): bool
|
||||
{
|
||||
return self::where('key', $key)->delete() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all settings grouped by group
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public static function getAllGrouped()
|
||||
{
|
||||
return Cache::remember(
|
||||
'all_system_settings',
|
||||
self::CACHE_DURATION,
|
||||
function () {
|
||||
return self::all()->groupBy('group')->map(function ($groupSettings) {
|
||||
return $groupSettings->mapWithKeys(function ($setting) {
|
||||
return [$setting->key => $setting->getCastedValue()];
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the casted value based on type
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getCastedValue()
|
||||
{
|
||||
if ($this->value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($this->type) {
|
||||
'boolean' => filter_var($this->value, FILTER_VALIDATE_BOOLEAN),
|
||||
'integer' => (int) $this->value,
|
||||
'json', 'array' => json_decode($this->value, true),
|
||||
default => $this->value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode value for storage based on type
|
||||
*
|
||||
* @param mixed $value
|
||||
* @param string $type
|
||||
* @return string|null
|
||||
*/
|
||||
protected static function encodeValue($value, string $type): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
'boolean' => $value ? '1' : '0',
|
||||
'integer' => (string) $value,
|
||||
'json', 'array' => json_encode($value),
|
||||
default => (string) $value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a boolean setting
|
||||
*
|
||||
* @param string $key
|
||||
* @return bool New value after toggle
|
||||
*/
|
||||
public static function toggle(string $key): bool
|
||||
{
|
||||
$currentValue = self::get($key, false);
|
||||
$newValue = !$currentValue;
|
||||
self::set($key, $newValue, 'boolean');
|
||||
|
||||
return $newValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment an integer setting
|
||||
*
|
||||
* @param string $key
|
||||
* @param int $amount
|
||||
* @return int
|
||||
*/
|
||||
public static function incrementSetting(string $key, int $amount = 1): int
|
||||
{
|
||||
$currentValue = self::get($key, 0);
|
||||
$newValue = $currentValue + $amount;
|
||||
self::set($key, $newValue, 'integer');
|
||||
|
||||
return $newValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all settings cache
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function clearCache(): void
|
||||
{
|
||||
Cache::flush();
|
||||
}
|
||||
}
|
||||
87
app/Models/Transaction.php
Normal file
87
app/Models/Transaction.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Transaction extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'budget_item_id',
|
||||
'chart_of_account_id',
|
||||
'transaction_date',
|
||||
'amount',
|
||||
'transaction_type',
|
||||
'description',
|
||||
'reference_number',
|
||||
'finance_document_id',
|
||||
'membership_payment_id',
|
||||
'created_by_user_id',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'transaction_date' => 'date',
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function budgetItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BudgetItem::class);
|
||||
}
|
||||
|
||||
public function chartOfAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChartOfAccount::class);
|
||||
}
|
||||
|
||||
public function financeDocument(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FinanceDocument::class);
|
||||
}
|
||||
|
||||
public function membershipPayment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MembershipPayment::class);
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function isIncome(): bool
|
||||
{
|
||||
return $this->transaction_type === 'income';
|
||||
}
|
||||
|
||||
public function isExpense(): bool
|
||||
{
|
||||
return $this->transaction_type === 'expense';
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeIncome($query)
|
||||
{
|
||||
return $query->where('transaction_type', 'income');
|
||||
}
|
||||
|
||||
public function scopeExpense($query)
|
||||
{
|
||||
return $query->where('transaction_type', 'expense');
|
||||
}
|
||||
|
||||
public function scopeForPeriod($query, $startDate, $endDate)
|
||||
{
|
||||
return $query->whereBetween('transaction_date', [$startDate, $endDate]);
|
||||
}
|
||||
}
|
||||
65
app/Models/User.php
Normal file
65
app/Models/User.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable, HasRoles;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'is_admin',
|
||||
'profile_photo_path',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_admin' => 'boolean',
|
||||
];
|
||||
|
||||
public function member(): HasOne
|
||||
{
|
||||
return $this->hasOne(Member::class);
|
||||
}
|
||||
|
||||
public function profilePhotoUrl(): ?string
|
||||
{
|
||||
if (! $this->profile_photo_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::disk('public')->url($this->profile_photo_path);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user