510 lines
14 KiB
PHP
510 lines
14 KiB
PHP
<?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',
|
||
'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
|
||
'request_type',
|
||
'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',
|
||
];
|
||
|
||
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(?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;
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
{
|
||
$tier = $this->amount_tier ?? $this->determineAmountTier();
|
||
return $tier === self::AMOUNT_TIER_LARGE;
|
||
}
|
||
|
||
/**
|
||
* Check if approval stage is complete (ready for payment order creation)
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 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 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() &&
|
||
$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 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 => '小額(< 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';
|
||
}
|
||
}
|