Add membership fee system with disability discount and fix document permissions

Features:
- Implement two fee types: entrance fee and annual fee (both NT$1,000)
- Add 50% discount for disability certificate holders
- Add disability certificate upload in member profile
- Integrate disability verification into cashier approval workflow
- Add membership fee settings in system admin

Document permissions:
- Fix hard-coded role logic in Document model
- Use permission-based authorization instead of role checks

Additional features:
- Add announcements, general ledger, and trial balance modules
- Add income management and accounting entries
- Add comprehensive test suite with factories
- Update UI translations to Traditional Chinese

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-01 09:56:01 +08:00
parent 83ce1f7fc8
commit 642b879dd4
207 changed files with 19487 additions and 3048 deletions

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AccountingEntry extends Model
{
use HasFactory;
const ENTRY_TYPE_DEBIT = 'debit';
const ENTRY_TYPE_CREDIT = 'credit';
protected $fillable = [
'finance_document_id',
'income_id',
'chart_of_account_id',
'entry_type',
'amount',
'entry_date',
'description',
];
protected $casts = [
'entry_date' => 'date',
'amount' => 'decimal:2',
];
/**
* Get the finance document that owns this entry
*/
public function financeDocument()
{
return $this->belongsTo(FinanceDocument::class);
}
/**
* Get the income that owns this entry
*/
public function income()
{
return $this->belongsTo(Income::class);
}
/**
* Get the chart of account for this entry
*/
public function chartOfAccount()
{
return $this->belongsTo(ChartOfAccount::class);
}
/**
* Check if this is a debit entry
*/
public function isDebit(): bool
{
return $this->entry_type === self::ENTRY_TYPE_DEBIT;
}
/**
* Check if this is a credit entry
*/
public function isCredit(): bool
{
return $this->entry_type === self::ENTRY_TYPE_CREDIT;
}
/**
* Scope to filter debit entries
*/
public function scopeDebits($query)
{
return $query->where('entry_type', self::ENTRY_TYPE_DEBIT);
}
/**
* Scope to filter credit entries
*/
public function scopeCredits($query)
{
return $query->where('entry_type', self::ENTRY_TYPE_CREDIT);
}
/**
* Scope to filter by account
*/
public function scopeForAccount($query, $accountId)
{
return $query->where('chart_of_account_id', $accountId);
}
/**
* Scope to filter by date range
*/
public function scopeDateRange($query, $startDate, $endDate)
{
return $query->whereBetween('entry_date', [$startDate, $endDate]);
}
}

427
app/Models/Announcement.php Normal file
View File

