Initial commit

This commit is contained in:
2025-11-20 23:21:05 +08:00
commit 13bc6db529
378 changed files with 54527 additions and 0 deletions

28
app/Models/AuditLog.php Normal file
View 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);
}
}

View 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
View 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
View 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;
}
}

View 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;
}
}

View 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';
}
}

View 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 ?? []);
}
}

View 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
View 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());
}
}

View 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;
}
}

View 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 => '未知',
};
}
}

View 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();
}
}

View 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 ? '當前版本' : '歷史版本';
}
}

View 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';
}
}

View 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
View 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);
});
}
}

View 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);
}
});
}
}

View 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
View 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';
}
}

View 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,
};
}
}

View 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
View 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();
}
}

View 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
View 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 => '未知',
};
}
}

View 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();
}
}

View 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
View 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);
}
}