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,409 @@
<?php
namespace App\Http\Controllers;
use App\Models\ChartOfAccount;
use App\Models\Income;
use App\Models\Member;
use App\Support\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class IncomeController extends Controller
{
/**
* 收入列表
*/
public function index(Request $request)
{
$query = Income::query()
->with(['chartOfAccount', 'member', 'recordedByCashier', 'confirmedByAccountant'])
->orderByDesc('income_date')
->orderByDesc('id');
// 篩選狀態
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// 篩選收入類型
if ($request->filled('income_type')) {
$query->where('income_type', $request->income_type);
}
// 篩選付款方式
if ($request->filled('payment_method')) {
$query->where('payment_method', $request->payment_method);
}
// 篩選會員
if ($request->filled('member_id')) {
$query->where('member_id', $request->member_id);
}
// 篩選日期範圍
if ($request->filled('date_from')) {
$query->where('income_date', '>=', $request->date_from);
}
if ($request->filled('date_to')) {
$query->where('income_date', '<=', $request->date_to);
}
$incomes = $query->paginate(20);
// 統計資料
$statistics = [
'pending_count' => Income::pending()->count(),
'pending_amount' => Income::pending()->sum('amount'),
'confirmed_count' => Income::confirmed()->count(),
'confirmed_amount' => Income::confirmed()->sum('amount'),
];
return view('admin.incomes.index', [
'incomes' => $incomes,
'statistics' => $statistics,
]);
}
/**
* 新增收入表單
*/
public function create(Request $request)
{
// 取得收入類會計科目
$chartOfAccounts = ChartOfAccount::where('account_type', 'income')
->where('is_active', true)
->orderBy('account_code')
->get();
// 取得會員列表(可選關聯)
$members = Member::orderBy('full_name')->get();
// 預選會員
$selectedMember = null;
if ($request->filled('member_id')) {
$selectedMember = Member::find($request->member_id);
}
return view('admin.incomes.create', [
'chartOfAccounts' => $chartOfAccounts,
'members' => $members,
'selectedMember' => $selectedMember,
]);
}
/**
* 儲存收入(出納記錄)
*/
public function store(Request $request)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'income_date' => ['required', 'date'],
'amount' => ['required', 'numeric', 'min:0.01'],
'income_type' => ['required', 'in:membership_fee,entrance_fee,donation,activity,grant,interest,other'],
'chart_of_account_id' => ['required', 'exists:chart_of_accounts,id'],
'payment_method' => ['required', 'in:cash,bank_transfer,check'],
'bank_account' => ['nullable', 'string', 'max:255'],
'payer_name' => ['nullable', 'string', 'max:255'],
'member_id' => ['nullable', 'exists:members,id'],
'receipt_number' => ['nullable', 'string', 'max:255'],
'transaction_reference' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'notes' => ['nullable', 'string'],
'attachment' => ['nullable', 'file', 'max:10240'],
]);
// 處理附件上傳
$attachmentPath = null;
if ($request->hasFile('attachment')) {
$attachmentPath = $request->file('attachment')->store('incomes', 'local');
}
$income = Income::create([
'title' => $validated['title'],
'income_date' => $validated['income_date'],
'amount' => $validated['amount'],
'income_type' => $validated['income_type'],
'chart_of_account_id' => $validated['chart_of_account_id'],
'payment_method' => $validated['payment_method'],
'bank_account' => $validated['bank_account'] ?? null,
'payer_name' => $validated['payer_name'] ?? null,
'member_id' => $validated['member_id'] ?? null,
'receipt_number' => $validated['receipt_number'] ?? null,
'transaction_reference' => $validated['transaction_reference'] ?? null,
'description' => $validated['description'] ?? null,
'notes' => $validated['notes'] ?? null,
'attachment_path' => $attachmentPath,
'status' => Income::STATUS_PENDING,
'recorded_by_cashier_id' => $request->user()->id,
'recorded_at' => now(),
]);
AuditLogger::log('income.created', $income, $validated);
return redirect()
->route('admin.incomes.show', $income)
->with('status', '收入記錄已建立,等待會計確認。收入編號:' . $income->income_number);
}
/**
* 收入詳情
*/
public function show(Income $income)
{
$income->load([
'chartOfAccount',
'member',
'recordedByCashier',
'confirmedByAccountant',
'cashierLedgerEntry',
'accountingEntries.chartOfAccount',
]);
return view('admin.incomes.show', [
'income' => $income,
]);
}
/**
* 會計確認收入
*/
public function confirm(Request $request, Income $income)
{
$user = $request->user();
// 檢查權限
$canConfirm = $user->hasRole('admin') ||
$user->hasRole('finance_accountant');
if (!$canConfirm) {
abort(403, '您無權確認此收入。');
}
if (!$income->canBeConfirmed()) {
return redirect()
->route('admin.incomes.show', $income)
->with('error', '此收入無法確認。');
}
try {
$income->confirmByAccountant($user);
AuditLogger::log('income.confirmed', $income, [
'confirmed_by' => $user->name,
]);
return redirect()
->route('admin.incomes.show', $income)
->with('status', '收入已確認。已自動產生出納日記帳和會計分錄。');
} catch (\Exception $e) {
return redirect()
->route('admin.incomes.show', $income)
->with('error', '確認失敗:' . $e->getMessage());
}
}
/**
* 取消收入
*/
public function cancel(Request $request, Income $income)
{
$user = $request->user();
// 檢查權限
$canCancel = $user->hasRole('admin') ||
$user->hasRole('finance_accountant');
if (!$canCancel) {
abort(403, '您無權取消此收入。');
}
if (!$income->canBeCancelled()) {
return redirect()
->route('admin.incomes.show', $income)
->with('error', '此收入無法取消。');
}
$validated = $request->validate([
'cancel_reason' => ['nullable', 'string', 'max:1000'],
]);
$income->cancel();
AuditLogger::log('income.cancelled', $income, [
'cancelled_by' => $user->name,
'reason' => $validated['cancel_reason'] ?? null,
]);
return redirect()
->route('admin.incomes.show', $income)
->with('status', '收入已取消。');
}
/**
* 收入統計
*/
public function statistics(Request $request)
{
$year = $request->input('year', date('Y'));
$month = $request->input('month');
// 依收入類型統計
$byTypeQuery = Income::confirmed()
->whereYear('income_date', $year);
if ($month) {
$byTypeQuery->whereMonth('income_date', $month);
}
$byType = $byTypeQuery
->selectRaw('income_type, SUM(amount) as total_amount, COUNT(*) as count')
->groupBy('income_type')
->get();
// 依月份統計
$byMonth = Income::confirmed()
->whereYear('income_date', $year)
->selectRaw("CAST(strftime('%m', income_date) AS INTEGER) as month, SUM(amount) as total_amount, COUNT(*) as count")
->groupBy('month')
->orderBy('month')
->get();
// 依會計科目統計
$byAccountQuery = Income::confirmed()
->whereYear('income_date', $year);
if ($month) {
$byAccountQuery->whereMonth('income_date', $month);
}
$byAccountResults = $byAccountQuery
->selectRaw('chart_of_account_id, SUM(amount) as total_amount, COUNT(*) as count')
->groupBy('chart_of_account_id')
->get();
// 手動載入會計科目關聯
$accountIds = $byAccountResults->pluck('chart_of_account_id')->filter()->unique();
$accounts = \App\Models\ChartOfAccount::whereIn('id', $accountIds)->get()->keyBy('id');
$byAccount = $byAccountResults->map(function ($item) use ($accounts) {
$item->chartOfAccount = $accounts->get($item->chart_of_account_id);
return $item;
});
// 總計
$totalQuery = Income::confirmed()
->whereYear('income_date', $year);
if ($month) {
$totalQuery->whereMonth('income_date', $month);
}
$total = [
'amount' => $totalQuery->sum('amount'),
'count' => $totalQuery->count(),
];
return view('admin.incomes.statistics', [
'year' => $year,
'month' => $month,
'byType' => $byType,
'byMonth' => $byMonth,
'byAccount' => $byAccount,
'total' => $total,
]);
}
/**
* 匯出收入
*/
public function export(Request $request)
{
$query = Income::confirmed()
->with(['chartOfAccount', 'member', 'recordedByCashier'])
->orderByDesc('income_date');
// 篩選日期範圍
if ($request->filled('date_from')) {
$query->where('income_date', '>=', $request->date_from);
}
if ($request->filled('date_to')) {
$query->where('income_date', '<=', $request->date_to);
}
$incomes = $query->get();
// 產生 CSV
$filename = 'incomes_' . date('Y-m-d_His') . '.csv';
$headers = [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
];
$callback = function () use ($incomes) {
$file = fopen('php://output', 'w');
// BOM for Excel UTF-8
fprintf($file, chr(0xEF) . chr(0xBB) . chr(0xBF));
// Header
fputcsv($file, [
'收入編號',
'日期',
'標題',
'金額',
'收入類型',
'會計科目',
'付款方式',
'付款人',
'會員',
'收據編號',
'狀態',
'記錄人',
'確認人',
]);
foreach ($incomes as $income) {
fputcsv($file, [
$income->income_number,
$income->income_date->format('Y-m-d'),
$income->title,
$income->amount,
$income->getIncomeTypeText(),
$income->chartOfAccount->account_name_zh ?? '',
$income->getPaymentMethodText(),
$income->payer_name,
$income->member->full_name ?? '',
$income->receipt_number,
$income->getStatusText(),
$income->recordedByCashier->name ?? '',
$income->confirmedByAccountant->name ?? '',
]);
}
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
/**
* 下載附件
*/
public function download(Income $income)
{
if (!$income->attachment_path) {
abort(404, '找不到附件。');
}
$path = storage_path('app/' . $income->attachment_path);
if (!file_exists($path)) {
abort(404, '附件檔案不存在。');
}
return response()->download($path);
}
}