@@ -0,0 +1,427 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;
class Announcement extends Model
{
use HasFactory, SoftDeletes;
// ==================== Constants ====================
const STATUS_DRAFT = 'draft';
const STATUS_PUBLISHED = 'published';
const STATUS_ARCHIVED = 'archived';
const ACCESS_LEVEL_PUBLIC = 'public';
const ACCESS_LEVEL_MEMBERS = 'members';
const ACCESS_LEVEL_BOARD = 'board';
const ACCESS_LEVEL_ADMIN = 'admin';
// ==================== Configuration ====================
protected $fillable = [
'title',
'content',
'status',
'is_pinned',
'display_order',
'access_level',
'published_at',
'expires_at',
'archived_at',
'view_count',
'created_by_user_id',
'last_updated_by_user_id',
];
protected $casts = [
'is_pinned' => 'boolean',
'display_order' => 'integer',
'view_count' => 'integer',
'published_at' => 'datetime',
'expires_at' => 'datetime',
'archived_at' => 'datetime',
];
// ==================== Relationships ====================
/**
* Get the user who created this announcement
*/
public function creator()
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
/**
* Get the user who last updated this announcement
*/
public function lastUpdatedBy()
{
return $this->belongsTo(User::class, 'last_updated_by_user_id');
}
// ==================== Status Check Methods ====================
/**
* Check if announcement is draft
*/
public function isDraft(): bool
{
return $this->status === self::STATUS_DRAFT;
}
/**
* Check if announcement is published
*/
public function isPublished(): bool
{
return $this->status === self::STATUS_PUBLISHED;
}
/**
* Check if announcement is archived
*/
public function isArchived(): bool
{
return $this->status === self::STATUS_ARCHIVED;
}
/**
* Check if announcement is pinned
*/
public function isPinned(): bool
{
return $this->is_pinned;
}
/**
* Check if announcement is expired
*/
public function isExpired(): bool
{
if (!$this->expires_at) {
return false;
}
return $this->expires_at->isPast();
}
/**
* Check if announcement is scheduled (published_at is in the future)
*/
public function isScheduled(): bool
{
if (!$this->published_at) {
return false;
}
return $this->published_at->isFuture();
}
/**
* Check if announcement is currently active
*/
public function isActive(): bool
{
return $this->isPublished()
&& !$this->isExpired()
&& (!$this->published_at || $this->published_at->isPast());
}
// ==================== Access Control Methods ====================
/**
* Check if a user can view this announcement
*/
public function canBeViewedBy(?User $user): bool
{
// Draft announcements - only creator and admins can view
if ($this->isDraft()) {
if (!$user) {
return false;
}
return $user->id === $this->created_by_user_id
|| $user->hasRole('admin')
|| $user->can('manage_all_announcements');
}
// Archived announcements - only admins can view
if ($this->isArchived()) {
if (!$user) {
return false;
}
return $user->hasRole('admin') || $user->can('manage_all_announcements');
}
// Expired announcements - hidden from regular users
if ($this->isExpired()) {
if (!$user) {
return false;
}
return $user->hasRole('admin') || $user->can('manage_all_announcements');
}
// Scheduled announcements - not yet visible
if ($this->isScheduled()) {
if (!$user) {
return false;
}
return $user->id === $this->created_by_user_id
|| $user->hasRole('admin')
|| $user->can('manage_all_announcements');
}
// Check access level for published announcements
if ($this->access_level === self::ACCESS_LEVEL_PUBLIC) {
return true;
}
if (!$user) {
return false;
}
if ($user->hasRole('admin')) {
return true;
}
if ($this->access_level === self::ACCESS_LEVEL_MEMBERS) {
return $user->member && $user->member->hasPaidMembership();
}
if ($this->access_level === self::ACCESS_LEVEL_BOARD) {
return $user->hasRole(['admin', 'finance_chair', 'finance_board_member']);
}
if ($this->access_level === self::ACCESS_LEVEL_ADMIN) {
return $user->hasRole('admin');
}
return false;
}
/**
* Check if a user can edit this announcement
*/
public function canBeEditedBy(User $user): bool
{
// Admin and users with manage_all_announcements can edit all
if ($user->hasRole('admin') || $user->can('manage_all_announcements')) {
return true;
}
// User must have edit_announcements permission
if (!$user->can('edit_announcements')) {
return false;
}
// Can only edit own announcements
return $user->id === $this->created_by_user_id;
}
// ==================== Query Scopes ====================
/**
* Scope to only published announcements
*/
public function scopePublished(Builder $query): Builder
{
return $query->where('status', self::STATUS_PUBLISHED);
}
/**
* Scope to only draft announcements
*/
public function scopeDraft(Builder $query): Builder
{
return $query->where('status', self::STATUS_DRAFT);
}
/**
* Scope to only archived announcements
*/
public function scopeArchived(Builder $query): Builder
{
return $query->where('status', self::STATUS_ARCHIVED);
}
/**
* Scope to only active announcements (published, not expired, not scheduled)
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('status', self::STATUS_PUBLISHED)
->where(function ($q) {
$q->whereNull('published_at')
->orWhere('published_at', '<=', now());
})
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
/**
* Scope to only pinned announcements
*/
public function scopePinned(Builder $query): Builder
{
return $query->where('is_pinned', true);
}
/**
* Scope to filter by access level
*/
public function scopeForAccessLevel(Builder $query, User $user): Builder
{
if ($user->hasRole('admin')) {
return $query;
}
$accessLevels = [self::ACCESS_LEVEL_PUBLIC];
if ($user->member && $user->member->hasPaidMembership()) {
$accessLevels[] = self::ACCESS_LEVEL_MEMBERS;
}
if ($user->hasRole(['finance_chair', 'finance_board_member'])) {
$accessLevels[] = self::ACCESS_LEVEL_BOARD;
}
return $query->whereIn('access_level', $accessLevels);
}
// ==================== Helper Methods ====================
/**
* Publish this announcement
*/
public function publish(?User $user = null): void
{
$updates = [
'status' => self::STATUS_PUBLISHED,
];
if (!$this->published_at) {
$updates['published_at'] = now();
}
if ($user) {
$updates['last_updated_by_user_id'] = $user->id;
}
$this->update($updates);
}
/**
* Archive this announcement
*/
public function archive(?User $user = null): void
{
$updates = [
'status' => self::STATUS_ARCHIVED,
'archived_at' => now(),
];
if ($user) {
$updates['last_updated_by_user_id'] = $user->id;
}
$this->update($updates);
}
/**
* Pin this announcement
*/
public function pin(?int $order = null, ?User $user = null): void
{
$updates = [
'is_pinned' => true,
'display_order' => $order ?? 0,
];
if ($user) {
$updates['last_updated_by_user_id'] = $user->id;
}
$this->update($updates);
}
/**
* Unpin this announcement
*/
public function unpin(?User $user = null): void
{
$updates = [
'is_pinned' => false,
'display_order' => 0,
];
if ($user) {
$updates['last_updated_by_user_id'] = $user->id;
}
$this->update($updates);
}
/**
* Increment view count
*/
public function incrementViewCount(): void
{
$this->increment('view_count');
}
/**
* Get the access level label in Chinese
*/
public function getAccessLevelLabel(): string
{
return match($this->access_level) {
self::ACCESS_LEVEL_PUBLIC => '公開',
self::ACCESS_LEVEL_MEMBERS => '會員',
self::ACCESS_LEVEL_BOARD => '理事會',
self::ACCESS_LEVEL_ADMIN => '管理員',
default => '未知',
};
}
/**
* Get status label in Chinese
*/
public function getStatusLabel(): string
{
return match($this->status) {
self::STATUS_DRAFT => '草稿',
self::STATUS_PUBLISHED => '已發布',
self::STATUS_ARCHIVED => '已歸檔',
default => '未知',
};
}
/**
* Get status badge color
*/
public function getStatusBadgeColor(): string
{
return match($this->status) {
self::STATUS_DRAFT => 'gray',
self::STATUS_PUBLISHED => 'green',
self::STATUS_ARCHIVED => 'yellow',
default => 'gray',
};
}
/**
* Get content excerpt (first 150 characters)
*/
public function getExcerpt(int $length = 150): string
{
return \Illuminate\Support\Str::limit($this->content, $length);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BoardMeeting extends Model
{
use HasFactory;
protected $fillable = [
'meeting_date',
'title',
'notes',
'status',
];
protected $casts = [
'meeting_date' => 'date',
];
/**
* Get the finance documents approved by this board meeting.
*/
public function approvedFinanceDocuments(): HasMany
{
return $this->hasMany(FinanceDocument::class, 'approved_by_board_meeting_id');
}
}

View File

@@ -45,7 +45,7 @@ class CashierLedgerEntry extends Model
const PAYMENT_METHOD_CASH = 'cash';
/**
* 關聯到財務申請單
* 關聯到報銷申請單
*/
public function financeDocument(): BelongsTo
{

View File

@@ -178,7 +178,7 @@ class Document extends Model
'original_filename' => $originalFilename,
'mime_type' => $mimeType,
'file_size' => $fileSize,
'file_hash' => hash_file('sha256', storage_path('app/' . $filePath)),
'file_hash' => hash_file('sha256', \Illuminate\Support\Facades\Storage::disk('private')->path($filePath)),
'uploaded_by_user_id' => $uploadedBy->id,
'uploaded_at' => now(),
]);
@@ -265,24 +265,44 @@ class Document extends Model
*/
public function canBeViewedBy(?User $user): bool
{
// 公開文件:任何人可看
if ($this->isPublic()) {
return true;
}
// 非公開文件需要登入
if (!$user) {
return false;
}
if ($user->is_admin || $user->hasRole('admin')) {
// 有文件管理權限者可看所有文件
if ($user->can('manage_documents')) {
return true;
}
// 會員等級:已繳費會員可看
if ($this->access_level === 'members') {
return $user->member && $user->member->hasPaidMembership();
}
// 管理員等級:有任何管理權限者可看
if ($this->access_level === 'admin') {
return $user->hasAnyPermission([
'manage_documents',
'manage_members',
'manage_finance',
'manage_system_settings',
]);
}
// 理事會等級:有理事會相關權限者可看
if ($this->access_level === 'board') {
return $user->hasRole(['admin', 'chair', 'board']);
return $user->hasAnyPermission([
'manage_documents',
'approve_finance_documents',
'verify_payments_chair',
'activate_memberships',
]);
}
return false;

View File

@@ -11,18 +11,26 @@ class FinanceDocument extends Model
{
use HasFactory;
// Status constants
public const STATUS_PENDING = 'pending';
// Status constants (審核階段)
public const STATUS_PENDING = 'pending'; // 待審核
public const STATUS_APPROVED_SECRETARY = 'approved_secretary'; // 秘書長已核准
public const STATUS_APPROVED_CHAIR = 'approved_chair'; // 理事長已核准
public const STATUS_APPROVED_BOARD = 'approved_board'; // 董理事會已核准
public const STATUS_REJECTED = 'rejected'; // 已駁回
// Legacy status constants (保留向後相容)
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';
// Disbursement status constants (出帳階段)
public const DISBURSEMENT_PENDING = 'pending'; // 待出帳
public const DISBURSEMENT_REQUESTER_CONFIRMED = 'requester_confirmed'; // 申請人已確認
public const DISBURSEMENT_CASHIER_CONFIRMED = 'cashier_confirmed'; // 出納已確認
public const DISBURSEMENT_COMPLETED = 'completed'; // 已出帳
// Recording status constants (入帳階段)
public const RECORDING_PENDING = 'pending'; // 待入帳
public const RECORDING_COMPLETED = 'completed'; // 已入帳
// Amount tier constants
public const AMOUNT_TIER_SMALL = 'small'; // < 5,000
@@ -63,7 +71,6 @@ class FinanceDocument extends Model
'rejected_at',
'rejection_reason',
// New payment stage fields
'request_type',
'amount_tier',
'chart_of_account_id',
'budget_item_id',
@@ -89,6 +96,17 @@ class FinanceDocument extends Model
'bank_reconciliation_id',
'reconciliation_status',
'reconciled_at',
// 新工作流程欄位
'approved_by_secretary_id',
'secretary_approved_at',
'disbursement_status',
'requester_confirmed_at',
'requester_confirmed_by_id',
'cashier_confirmed_at',
'cashier_confirmed_by_id',
'recording_status',
'accountant_recorded_at',
'accountant_recorded_by_id',
];
protected $casts = [
@@ -106,6 +124,11 @@ class FinanceDocument extends Model
'payment_executed_at' => 'datetime',
'actual_payment_amount' => 'decimal:2',
'reconciled_at' => 'datetime',
// 新工作流程欄位
'secretary_approved_at' => 'datetime',
'requester_confirmed_at' => 'datetime',
'cashier_confirmed_at' => 'datetime',
'accountant_recorded_at' => 'datetime',
];
public function member()
@@ -138,6 +161,29 @@ class FinanceDocument extends Model
return $this->belongsTo(User::class, 'rejected_by_user_id');
}
/**
* 新工作流程 Relationships
*/
public function approvedBySecretary(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by_secretary_id');
}
public function requesterConfirmedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'requester_confirmed_by_id');
}
public function cashierConfirmedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'cashier_confirmed_by_id');
}
public function accountantRecordedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'accountant_recorded_by_id');
}
/**
* New payment stage relationships
*/
@@ -187,9 +233,140 @@ class FinanceDocument extends Model
}
/**
* Check if document can be approved by cashier
* Get all accounting entries for this document
*/
public function canBeApprovedByCashier(?User $user = null): bool
public function accountingEntries()
{
return $this->hasMany(AccountingEntry::class);
}
/**
* Get debit entries for this document
*/
public function debitEntries()
{
return $this->accountingEntries()->where('entry_type', AccountingEntry::ENTRY_TYPE_DEBIT);
}
/**
* Get credit entries for this document
*/
public function creditEntries()
{
return $this->accountingEntries()->where('entry_type', AccountingEntry::ENTRY_TYPE_CREDIT);
}
/**
* Validate that debit and credit entries balance
*/
public function validateBalance(): bool
{
$debitTotal = $this->debitEntries()->sum('amount');
$creditTotal = $this->creditEntries()->sum('amount');
return bccomp((string)$debitTotal, (string)$creditTotal, 2) === 0;
}
/**
* Generate accounting entries for this document
* This creates the double-entry bookkeeping records
*/
public function generateAccountingEntries(array $entries): void
{
// Delete existing entries
$this->accountingEntries()->delete();
// Create new entries
foreach ($entries as $entry) {
$this->accountingEntries()->create([
'chart_of_account_id' => $entry['chart_of_account_id'],
'entry_type' => $entry['entry_type'],
'amount' => $entry['amount'],
'entry_date' => $entry['entry_date'] ?? $this->submitted_at ?? now(),
'description' => $entry['description'] ?? $this->description,
]);
}
}
/**
* Auto-generate simple accounting entries based on document type
* For basic income/expense transactions
*/
public function autoGenerateAccountingEntries(): void
{
// Only auto-generate if chart_of_account_id is set
if (!$this->chart_of_account_id) {
return;
}
$entries = [];
$entryDate = $this->submitted_at ?? now();
// Determine if this is income or expense based on request type or account type
$account = $this->chartOfAccount;
if (!$account) {
return;
}
if ($account->account_type === 'income') {
// Income: Debit Cash, Credit Income Account
$entries[] = [
'chart_of_account_id' => $this->getCashAccountId(),
'entry_type' => AccountingEntry::ENTRY_TYPE_DEBIT,
'amount' => $this->amount,
'entry_date' => $entryDate,
'description' => '收入 - ' . ($this->description ?? $this->title),
];
$entries[] = [
'chart_of_account_id' => $this->chart_of_account_id,
'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT,
'amount' => $this->amount,
'entry_date' => $entryDate,
'description' => $this->description ?? $this->title,
];
} elseif ($account->account_type === 'expense') {
// Expense: Debit Expense Account, Credit Cash
$entries[] = [
'chart_of_account_id' => $this->chart_of_account_id,
'entry_type' => AccountingEntry::ENTRY_TYPE_DEBIT,
'amount' => $this->amount,
'entry_date' => $entryDate,
'description' => $this->description ?? $this->title,
];
$entries[] = [
'chart_of_account_id' => $this->getCashAccountId(),
'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT,
'amount' => $this->amount,
'entry_date' => $entryDate,
'description' => '支出 - ' . ($this->description ?? $this->title),
];
}
if (!empty($entries)) {
$this->generateAccountingEntries($entries);
}
}
/**
* Get the cash account ID (1101 - 現金)
*/
protected function getCashAccountId(): int
{
static $cashAccountId = null;
if ($cashAccountId === null) {
$cashAccount = ChartOfAccount::where('account_code', '1101')->first();
$cashAccountId = $cashAccount ? $cashAccount->id : 1;
}
return $cashAccountId;
}
/**
* 新工作流程:秘書長可審核
* 條件:待審核狀態 + 不能審核自己的申請
*/
public function canBeApprovedBySecretary(?User $user = null): bool
{
if ($this->status !== self::STATUS_PENDING) {
return false;
@@ -203,27 +380,164 @@ class FinanceDocument extends Model
}
/**
* Check if document can be approved by accountant
* 新工作流程:理事長可審核
* 條件:秘書長已核准 + 中額或大額
*/
public function canBeApprovedByAccountant(): bool
public function canBeApprovedByChair(?User $user = null): bool
{
return $this->status === self::STATUS_APPROVED_CASHIER;
$tier = $this->amount_tier ?? $this->determineAmountTier();
if ($this->status !== self::STATUS_APPROVED_SECRETARY) {
return false;
}
if (!in_array($tier, [self::AMOUNT_TIER_MEDIUM, self::AMOUNT_TIER_LARGE])) {
return false;
}
if ($user && $this->submitted_by_user_id && $this->submitted_by_user_id === $user->id) {
return false;
}
return true;
}
/**
* Check if document can be approved by chair
* 新工作流程:董理事會可審核
* 條件:理事長已核准 + 大額
*/
public function canBeApprovedByChair(): bool
public function canBeApprovedByBoard(?User $user = null): bool
{
return $this->status === self::STATUS_APPROVED_ACCOUNTANT;
$tier = $this->amount_tier ?? $this->determineAmountTier();
if ($this->status !== self::STATUS_APPROVED_CHAIR) {
return false;
}
if ($tier !== self::AMOUNT_TIER_LARGE) {
return false;
}
return true;
}
/**
* Check if document is fully approved
* 新工作流程:審核是否完成
* 依金額級別判斷
*/
public function isApprovalComplete(): bool
{
$tier = $this->amount_tier ?? $this->determineAmountTier();
// 小額:秘書長核准即可
if ($tier === self::AMOUNT_TIER_SMALL) {
return $this->status === self::STATUS_APPROVED_SECRETARY;
}
// 中額:理事長核准
if ($tier === self::AMOUNT_TIER_MEDIUM) {
return $this->status === self::STATUS_APPROVED_CHAIR;
}
// 大額:董理事會核准
return $this->status === self::STATUS_APPROVED_BOARD;
}
/**
* Check if document is fully approved (alias for isApprovalComplete)
*/
public function isFullyApproved(): bool
{
return $this->status === self::STATUS_APPROVED_CHAIR;
return $this->isApprovalComplete();
}
// ========== 出帳階段方法 ==========
/**
* 申請人可確認出帳
* 條件:審核完成 + 尚未確認 + 是原申請人
*/
public function canRequesterConfirmDisbursement(?User $user = null): bool
{
if (!$this->isApprovalComplete()) {
return false;
}
if ($this->requester_confirmed_at !== null) {
return false;
}
// 只有原申請人可以確認
if ($user && $this->submitted_by_user_id !== $user->id) {
return false;
}
return true;
}
/**
* 出納可確認出帳
* 條件:審核完成 + 尚未確認
*/
public function canCashierConfirmDisbursement(): bool
{
if (!$this->isApprovalComplete()) {
return false;
}
if ($this->cashier_confirmed_at !== null) {
return false;
}
return true;
}
/**
* 出帳是否完成(雙重確認)
*/
public function isDisbursementComplete(): bool
{
return $this->requester_confirmed_at !== null
&& $this->cashier_confirmed_at !== null;
}
// ========== 入帳階段方法 ==========
/**
* 會計可入帳
* 條件:出帳完成 + 尚未入帳
*/
public function canAccountantConfirmRecording(): bool
{
return $this->isDisbursementComplete()
&& $this->accountant_recorded_at === null;
}
/**
* 入帳是否完成
*/
public function isRecordingComplete(): bool
{
return $this->accountant_recorded_at !== null;
}
// ========== Legacy methods for backward compatibility ==========
/**
* @deprecated Use canBeApprovedBySecretary instead
*/
public function canBeApprovedByCashier(?User $user = null): bool
{
return $this->canBeApprovedBySecretary($user);
}
/**
* @deprecated Use isApprovalComplete with amount tier logic
*/
public function canBeApprovedByAccountant(): bool
{
// Legacy: accountant approval after cashier
return $this->status === self::STATUS_APPROVED_CASHIER;
}
/**
@@ -235,20 +549,87 @@ class FinanceDocument extends Model
}
/**
* Get human-readable status
* 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',
self::STATUS_PENDING => '待審核',
self::STATUS_APPROVED_SECRETARY => '秘書長已核准',
self::STATUS_APPROVED_CHAIR => '理事長已核准',
self::STATUS_APPROVED_BOARD => '董理事會已核准',
self::STATUS_REJECTED => '已駁回',
// Legacy statuses
self::STATUS_APPROVED_CASHIER => '出納已審核',
self::STATUS_APPROVED_ACCOUNTANT => '會計已審核',
default => ucfirst($this->status),
};
}
/**
* Get disbursement status label (中文)
*/
public function getDisbursementStatusLabelAttribute(): string
{
if (!$this->isApprovalComplete()) {
return '審核中';
}
if ($this->isDisbursementComplete()) {
return '已出帳';
}
if ($this->requester_confirmed_at !== null && $this->cashier_confirmed_at === null) {
return '申請人已確認,待出納確認';
}
if ($this->requester_confirmed_at === null && $this->cashier_confirmed_at !== null) {
return '出納已確認,待申請人確認';
}
return '待出帳';
}
/**
* Get recording status label (中文)
*/
public function getRecordingStatusLabelAttribute(): string
{
if (!$this->isDisbursementComplete()) {
return '尚未出帳';
}
if ($this->accountant_recorded_at !== null) {
return '已入帳';
}
return '待入帳';
}
/**
* Get overall workflow stage label (中文)
*/
public function getWorkflowStageLabelAttribute(): string
{
if ($this->isRejected()) {
return '已駁回';
}
if (!$this->isApprovalComplete()) {
return '審核階段';
}
if (!$this->isDisbursementComplete()) {
return '出帳階段';
}
if (!$this->isRecordingComplete()) {
return '入帳階段';
}
return '已完成';
}
/**
* New payment stage business logic methods
*/
@@ -277,29 +658,12 @@ class FinanceDocument extends Model
}
/**
* Check if approval stage is complete (ready for payment order creation)
* Check if approval stage is complete (ready for disbursement)
* 新工作流程:使用 isApprovalComplete()
*/
public function isApprovalStageComplete(): bool
{
$tier = $this->amount_tier ?? $this->determineAmountTier();
// For small amounts: cashier + accountant
if ($tier === self::AMOUNT_TIER_SMALL) {
return $this->status === self::STATUS_APPROVED_ACCOUNTANT;
}
// For medium amounts: cashier + accountant + chair
if ($tier === self::AMOUNT_TIER_MEDIUM) {
return $this->status === self::STATUS_APPROVED_CHAIR;
}
// For large amounts: cashier + accountant + chair + board meeting
if ($tier === self::AMOUNT_TIER_LARGE) {
return $this->status === self::STATUS_APPROVED_CHAIR &&
$this->board_meeting_approved_at !== null;
}
return false;
return $this->isApprovalComplete();
}
/**
@@ -341,21 +705,13 @@ class FinanceDocument extends Model
return $this->payment_executed_at !== null;
}
/**
* Check if recording stage is complete
*/
public function isRecordingComplete(): bool
{
return $this->cashier_recorded_at !== null;
}
/**
* Check if document is fully processed (all stages complete)
*/
public function isFullyProcessed(): bool
{
return $this->isApprovalStageComplete() &&
$this->isPaymentCompleted() &&
return $this->isApprovalComplete() &&
$this->isDisbursementComplete() &&
$this->isRecordingComplete();
}
@@ -425,20 +781,6 @@ class FinanceDocument extends Model
$this->attributes['approved_by_board_meeting_id'] = $value;
}
/**
* 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
*/

