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

@@ -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
*/