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:
446
app/Models/Income.php
Normal file
446
app/Models/Income.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user