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>
410 lines
13 KiB
PHP
410 lines
13 KiB
PHP
<?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);
|
|
}
|
|
}
|