446
app/Models/Income.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\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
class Income extends Model
{
use HasFactory;
// 收入類型常數
const TYPE_MEMBERSHIP_FEE = 'membership_fee'; // 會費收入
const TYPE_ENTRANCE_FEE = 'entrance_fee'; // 入會費收入
const TYPE_DONATION = 'donation'; // 捐款收入
const TYPE_ACTIVITY = 'activity'; // 活動收入
const TYPE_GRANT = 'grant'; // 補助收入
const TYPE_INTEREST = 'interest'; // 利息收入
const TYPE_OTHER = 'other'; // 其他收入
// 狀態常數
const STATUS_PENDING = 'pending'; // 待確認
const STATUS_CONFIRMED = 'confirmed'; // 已確認
const STATUS_CANCELLED = 'cancelled'; // 已取消
// 付款方式常數
const PAYMENT_METHOD_CASH = 'cash';
const PAYMENT_METHOD_BANK_TRANSFER = 'bank_transfer';
const PAYMENT_METHOD_CHECK = 'check';
protected $fillable = [
'income_number',
'title',
'description',
'income_date',
'amount',
'income_type',
'chart_of_account_id',
'payment_method',
'bank_account',
'payer_name',
'receipt_number',
'transaction_reference',
'attachment_path',
'member_id',
'status',
'recorded_by_cashier_id',
'recorded_at',
'confirmed_by_accountant_id',
'confirmed_at',
'cashier_ledger_entry_id',
'notes',
];
protected $casts = [
'income_date' => 'date',
'amount' => 'decimal:2',
'recorded_at' => 'datetime',
'confirmed_at' => 'datetime',
];
/**
* Boot 方法 - 自動產生收入編號
*/
protected static function boot()
{
parent::boot();
static::creating(function ($income) {
if (empty($income->income_number)) {
$income->income_number = self::generateIncomeNumber();
}
if (empty($income->recorded_at)) {
$income->recorded_at = now();
}
});
}
/**
* 產生收入編號 INC-2025-0001
*/
public static function generateIncomeNumber(): string
{
$year = date('Y');
$prefix = "INC-{$year}-";
$lastIncome = self::where('income_number', 'like', "{$prefix}%")
->orderBy('income_number', 'desc')
->first();
if ($lastIncome) {
$lastNumber = (int) substr($lastIncome->income_number, -4);
$newNumber = $lastNumber + 1;
} else {
$newNumber = 1;
}
return $prefix . str_pad($newNumber, 4, '0', STR_PAD_LEFT);
}
// ========== 關聯 ==========
/**
* 會計科目
*/
public function chartOfAccount(): BelongsTo
{
return $this->belongsTo(ChartOfAccount::class);
}
/**
* 關聯會員
*/
public function member(): BelongsTo
{
return $this->belongsTo(Member::class);
}
/**
* 記錄的出納人員
*/
public function recordedByCashier(): BelongsTo
{
return $this->belongsTo(User::class, 'recorded_by_cashier_id');
}
/**
* 確認的會計人員
*/
public function confirmedByAccountant(): BelongsTo
{
return $this->belongsTo(User::class, 'confirmed_by_accountant_id');
}
/**
* 關聯的出納日記帳
*/
public function cashierLedgerEntry(): BelongsTo
{
return $this->belongsTo(CashierLedgerEntry::class);
}
/**
* 會計分錄
*/
public function accountingEntries(): HasMany
{
return $this->hasMany(AccountingEntry::class);
}
// ========== 狀態查詢 ==========
/**
* 是否待確認
*/
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
/**
* 是否已確認
*/
public function isConfirmed(): bool
{
return $this->status === self::STATUS_CONFIRMED;
}
/**
* 是否已取消
*/
public function isCancelled(): bool
{
return $this->status === self::STATUS_CANCELLED;
}
/**
* 是否可以被會計確認
*/
public function canBeConfirmed(): bool
{
return $this->status === self::STATUS_PENDING;
}
/**
* 是否可以被取消
*/
public function canBeCancelled(): bool
{
return $this->status === self::STATUS_PENDING;
}
// ========== 業務方法 ==========
/**
* 會計確認收入
*/
public function confirmByAccountant(User $accountant): void
{
if (!$this->canBeConfirmed()) {
throw new \Exception('此收入無法確認');
}
DB::transaction(function () use ($accountant) {
// 1. 更新收入狀態
$this->update([
'status' => self::STATUS_CONFIRMED,
'confirmed_by_accountant_id' => $accountant->id,
'confirmed_at' => now(),
]);
// 2. 產生出納日記帳記錄
$ledgerEntry = $this->createCashierLedgerEntry();
// 3. 產生會計分錄
$this->generateAccountingEntries();
});
}
/**
* 取消收入
*/
public function cancel(): void
{
if (!$this->canBeCancelled()) {
throw new \Exception('此收入無法取消');
}
$this->update([
'status' => self::STATUS_CANCELLED,
]);
}
/**
* 建立出納日記帳記錄
*/
protected function createCashierLedgerEntry(): CashierLedgerEntry
{
$bankAccount = $this->bank_account ?? 'Main Account';
$balanceBefore = CashierLedgerEntry::getLatestBalance($bankAccount);
$ledgerEntry = CashierLedgerEntry::create([
'entry_date' => $this->income_date,
'entry_type' => CashierLedgerEntry::ENTRY_TYPE_RECEIPT,
'payment_method' => $this->payment_method,
'bank_account' => $bankAccount,
'amount' => $this->amount,
'balance_before' => $balanceBefore,
'balance_after' => $balanceBefore + $this->amount,
'receipt_number' => $this->receipt_number,
'transaction_reference' => $this->transaction_reference,
'recorded_by_cashier_id' => $this->recorded_by_cashier_id,
'recorded_at' => now(),
'notes' => "收入確認:{$this->title} ({$this->income_number})",
]);
$this->update(['cashier_ledger_entry_id' => $ledgerEntry->id]);
return $ledgerEntry;
}
/**
* 產生會計分錄
*/
protected function generateAccountingEntries(): void
{
// 借方:資產帳戶(現金或銀行存款)
$assetAccountId = $this->getAssetAccountId();
AccountingEntry::create([
'income_id' => $this->id,
'chart_of_account_id' => $assetAccountId,
'entry_type' => AccountingEntry::ENTRY_TYPE_DEBIT,
'amount' => $this->amount,
'entry_date' => $this->income_date,
'description' => "收入:{$this->title} ({$this->income_number})",
]);
// 貸方:收入科目
AccountingEntry::create([
'income_id' => $this->id,
'chart_of_account_id' => $this->chart_of_account_id,
'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT,
'amount' => $this->amount,
'entry_date' => $this->income_date,
'description' => "收入:{$this->title} ({$this->income_number})",
]);
}
/**
* 根據付款方式取得資產帳戶 ID
*/
protected function getAssetAccountId(): int
{
$accountCode = match ($this->payment_method) {
self::PAYMENT_METHOD_BANK_TRANSFER => '1201', // 銀行存款
self::PAYMENT_METHOD_CHECK => '1201', // 銀行存款
default => '1101', // 現金
};
return ChartOfAccount::where('account_code', $accountCode)->value('id') ?? 1;
}
// ========== 文字取得 ==========
/**
* 取得收入類型文字
*/
public function getIncomeTypeText(): string
{
return match ($this->income_type) {
self::TYPE_MEMBERSHIP_FEE => '會費收入',
self::TYPE_ENTRANCE_FEE => '入會費收入',
self::TYPE_DONATION => '捐款收入',
self::TYPE_ACTIVITY => '活動收入',
self::TYPE_GRANT => '補助收入',
self::TYPE_INTEREST => '利息收入',
self::TYPE_OTHER => '其他收入',
default => '未知',
};
}
/**
* 取得狀態文字
*/
public function getStatusText(): string
{
return match ($this->status) {
self::STATUS_PENDING => '待確認',
self::STATUS_CONFIRMED => '已確認',
self::STATUS_CANCELLED => '已取消',
default => '未知',
};
}
/**
* 取得付款方式文字
*/
public function getPaymentMethodText(): string
{
return match ($this->payment_method) {
self::PAYMENT_METHOD_CASH => '現金',
self::PAYMENT_METHOD_BANK_TRANSFER => '銀行轉帳',
self::PAYMENT_METHOD_CHECK => '支票',
default => '未知',
};
}
/**
* 取得狀態標籤屬性
*/
public function getStatusLabelAttribute(): string
{
return $this->getStatusText();
}
// ========== 收入類型與科目對應 ==========
/**
* 取得收入類型對應的預設會計科目代碼
*/
public static function getDefaultAccountCode(string $incomeType): string
{
return match ($incomeType) {
self::TYPE_MEMBERSHIP_FEE => '4101',
self::TYPE_ENTRANCE_FEE => '4102',
self::TYPE_DONATION => '4201',
self::TYPE_ACTIVITY => '4402',
self::TYPE_GRANT => '4301',
self::TYPE_INTEREST => '4401',
self::TYPE_OTHER => '4901',
default => '4901',
};
}
/**
* 取得收入類型對應的預設會計科目 ID
*/
public static function getDefaultAccountId(string $incomeType): ?int
{
$accountCode = self::getDefaultAccountCode($incomeType);
return ChartOfAccount::where('account_code', $accountCode)->value('id');
}
/**
* 靜態方法:取得收入類型文字標籤
*/
public static function getIncomeTypeLabel(string $incomeType): string
{
return match ($incomeType) {
self::TYPE_MEMBERSHIP_FEE => '會費收入',
self::TYPE_ENTRANCE_FEE => '入會費收入',
self::TYPE_DONATION => '捐款收入',
self::TYPE_ACTIVITY => '活動收入',
self::TYPE_GRANT => '補助收入',
self::TYPE_INTEREST => '利息收入',
self::TYPE_OTHER => '其他收入',
default => '未知',
};
}
// ========== 查詢範圍 ==========
/**
* 篩選待確認的收入
*/
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
/**
* 篩選已確認的收入
*/
public function scopeConfirmed($query)
{
return $query->where('status', self::STATUS_CONFIRMED);
}
/**
* 篩選特定收入類型
*/
public function scopeOfType($query, string $type)
{
return $query->where('income_type', $type);
}
/**
* 篩選特定會員
*/
public function scopeForMember($query, int $memberId)
{
return $query->where('member_id', $memberId);
}
/**
* 篩選日期範圍
*/
public function scopeDateRange($query, $startDate, $endDate)
{
return $query->whereBetween('income_date', [$startDate, $endDate]);
}
}

