Files
usher-manage-stack/app/Models/FinanceDocument.php
Gbanyan 642b879dd4 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>
2025-12-01 09:56:01 +08:00

852 lines
24 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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_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';
// 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
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',
'submitted_by_id',
'title',
'amount',
'status',
'description',
'attachment_path',
'submitted_at',
'approved_by_cashier_id',
'cashier_approved_by_id',
'cashier_approved_at',
'approved_by_accountant_id',
'accountant_approved_by_id',
'accountant_approved_at',
'approved_by_chair_id',
'chair_approved_by_id',
'chair_approved_at',
'rejected_by_user_id',
'rejected_at',
'rejection_reason',
// New payment stage fields
'amount_tier',
'chart_of_account_id',
'budget_item_id',
'requires_board_meeting',
'approved_by_board_meeting_id',
'board_meeting_approved_by_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',
'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 = [
'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',
// 新工作流程欄位
'secretary_approved_at' => 'datetime',
'requester_confirmed_at' => 'datetime',
'cashier_confirmed_at' => 'datetime',
'accountant_recorded_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');
}
/**
* 新工作流程 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
*/
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);
}
/**
* Get all accounting entries for this document
*/
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;
}
if ($user && $this->submitted_by_user_id && $this->submitted_by_user_id === $user->id) {
return false;
}
return true;
}
/**
* 新工作流程:理事長可審核
* 條件:秘書長已核准 + 中額或大額
*/
public function canBeApprovedByChair(?User $user = null): bool
{
$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;
}
/**
* 新工作流程:董理事會可審核
* 條件:理事長已核准 + 大額
*/
public function canBeApprovedByBoard(?User $user = null): bool
{
$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;
}
/**
* 新工作流程:審核是否完成
* 依金額級別判斷
*/
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->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;
}
/**
* 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 => '待審核',
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
*/
/**
* 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
{
$tier = $this->amount_tier ?? $this->determineAmountTier();
return $tier === self::AMOUNT_TIER_LARGE;
}
/**
* Check if approval stage is complete (ready for disbursement)
* 新工作流程:使用 isApprovalComplete()
*/
public function isApprovalStageComplete(): bool
{
return $this->isApprovalComplete();
}
/**
* 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;
}
/**
* Check if document is fully processed (all stages complete)
*/
public function isFullyProcessed(): bool
{
return $this->isApprovalComplete() &&
$this->isDisbursementComplete() &&
$this->isRecordingComplete();
}
/**
* Check if reconciliation is complete
*/
public function isReconciled(): bool
{
return $this->bank_reconciliation_id !== null;
}
// ============== Attribute aliases for backward compatibility ==============
public function getSubmittedByIdAttribute(): ?int
{
return $this->attributes['submitted_by_id'] ?? $this->attributes['submitted_by_user_id'] ?? null;
}
public function setSubmittedByIdAttribute($value): void
{
$this->attributes['submitted_by_user_id'] = $value;
$this->attributes['submitted_by_id'] = $value;
}
public function setSubmittedByUserIdAttribute($value): void
{
$this->attributes['submitted_by_user_id'] = $value;
$this->attributes['submitted_by_id'] = $value;
}
public function getCashierApprovedByIdAttribute(): ?int
{
return $this->approved_by_cashier_id ?? $this->attributes['approved_by_cashier_id'] ?? null;
}
public function setCashierApprovedByIdAttribute($value): void
{
$this->attributes['approved_by_cashier_id'] = $value;
}
public function getAccountantApprovedByIdAttribute(): ?int
{
return $this->approved_by_accountant_id ?? $this->attributes['approved_by_accountant_id'] ?? null;
}
public function setAccountantApprovedByIdAttribute($value): void
{
$this->attributes['approved_by_accountant_id'] = $value;
}
public function getChairApprovedByIdAttribute(): ?int
{
return $this->approved_by_chair_id ?? $this->attributes['approved_by_chair_id'] ?? null;
}
public function setChairApprovedByIdAttribute($value): void
{
$this->attributes['approved_by_chair_id'] = $value;
}
public function getBoardMeetingApprovedByIdAttribute(): ?int
{
return $this->approved_by_board_meeting_id ?? $this->attributes['approved_by_board_meeting_id'] ?? null;
}
public function setBoardMeetingApprovedByIdAttribute($value): void
{
$this->attributes['approved_by_board_meeting_id'] = $value;
}
/**
* Get amount tier text
*/
public function getAmountTierText(): string
{
return match ($this->amount_tier) {
self::AMOUNT_TIER_SMALL => '小額(< 5000',
self::AMOUNT_TIER_MEDIUM => '中額5000-50000',
self::AMOUNT_TIER_LARGE => '大額(> 50000',
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->payment_order_created_at === null) {
return 'approval';
}
if ($this->cashier_recorded_at === null) {
return 'payment';
}
if ($this->bank_reconciliation_id !== null) {
return 'completed';
}
if (! $this->exists) {
return 'recording';
}
return 'reconciliation';
}
}