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); } }