View File

@@ -22,6 +22,11 @@ class Member extends Model
const TYPE_LIFETIME = 'lifetime';
const TYPE_STUDENT = 'student';
// Disability certificate status constants
const DISABILITY_STATUS_PENDING = 'pending';
const DISABILITY_STATUS_APPROVED = 'approved';
const DISABILITY_STATUS_REJECTED = 'rejected';
protected $fillable = [
'user_id',
'full_name',
@@ -39,11 +44,17 @@ class Member extends Model
'membership_expires_at',
'membership_status',
'membership_type',
'disability_certificate_path',
'disability_certificate_status',
'disability_verified_by',
'disability_verified_at',
'disability_rejection_reason',
];
protected $casts = [
'membership_started_at' => 'date',
'membership_expires_at' => 'date',
'disability_verified_at' => 'datetime',
];
protected $appends = ['national_id'];
@@ -58,6 +69,37 @@ class Member extends Model
return $this->hasMany(MembershipPayment::class);
}
/**
* 關聯的收入記錄
*/
public function incomes()
{
return $this->hasMany(Income::class);
}
/**
* 取得會員的會費收入記錄
*/
public function getMembershipFeeIncomes()
{
return $this->incomes()
->whereIn('income_type', [
Income::TYPE_MEMBERSHIP_FEE,
Income::TYPE_ENTRANCE_FEE
])
->get();
}
/**
* 取得會員的總收入金額
*/
public function getTotalIncomeAttribute(): float
{
return $this->incomes()
->where('status', Income::STATUS_CONFIRMED)
->sum('amount');
}
/**
* Get the decrypted national ID
*/
@@ -203,4 +245,120 @@ class Member extends Model
// Can submit if pending status and no pending payment
return $this->isPending() && !$this->getPendingPayment();
}
// ========== 身心障礙相關 ==========
/**
* 身心障礙手冊審核人
*/
public function disabilityVerifiedBy()
{
return $this->belongsTo(User::class, 'disability_verified_by');
}
/**
* 是否有上傳身心障礙手冊
*/
public function hasDisabilityCertificate(): bool
{
return !empty($this->disability_certificate_path);
}
/**
* 身心障礙手冊是否待審核
*/
public function isDisabilityPending(): bool
{
return $this->disability_certificate_status === self::DISABILITY_STATUS_PENDING;
}
/**
* 身心障礙手冊是否已通過審核
*/
public function hasApprovedDisability(): bool
{
return $this->disability_certificate_status === self::DISABILITY_STATUS_APPROVED;
}
/**
* 身心障礙手冊是否被駁回
*/
public function isDisabilityRejected(): bool
{
return $this->disability_certificate_status === self::DISABILITY_STATUS_REJECTED;
}
/**
* 取得身心障礙狀態標籤
*/
public function getDisabilityStatusLabelAttribute(): string
{
if (!$this->hasDisabilityCertificate()) {
return '未上傳';
}
return match ($this->disability_certificate_status) {
self::DISABILITY_STATUS_PENDING => '審核中',
self::DISABILITY_STATUS_APPROVED => '已通過',
self::DISABILITY_STATUS_REJECTED => '已駁回',
default => '未知',
};
}
/**
* 取得身心障礙狀態的 Badge 樣式
*/
public function getDisabilityStatusBadgeAttribute(): string
{
if (!$this->hasDisabilityCertificate()) {
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
}
return match ($this->disability_certificate_status) {
self::DISABILITY_STATUS_PENDING => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
self::DISABILITY_STATUS_APPROVED => 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
self::DISABILITY_STATUS_REJECTED => 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
default => 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
};
}
/**
* 審核通過身心障礙手冊
*/
public function approveDisabilityCertificate(User $verifier): void
{
$this->update([
'disability_certificate_status' => self::DISABILITY_STATUS_APPROVED,
'disability_verified_by' => $verifier->id,
'disability_verified_at' => now(),
'disability_rejection_reason' => null,
]);
}
/**
* 駁回身心障礙手冊
*/
public function rejectDisabilityCertificate(User $verifier, string $reason): void
{
$this->update([
'disability_certificate_status' => self::DISABILITY_STATUS_REJECTED,
'disability_verified_by' => $verifier->id,
'disability_verified_at' => now(),
'disability_rejection_reason' => $reason,
]);
}
/**
* 判斷下一次應繳哪種會費
*/
public function getNextFeeType(): string
{
// 新會員(從未啟用過)= 入會會費
if ($this->membership_started_at === null) {
return MembershipPayment::FEE_TYPE_ENTRANCE;
}
// 已有會籍 = 常年會費
return MembershipPayment::FEE_TYPE_ANNUAL;
}
}

