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:
409
app/Http/Controllers/IncomeController.php
Normal file
409
app/Http/Controllers/IncomeController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user