View File

@@ -23,10 +23,19 @@ class MembershipPayment extends Model
const METHOD_CASH = 'cash';
const METHOD_CREDIT_CARD = 'credit_card';
// Fee type constants
const FEE_TYPE_ENTRANCE = 'entrance_fee'; // 入會會費
const FEE_TYPE_ANNUAL = 'annual_fee'; // 常年會費
protected $fillable = [
'member_id',
'fee_type',
'paid_at',
'amount',
'base_amount',
'discount_amount',
'final_amount',
'disability_discount',
'method',
'reference',
'status',
@@ -51,6 +60,10 @@ class MembershipPayment extends Model
'accountant_verified_at' => 'datetime',
'chair_verified_at' => 'datetime',
'rejected_at' => 'datetime',
'base_amount' => 'decimal:2',
'discount_amount' => 'decimal:2',
'final_amount' => 'decimal:2',
'disability_discount' => 'boolean',
];
// Relationships
@@ -151,6 +164,36 @@ class MembershipPayment extends Model
};
}
// Accessor for fee type label
public function getFeeTypeLabelAttribute(): string
{
return match($this->fee_type) {
self::FEE_TYPE_ENTRANCE => '入會會費',
self::FEE_TYPE_ANNUAL => '常年會費',
default => $this->fee_type ?? '未指定',
};
}
/**
* 是否有使用身心障礙優惠
*/
public function hasDisabilityDiscount(): bool
{
return (bool) $this->disability_discount;
}
/**
* 取得折扣說明
*/
public function getDiscountDescriptionAttribute(): ?string
{
if (!$this->hasDisabilityDiscount()) {
return null;
}
return '身心障礙優惠 50%';
}
// Clean up receipt file when payment is deleted
protected static function boot()
{

View File

@@ -67,7 +67,7 @@ class PaymentOrder extends Model
const PAYMENT_METHOD_CASH = 'cash';
/**
* 關聯到財務申請單
* 關聯到報銷申請單
*/
public function financeDocument(): BelongsTo
{

View File

@@ -23,7 +23,6 @@ class User extends Authenticatable
protected $fillable = [
'name',
'email',
'is_admin',
'profile_photo_path',
'password',
];
@@ -46,7 +45,6 @@ class User extends Authenticatable
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_admin' => 'boolean',
];
public function member(): HasOne
@@ -54,6 +52,15 @@ class User extends Authenticatable
return $this->hasOne(Member::class);
}
/**
* 檢查使用者是否為管理員
* 使用 Spatie Permission admin 角色取代舊版 is_admin 欄位
*/
public function isAdmin(): bool
{
return $this->hasRole('admin');
}
public function profilePhotoUrl(): ?string
{
if (! $this->profile_photo_path) {