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,346 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Announcement;
use App\Models\AuditLog;
use Illuminate\Http\Request;
class AnnouncementController extends Controller
{
public function __construct()
{
$this->middleware('can:view_announcements')->only(['index', 'show']);
$this->middleware('can:create_announcements')->only(['create', 'store']);
$this->middleware('can:edit_announcements')->only(['edit', 'update']);
$this->middleware('can:delete_announcements')->only(['destroy']);
$this->middleware('can:publish_announcements')->only(['publish', 'archive']);
}
/**
* Display a listing of announcements
*/
public function index(Request $request)
{
$query = Announcement::with(['creator', 'lastUpdatedBy'])
->orderByDesc('is_pinned')
->orderByDesc('created_at');
// Filter by status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Filter by access level
if ($request->filled('access_level')) {
$query->where('access_level', $request->access_level);
}
// Filter by pinned
if ($request->filled('pinned')) {
$query->where('is_pinned', $request->pinned === 'yes');
}
// Search
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('content', 'like', "%{$search}%");
});
}
$announcements = $query->paginate(20);
// Statistics
$stats = [
'total' => Announcement::count(),
'draft' => Announcement::draft()->count(),
'published' => Announcement::published()->count(),
'archived' => Announcement::archived()->count(),
'pinned' => Announcement::pinned()->count(),
];
return view('admin.announcements.index', compact('announcements', 'stats'));
}
/**
* Show the form for creating a new announcement
*/
public function create()
{
return view('admin.announcements.create');
}
/**
* Store a newly created announcement
*/
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'access_level' => 'required|in:public,members,board,admin',
'published_at' => 'nullable|date',
'expires_at' => 'nullable|date|after:published_at',
'is_pinned' => 'boolean',
'display_order' => 'nullable|integer',
'save_action' => 'required|in:draft,publish',
]);
$announcement = Announcement::create([
'title' => $validated['title'],
'content' => $validated['content'],
'access_level' => $validated['access_level'],
'status' => $validated['save_action'] === 'publish' ? Announcement::STATUS_PUBLISHED : Announcement::STATUS_DRAFT,
'published_at' => $validated['save_action'] === 'publish' ? ($validated['published_at'] ?? now()) : null,
'expires_at' => $validated['expires_at'] ?? null,
'is_pinned' => $validated['is_pinned'] ?? false,
'display_order' => $validated['display_order'] ?? 0,
'created_by_user_id' => auth()->id(),
'last_updated_by_user_id' => auth()->id(),
]);
// Audit log
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'announcement.created',
'description' => "建立公告:{$announcement->title} (狀態:{$announcement->getStatusLabel()})",
'ip_address' => request()->ip(),
]);
$message = $validated['save_action'] === 'publish' ? '公告已成功發布' : '公告已儲存為草稿';
return redirect()
->route('admin.announcements.show', $announcement)
->with('status', $message);
}
/**
* Display the specified announcement
*/
public function show(Announcement $announcement)
{
// Check if user can view this announcement
if (!$announcement->canBeViewedBy(auth()->user())) {
abort(403, '您沒有權限查看此公告');
}
$announcement->load(['creator', 'lastUpdatedBy']);
// Increment view count if viewing published announcement
if ($announcement->isPublished()) {
$announcement->incrementViewCount();
}
return view('admin.announcements.show', compact('announcement'));
}
/**
* Show the form for editing the specified announcement
*/
public function edit(Announcement $announcement)
{
// Check if user can edit this announcement
if (!$announcement->canBeEditedBy(auth()->user())) {
abort(403, '您沒有權限編輯此公告');
}
return view('admin.announcements.edit', compact('announcement'));
}
/**
* Update the specified announcement
*/
public function update(Request $request, Announcement $announcement)
{
// Check if user can edit this announcement
if (!$announcement->canBeEditedBy(auth()->user())) {
abort(403, '您沒有權限編輯此公告');
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'access_level' => 'required|in:public,members,board,admin',
'published_at' => 'nullable|date',
'expires_at' => 'nullable|date|after:published_at',
'is_pinned' => 'boolean',
'display_order' => 'nullable|integer',
]);
$announcement->update([
'title' => $validated['title'],
'content' => $validated['content'],
'access_level' => $validated['access_level'],
'published_at' => $validated['published_at'],
'expires_at' => $validated['expires_at'] ?? null,
'is_pinned' => $validated['is_pinned'] ?? false,
'display_order' => $validated['display_order'] ?? 0,
'last_updated_by_user_id' => auth()->id(),
]);
// Audit log
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'announcement.updated',
'description' => "更新公告:{$announcement->title}",
'ip_address' => request()->ip(),
]);
return redirect()
->route('admin.announcements.show', $announcement)
->with('status', '公告已成功更新');
}
/**
* Remove the specified announcement (soft delete)
*/
public function destroy(Announcement $announcement)
{
// Check if user can delete this announcement
if (!$announcement->canBeEditedBy(auth()->user())) {
abort(403, '您沒有權限刪除此公告');
}
$title = $announcement->title;
$announcement->delete();
// Audit log
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'announcement.deleted',
'description' => "刪除公告:{$title}",
'ip_address' => request()->ip(),
]);
return redirect()
->route('admin.announcements.index')
->with('status', '公告已成功刪除');
}
/**
* Publish a draft announcement
*/
public function publish(Announcement $announcement)
{
// Check permission
if (!auth()->user()->can('publish_announcements')) {
abort(403, '您沒有權限發布公告');
}
// Check if user can edit this announcement
if (!$announcement->canBeEditedBy(auth()->user())) {
abort(403, '您沒有權限發布此公告');
}
if ($announcement->isPublished()) {
return back()->with('error', '此公告已經發布');
}
$announcement->publish(auth()->user());
// Audit log
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'announcement.published',
'description' => "發布公告:{$announcement->title}",
'ip_address' => request()->ip(),
]);
return back()->with('status', '公告已成功發布');
}
/**
* Archive an announcement
*/
public function archive(Announcement $announcement)
{
// Check permission
if (!auth()->user()->can('publish_announcements')) {
abort(403, '您沒有權限歸檔公告');
}
// Check if user can edit this announcement
if (!$announcement->canBeEditedBy(auth()->user())) {
abort(403, '您沒有權限歸檔此公告');
}
if ($announcement->isArchived()) {
return back()->with('error', '此公告已經歸檔');
}
$announcement->archive(auth()->user());
// Audit log
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'announcement.archived',
'description' => "歸檔公告:{$announcement->title}",
'ip_address' => request()->ip(),
]);
return back()->with('status', '公告已成功歸檔');
}
/**
* Pin an announcement
*/
public function pin(Request $request, Announcement $announcement)
{
// Check permission
if (!auth()->user()->can('edit_announcements')) {
abort(403, '您沒有權限置頂公告');
}
// Check if user can edit this announcement
if (!$announcement->canBeEditedBy(auth()->user())) {
abort(403, '您沒有權限置頂此公告');
}
$validated = $request->validate([
'display_order' => 'nullable|integer',
]);
$announcement->pin($validated['display_order'] ?? 0, auth()->user());
// Audit log
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'announcement.pinned',
'description' => "置頂公告:{$announcement->title}",
'ip_address' => request()->ip(),
]);
return back()->with('status', '公告已成功置頂');
}
/**
* Unpin an announcement
*/
public function unpin(Announcement $announcement)
{
// Check permission
if (!auth()->user()->can('edit_announcements')) {
abort(403, '您沒有權限取消置頂公告');
}
// Check if user can edit this announcement
if (!$announcement->canBeEditedBy(auth()->user())) {
abort(403, '您沒有權限取消置頂此公告');
}
$announcement->unpin(auth()->user());
// Audit log
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'announcement.unpinned',
'description' => "取消置頂公告:{$announcement->title}",
'ip_address' => request()->ip(),
]);
return back()->with('status', '公告已取消置頂');
}
}

View File

@@ -83,8 +83,8 @@ class DocumentController extends Controller
$document = Document::create([
'document_category_id' => $validated['document_category_id'],
'title' => $validated['title'],
'document_number' => $validated['document_number'],
'description' => $validated['description'],
'document_number' => $validated['document_number'] ?? null,
'description' => $validated['description'] ?? null,
'access_level' => $validated['access_level'],
'status' => 'active',
'created_by_user_id' => auth()->id(),
@@ -360,7 +360,7 @@ class DocumentController extends Controller
->get();
// Monthly upload trends (last 6 months)
$uploadTrends = Document::selectRaw('DATE_FORMAT(created_at, "%Y-%m") as month, COUNT(*) as count')
$uploadTrends = Document::selectRaw("strftime('%Y-%m', created_at) as month, COUNT(*) as count")
->where('created_at', '>=', now()->subMonths(6))
->groupBy('month')
->orderBy('month', 'desc')

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AccountingEntry;
use App\Models\ChartOfAccount;
use Illuminate\Http\Request;
class GeneralLedgerController extends Controller
{
/**
* Display the general ledger
*/
public function index(Request $request)
{
$accounts = ChartOfAccount::where('is_active', true)
->orderBy('account_code')
->get();
$selectedAccountId = $request->input('account_id');
$startDate = $request->input('start_date', now()->startOfYear()->format('Y-m-d'));
$endDate = $request->input('end_date', now()->format('Y-m-d'));
$entries = null;
$selectedAccount = null;
$openingBalance = 0;
$debitTotal = 0;
$creditTotal = 0;
$closingBalance = 0;
if ($selectedAccountId) {
$selectedAccount = ChartOfAccount::findOrFail($selectedAccountId);
// Get opening balance (all entries before start date)
$openingDebit = AccountingEntry::where('chart_of_account_id', $selectedAccountId)
->where('entry_date', '<', $startDate)
->where('entry_type', AccountingEntry::ENTRY_TYPE_DEBIT)
->sum('amount');
$openingCredit = AccountingEntry::where('chart_of_account_id', $selectedAccountId)
->where('entry_date', '<', $startDate)
->where('entry_type', AccountingEntry::ENTRY_TYPE_CREDIT)
->sum('amount');
// Calculate opening balance based on account type
if (in_array($selectedAccount->account_type, ['asset', 'expense'])) {
// Assets and Expenses: Debit increases, Credit decreases
$openingBalance = $openingDebit - $openingCredit;
} else {
// Liabilities, Equity, Income: Credit increases, Debit decreases
$openingBalance = $openingCredit - $openingDebit;
}
// Get entries for the period
$entries = AccountingEntry::with(['financeDocument', 'chartOfAccount'])
->where('chart_of_account_id', $selectedAccountId)
->whereBetween('entry_date', [$startDate, $endDate])
->orderBy('entry_date')
->orderBy('id')
->get();
// Calculate totals for the period
$debitTotal = $entries->where('entry_type', AccountingEntry::ENTRY_TYPE_DEBIT)->sum('amount');
$creditTotal = $entries->where('entry_type', AccountingEntry::ENTRY_TYPE_CREDIT)->sum('amount');
// Calculate closing balance
if (in_array($selectedAccount->account_type, ['asset', 'expense'])) {
$closingBalance = $openingBalance + $debitTotal - $creditTotal;
} else {
$closingBalance = $openingBalance + $creditTotal - $debitTotal;
}
}
return view('admin.general-ledger.index', compact(
'accounts',
'selectedAccount',
'entries',
'startDate',
'endDate',
'openingBalance',
'debitTotal',
'creditTotal',
'closingBalance'
));
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\SystemSetting;
use App\Services\MembershipFeeCalculator;
use App\Services\SettingsService;
use Illuminate\Http\Request;
@@ -270,4 +271,45 @@ class SystemSettingsController extends Controller
return redirect()->route('admin.settings.advanced')->with('status', '進階設定已更新');
}
/**
* Show membership fee settings page
*/
public function membership()
{
$feeCalculator = app(MembershipFeeCalculator::class);
$settings = [
'entrance_fee' => $feeCalculator->getEntranceFee(),
'annual_fee' => $feeCalculator->getAnnualFee(),
'disability_discount_rate' => $feeCalculator->getDisabilityDiscountRate() * 100, // Convert to percentage
];
return view('admin.settings.membership', compact('settings'));
}
/**
* Update membership fee settings
*/
public function updateMembership(Request $request)
{
$validated = $request->validate([
'entrance_fee' => 'required|numeric|min:0|max:100000',
'annual_fee' => 'required|numeric|min:0|max:100000',
'disability_discount_rate' => 'required|numeric|min:0|max:100',
]);
SystemSetting::set('membership_fee.entrance_fee', $validated['entrance_fee'], 'float', 'membership');
SystemSetting::set('membership_fee.annual_fee', $validated['annual_fee'], 'float', 'membership');
SystemSetting::set('membership_fee.disability_discount_rate', $validated['disability_discount_rate'] / 100, 'float', 'membership');
AuditLog::create([
'user_id' => auth()->id(),
'action' => 'settings.membership.updated',
'description' => '更新會費設定',
'ip_address' => $request->ip(),
]);
return redirect()->route('admin.settings.membership')->with('status', '會費設定已更新');
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AccountingEntry;
use App\Models\ChartOfAccount;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class TrialBalanceController extends Controller
{
/**
* Display the trial balance
*/
public function index(Request $request)
{
$startDate = $request->input('start_date', now()->startOfYear()->format('Y-m-d'));
$endDate = $request->input('end_date', now()->format('Y-m-d'));
// Get all active accounts with their balances
$accounts = ChartOfAccount::where('is_active', true)
->orderBy('account_code')
->get()
->map(function ($account) use ($startDate, $endDate) {
// Get debit and credit totals for this account
$debitTotal = AccountingEntry::where('chart_of_account_id', $account->id)
->whereBetween('entry_date', [$startDate, $endDate])
->where('entry_type', AccountingEntry::ENTRY_TYPE_DEBIT)
->sum('amount');
$creditTotal = AccountingEntry::where('chart_of_account_id', $account->id)
->whereBetween('entry_date', [$startDate, $endDate])
->where('entry_type', AccountingEntry::ENTRY_TYPE_CREDIT)
->sum('amount');
// Only include accounts with activity
if ($debitTotal == 0 && $creditTotal == 0) {
return null;
}
return [
'account' => $account,
'debit_total' => $debitTotal,
'credit_total' => $creditTotal,
];
})
->filter() // Remove null entries
->values();
// Calculate grand totals
$grandDebitTotal = $accounts->sum('debit_total');
$grandCreditTotal = $accounts->sum('credit_total');
// Check if balanced
$isBalanced = bccomp((string)$grandDebitTotal, (string)$grandCreditTotal, 2) === 0;
$difference = $grandDebitTotal - $grandCreditTotal;
// Group accounts by type
$accountsByType = $accounts->groupBy(function ($item) {
return $item['account']->account_type;
});
return view('admin.trial-balance.index', compact(
'accounts',
'accountsByType',
'startDate',
'endDate',
'grandDebitTotal',
'grandCreditTotal',
'isBalanced',
'difference'
));
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Member;
use App\Models\MembershipPayment;
use App\Models\FinanceDocument;
use App\Models\Announcement;
use Illuminate\Http\Request;
class AdminDashboardController extends Controller
@@ -47,16 +48,26 @@ class AdminDashboardController extends Controller
// Documents pending user's approval
$user = auth()->user();
$myPendingApprovals = 0;
if ($user->hasRole('cashier')) {
if ($user->hasRole('finance_cashier')) {
$myPendingApprovals += FinanceDocument::where('status', FinanceDocument::STATUS_PENDING)->count();
}
if ($user->hasRole('accountant')) {
if ($user->hasRole('finance_accountant')) {
$myPendingApprovals += FinanceDocument::where('status', FinanceDocument::STATUS_APPROVED_CASHIER)->count();
}
if ($user->hasRole('chair')) {
if ($user->hasRole('finance_chair')) {
$myPendingApprovals += FinanceDocument::where('status', FinanceDocument::STATUS_APPROVED_ACCOUNTANT)->count();
}
// Recent announcements
$recentAnnouncements = Announcement::query()
->published()
->active()
->forAccessLevel($user)
->orderByDesc('is_pinned')
->orderByDesc('published_at')
->limit(5)
->get();
return view('admin.dashboard.index', compact(
'totalMembers',
'activeMembers',
@@ -70,7 +81,8 @@ class AdminDashboardController extends Controller
'pendingApprovals',
'fullyApprovedDocs',
'rejectedDocs',
'myPendingApprovals'
'myPendingApprovals',
'recentAnnouncements'
));
}
}

View File

@@ -217,7 +217,7 @@ class AdminMemberController extends Controller
public function showActivate(Member $member)
{
// Check if user has permission
if (!auth()->user()->can('activate_memberships') && !auth()->user()->is_admin) {
if (!auth()->user()->can('activate_memberships') && !auth()->user()->hasRole('admin')) {
abort(403, 'You do not have permission to activate memberships.');
}
@@ -227,7 +227,7 @@ class AdminMemberController extends Controller
->latest()
->first();
if (!$approvedPayment && !auth()->user()->is_admin) {
if (!$approvedPayment && !auth()->user()->hasRole('admin')) {
return redirect()->route('admin.members.show', $member)
->with('error', __('Member must have an approved payment before activation.'));
}
@@ -241,7 +241,7 @@ class AdminMemberController extends Controller
public function activate(Request $request, Member $member)
{
// Check if user has permission
if (!auth()->user()->can('activate_memberships') && !auth()->user()->is_admin) {
if (!auth()->user()->can('activate_memberships') && !auth()->user()->hasRole('admin')) {
abort(403, 'You do not have permission to activate memberships.');
}
@@ -344,4 +344,53 @@ class AdminMemberController extends Controller
return $response;
}
public function batchDestroy(Request $request)
{
$validated = $request->validate([
'ids' => ['required', 'array'],
'ids.*' => ['exists:members,id'],
]);
$count = Member::whereIn('id', $validated['ids'])->delete();
AuditLogger::log('members.batch_deleted', null, ['ids' => $validated['ids'], 'count' => $count]);
return back()->with('status', __(':count members deleted successfully.', ['count' => $count]));
}
public function batchUpdateStatus(Request $request)
{
$validated = $request->validate([
'ids' => ['required', 'array'],
'ids.*' => ['exists:members,id'],
'status' => ['required', 'in:pending,active,expired,suspended'],
]);
$count = Member::whereIn('id', $validated['ids'])->update(['membership_status' => $validated['status']]);
AuditLogger::log('members.batch_status_updated', null, [
'ids' => $validated['ids'],
'status' => $validated['status'],
'count' => $count
]);
return back()->with('status', __(':count members updated successfully.', ['count' => $count]));
}
/**
* View member's disability certificate
*/
public function viewDisabilityCertificate(Member $member)
{
if (!$member->disability_certificate_path) {
abort(404, '找不到身心障礙手冊');
}
if (!\Illuminate\Support\Facades\Storage::disk('private')->exists($member->disability_certificate_path)) {
abort(404, '檔案不存在');
}
return \Illuminate\Support\Facades\Storage::disk('private')->response($member->disability_certificate_path);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Member;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Registered;
@@ -42,6 +43,15 @@ class RegisteredUserController extends Controller
'password' => Hash::make($request->password),
]);
// Auto-create member record
Member::create([
'user_id' => $user->id,
'full_name' => $user->name,
'email' => $user->email,
'membership_status' => Member::STATUS_PENDING,
'membership_type' => Member::TYPE_REGULAR,
]);
event(new Registered($user));
Auth::login($user);

View File

@@ -201,7 +201,7 @@ class BudgetController extends Controller
// Check if user has permission (admin or chair)
$user = $request->user();
if (!$user->hasRole('chair') && !$user->is_admin && !$user->hasRole('admin')) {
if (!$user->hasRole('finance_chair') && !$user->hasRole('admin')) {
abort(403, 'Only chair can approve budgets.');
}

View File

@@ -26,11 +26,6 @@ class FinanceDocumentController extends Controller
$query->where('status', $request->status);
}
// Filter by request type
if ($request->filled('request_type')) {
$query->where('request_type', $request->request_type);
}
// Filter by amount tier
if ($request->filled('amount_tier')) {
$query->where('amount_tier', $request->amount_tier);
@@ -79,7 +74,6 @@ class FinanceDocumentController extends Controller
'member_id' => ['nullable', 'exists:members,id'],
'title' => ['required', 'string', 'max:255'],
'amount' => ['required', 'numeric', 'min:0'],
'request_type' => ['required', 'in:expense_reimbursement,advance_payment,purchase_request,petty_cash'],
'description' => ['nullable', 'string'],
'attachment' => ['nullable', 'file', 'max:10240'], // 10MB max
]);
@@ -95,7 +89,6 @@ class FinanceDocumentController extends Controller
'submitted_by_user_id' => $request->user()->id,
'title' => $validated['title'],
'amount' => $validated['amount'],
'request_type' => $validated['request_type'],
'description' => $validated['description'] ?? null,
'attachment_path' => $attachmentPath,
'status' => FinanceDocument::STATUS_PENDING,
@@ -115,17 +108,13 @@ class FinanceDocumentController extends Controller
// Send email notification to finance cashiers
$cashiers = User::role('finance_cashier')->get();
if ($cashiers->isEmpty()) {
// Fallback to old cashier role for backward compatibility
$cashiers = User::role('cashier')->get();
}
foreach ($cashiers as $cashier) {
Mail::to($cashier->email)->queue(new FinanceDocumentSubmitted($document));
}
return redirect()
->route('admin.finance.index')
->with('status', '財務申請單已提交。申請類型:' . $document->getRequestTypeText() . '金額級別:' . $document->getAmountTierText());
->with('status', '報銷申請單已提交。金額級別:' . $document->getAmountTierText());
}
public function show(FinanceDocument $financeDocument)
@@ -133,13 +122,19 @@ class FinanceDocumentController extends Controller
$financeDocument->load([
'member',
'submittedBy',
// 新工作流程 relationships
'approvedBySecretary',
'approvedByChair',
'approvedByBoardMeeting',
'requesterConfirmedBy',
'cashierConfirmedBy',
'accountantRecordedBy',
// Legacy relationships
'approvedByCashier',
'approvedByAccountant',
'approvedByChair',
'rejectedBy',
'chartOfAccount',
'budgetItem',
'approvedByBoardMeeting',
'paymentOrderCreatedByAccountant',
'paymentVerifiedByCashier',
'paymentExecutedByCashier',
@@ -159,72 +154,48 @@ class FinanceDocumentController extends Controller
{
$user = $request->user();
// Check if user has any finance approval permissions
$isCashier = $user->hasRole('finance_cashier') || $user->hasRole('cashier');
$isAccountant = $user->hasRole('finance_accountant') || $user->hasRole('accountant');
$isChair = $user->hasRole('finance_chair') || $user->hasRole('chair');
// 新工作流程:秘書長 → 理事長 → 董理事會
$isSecretary = $user->hasRole('secretary_general');
$isChair = $user->hasRole('finance_chair');
$isBoardMember = $user->hasRole('finance_board_member');
$isAdmin = $user->hasRole('admin');
// Determine which level of approval based on current status and user role
if ($financeDocument->canBeApprovedByCashier() && $isCashier) {
// 秘書長審核(第一階段)
if ($financeDocument->canBeApprovedBySecretary($user) && ($isSecretary || $isAdmin)) {
$financeDocument->update([
'approved_by_cashier_id' => $user->id,
'cashier_approved_at' => now(),
'status' => FinanceDocument::STATUS_APPROVED_CASHIER,
'approved_by_secretary_id' => $user->id,
'secretary_approved_at' => now(),
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
]);
AuditLogger::log('finance_document.approved_by_cashier', $financeDocument, [
AuditLogger::log('finance_document.approved_by_secretary', $financeDocument, [
'approved_by' => $user->name,
'amount_tier' => $financeDocument->amount_tier,
]);
// Send email notification to accountants
$accountants = User::role('finance_accountant')->get();
if ($accountants->isEmpty()) {
$accountants = User::role('accountant')->get();
}
foreach ($accountants as $accountant) {
Mail::to($accountant->email)->queue(new FinanceDocumentApprovedByCashier($financeDocument));
}
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '出納已審核通過。已送交會計審核。');
}
if ($financeDocument->canBeApprovedByAccountant() && $isAccountant) {
$financeDocument->update([
'approved_by_accountant_id' => $user->id,
'accountant_approved_at' => now(),
'status' => FinanceDocument::STATUS_APPROVED_ACCOUNTANT,
]);
AuditLogger::log('finance_document.approved_by_accountant', $financeDocument, [
'approved_by' => $user->name,
'amount_tier' => $financeDocument->amount_tier,
]);
// For small amounts, approval is complete (no chair needed)
// 小額:審核完成
if ($financeDocument->amount_tier === FinanceDocument::AMOUNT_TIER_SMALL) {
// 通知申請人審核已完成,可以領款
Mail::to($financeDocument->submittedBy->email)->queue(new FinanceDocumentFullyApproved($financeDocument));
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '會計已審核通過。小額申請審核完成,可以製作付款單。');
->with('status', '秘書長已核准。小額申請審核完成,申請人可向出納領款。');
}
// For medium and large amounts, send to chair
// 中額/大額:送交理事長
$chairs = User::role('finance_chair')->get();
if ($chairs->isEmpty()) {
$chairs = User::role('chair')->get();
}
foreach ($chairs as $chair) {
Mail::to($chair->email)->queue(new FinanceDocumentApprovedByAccountant($financeDocument));
}
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '會計已審核通過。已送交理事長審核。');
->with('status', '秘書長已核准。已送交理事長審核。');
}
if ($financeDocument->canBeApprovedByChair() && $isChair) {
// 理事長審核(第二階段:中額或大額)
if ($financeDocument->canBeApprovedByChair($user) && ($isChair || $isAdmin)) {
$financeDocument->update([
'approved_by_chair_id' => $user->id,
'chair_approved_at' => now(),
@@ -234,25 +205,147 @@ class FinanceDocumentController extends Controller
AuditLogger::log('finance_document.approved_by_chair', $financeDocument, [
'approved_by' => $user->name,
'amount_tier' => $financeDocument->amount_tier,
'requires_board_meeting' => $financeDocument->requires_board_meeting,
]);
// For large amounts, notify that board meeting approval is still needed
if ($financeDocument->requires_board_meeting && !$financeDocument->board_meeting_approved_at) {
// 中額:審核完成
if ($financeDocument->amount_tier === FinanceDocument::AMOUNT_TIER_MEDIUM) {
Mail::to($financeDocument->submittedBy->email)->queue(new FinanceDocumentFullyApproved($financeDocument));
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '理事長已審核通過。大額申請仍需理事會核准。');
->with('status', '理事長已核准。中額申請審核完成,申請人可向出納領款。');
}
// For medium amounts or large amounts with board approval, complete
// 大額:送交董理事會
$boardMembers = User::role('finance_board_member')->get();
foreach ($boardMembers as $member) {
Mail::to($member->email)->queue(new FinanceDocumentApprovedByAccountant($financeDocument));
}
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '理事長已核准。大額申請需送交董理事會審核。');
}
// 董理事會審核(第三階段:大額)
if ($financeDocument->canBeApprovedByBoard($user) && ($isBoardMember || $isAdmin)) {
$financeDocument->update([
'board_meeting_approved_by_id' => $user->id,
'board_meeting_approved_at' => now(),
'status' => FinanceDocument::STATUS_APPROVED_BOARD,
]);
AuditLogger::log('finance_document.approved_by_board', $financeDocument, [
'approved_by' => $user->name,
'amount_tier' => $financeDocument->amount_tier,
]);
Mail::to($financeDocument->submittedBy->email)->queue(new FinanceDocumentFullyApproved($financeDocument));
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '審核流程完成。會計可以製作付款單。');
->with('status', '董理事會已核准。審核流程完成,申請人可向出納領款。');
}
abort(403, 'You are not authorized to approve this document at this stage.');
abort(403, '您無權在此階段審核此文件。');
}
/**
* 出帳確認(雙重確認:申請人 + 出納)
*/
public function confirmDisbursement(Request $request, FinanceDocument $financeDocument)
{
$user = $request->user();
$isRequester = $financeDocument->submitted_by_user_id === $user->id;
$isCashier = $user->hasRole('finance_cashier');
$isAdmin = $user->hasRole('admin');
// 申請人確認
if ($isRequester && $financeDocument->canRequesterConfirmDisbursement($user)) {
$financeDocument->update([
'requester_confirmed_at' => now(),
'requester_confirmed_by_id' => $user->id,
]);
AuditLogger::log('finance_document.requester_confirmed_disbursement', $financeDocument, [
'confirmed_by' => $user->name,
]);
// 檢查是否雙重確認完成
if ($financeDocument->isDisbursementComplete()) {
$financeDocument->update(['disbursement_status' => FinanceDocument::DISBURSEMENT_COMPLETED]);
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '出帳確認完成。等待會計入帳。');
}
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '申請人已確認領款。等待出納確認。');
}
// 出納確認
if (($isCashier || $isAdmin) && $financeDocument->canCashierConfirmDisbursement()) {
$financeDocument->update([
'cashier_confirmed_at' => now(),
'cashier_confirmed_by_id' => $user->id,
]);
AuditLogger::log('finance_document.cashier_confirmed_disbursement', $financeDocument, [
'confirmed_by' => $user->name,
]);
// 檢查是否雙重確認完成
if ($financeDocument->isDisbursementComplete()) {
$financeDocument->update(['disbursement_status' => FinanceDocument::DISBURSEMENT_COMPLETED]);
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '出帳確認完成。等待會計入帳。');
}
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '出納已確認出帳。等待申請人確認。');
}
abort(403, '您無權確認此出帳。');
}
/**
* 入帳確認(會計)
*/
public function confirmRecording(Request $request, FinanceDocument $financeDocument)
{
$user = $request->user();
$isAccountant = $user->hasRole('finance_accountant');
$isAdmin = $user->hasRole('admin');
if (!$financeDocument->canAccountantConfirmRecording()) {
abort(403, '此文件尚未完成出帳確認,無法入帳。');
}
if (!$isAccountant && !$isAdmin) {
abort(403, '只有會計可以確認入帳。');
}
$financeDocument->update([
'accountant_recorded_at' => now(),
'accountant_recorded_by_id' => $user->id,
'recording_status' => FinanceDocument::RECORDING_COMPLETED,
]);
// 自動產生會計分錄
$financeDocument->autoGenerateAccountingEntries();
AuditLogger::log('finance_document.accountant_confirmed_recording', $financeDocument, [
'confirmed_by' => $user->name,
]);
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '會計已確認入帳。財務流程完成。');
}
public function reject(Request $request, FinanceDocument $financeDocument)
@@ -269,9 +362,12 @@ class FinanceDocumentController extends Controller
}
// Check if user has permission to reject
$canReject = $user->hasRole('finance_cashier') || $user->hasRole('cashier') ||
$user->hasRole('finance_accountant') || $user->hasRole('accountant') ||
$user->hasRole('finance_chair') || $user->hasRole('chair');
$canReject = $user->hasRole('admin') ||
$user->hasRole('secretary_general') ||
$user->hasRole('finance_cashier') ||
$user->hasRole('finance_accountant') ||
$user->hasRole('finance_chair') ||
$user->hasRole('finance_board_member');
if (!$canReject) {
abort(403, '您無權駁回此文件。');
@@ -295,7 +391,7 @@ class FinanceDocumentController extends Controller
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '財務申請單已駁回。');
->with('status', '報銷申請單已駁回。');
}
public function download(FinanceDocument $financeDocument)

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

View File

@@ -196,7 +196,7 @@ class IssueController extends Controller
public function edit(Issue $issue)
{
if ($issue->isClosed() && !Auth::user()->is_admin) {
if ($issue->isClosed() && !Auth::user()->hasRole('admin')) {
return redirect()->route('admin.issues.show', $issue)
->with('error', __('Cannot edit closed issues.'));
}
@@ -211,7 +211,7 @@ class IssueController extends Controller
public function update(Request $request, Issue $issue)
{
if ($issue->isClosed() && !Auth::user()->is_admin) {
if ($issue->isClosed() && !Auth::user()->hasRole('admin')) {
return redirect()->route('admin.issues.show', $issue)
->with('error', __('Cannot edit closed issues.'));
}
@@ -262,7 +262,7 @@ class IssueController extends Controller
public function destroy(Issue $issue)
{
if (!Auth::user()->is_admin) {
if (!Auth::user()->hasRole('admin')) {
abort(403, 'Only administrators can delete issues.');
}

View File

@@ -63,7 +63,7 @@ class IssueLabelController extends Controller
public function destroy(IssueLabel $issueLabel)
{
if (!Auth::user()->is_admin) {
if (!Auth::user()->hasRole('admin')) {
abort(403, 'Only administrators can delete labels.');
}

View File

@@ -6,6 +6,7 @@ use App\Mail\PaymentSubmittedMail;
use App\Models\Member;
use App\Models\MembershipPayment;
use App\Models\User;
use App\Services\MembershipFeeCalculator;
use App\Support\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -14,6 +15,13 @@ use Illuminate\Validation\Rule;
class MemberPaymentController extends Controller
{
protected MembershipFeeCalculator $feeCalculator;
public function __construct(MembershipFeeCalculator $feeCalculator)
{
$this->feeCalculator = $feeCalculator;
}
/**
* Show payment submission form
*/
@@ -32,7 +40,10 @@ class MemberPaymentController extends Controller
->with('error', __('You cannot submit payment at this time. You may already have a pending payment or your membership is already active.'));
}
return view('member.submit-payment', compact('member'));
// Calculate fee details
$feeDetails = $this->feeCalculator->calculateNextFee($member);
return view('member.submit-payment', compact('member', 'feeDetails'));
}
/**
@@ -47,8 +58,11 @@ class MemberPaymentController extends Controller
->with('error', __('You cannot submit payment at this time.'));
}
// Calculate fee details
$feeDetails = $this->feeCalculator->calculateNextFee($member);
$validated = $request->validate([
'amount' => ['required', 'numeric', 'min:0'],
'amount' => ['required', 'numeric', 'min:' . $feeDetails['final_amount']],
'paid_at' => ['required', 'date', 'before_or_equal:today'],
'payment_method' => ['required', Rule::in([
MembershipPayment::METHOD_BANK_TRANSFER,
@@ -65,10 +79,15 @@ class MemberPaymentController extends Controller
$receiptFile = $request->file('receipt');
$receiptPath = $receiptFile->store('payment-receipts', 'private');
// Create payment record
// Create payment record with fee details
$payment = MembershipPayment::create([
'member_id' => $member->id,
'fee_type' => $feeDetails['fee_type'],
'amount' => $validated['amount'],
'base_amount' => $feeDetails['base_amount'],
'discount_amount' => $feeDetails['discount_amount'],
'final_amount' => $feeDetails['final_amount'],
'disability_discount' => $feeDetails['disability_discount'],
'paid_at' => $validated['paid_at'],
'payment_method' => $validated['payment_method'],
'reference' => $validated['reference'] ?? null,

View File

@@ -59,14 +59,14 @@ class PaymentOrderController extends Controller
if (!$financeDocument->canCreatePaymentOrder()) {
return redirect()
->route('admin.finance.show', $financeDocument)
->with('error', '此財務申請單尚未完成審核流程,無法製作付款單。');
->with('error', '此報銷申請單尚未完成審核流程,無法製作付款單。');
}
// Check if payment order already exists
if ($financeDocument->paymentOrder !== null) {
return redirect()
->route('admin.payment-orders.show', $financeDocument->paymentOrder)
->with('error', '此財務申請單已有付款單。');
->with('error', '此報銷申請單已有付款單。');
}
$financeDocument->load(['member', 'submittedBy']);
@@ -98,7 +98,7 @@ class PaymentOrderController extends Controller
}
return redirect()
->route('admin.finance.show', $financeDocument)
->with('error', '此財務申請單尚未完成審核流程,無法製作付款單。');
->with('error', '此報銷申請單尚未完成審核流程,無法製作付款單。');
}
$validated = $request->validate([

View File

@@ -88,8 +88,20 @@ class PaymentVerificationController extends Controller
$validated = $request->validate([
'notes' => ['nullable', 'string', 'max:1000'],
'disability_action' => ['nullable', 'in:approve,reject'],
'disability_rejection_reason' => ['required_if:disability_action,reject', 'nullable', 'string', 'max:500'],
]);
// Handle disability certificate verification if applicable
$member = $payment->member;
if ($member && $member->hasDisabilityCertificate() && $member->isDisabilityPending()) {
if ($validated['disability_action'] === 'approve') {
$member->approveDisabilityCertificate(Auth::user());
} elseif ($validated['disability_action'] === 'reject') {
$member->rejectDisabilityCertificate(Auth::user(), $validated['disability_rejection_reason']);
}
}
$payment->update([
'status' => MembershipPayment::STATUS_APPROVED_CASHIER,
'verified_by_cashier_id' => Auth::id(),

View File

@@ -97,4 +97,57 @@ class ProfileController extends Controller
return Redirect::to('/');
}
/**
* Upload disability certificate.
*/
public function uploadDisabilityCertificate(Request $request): RedirectResponse
{
$request->validate([
'disability_certificate' => 'required|file|mimes:jpg,jpeg,png,pdf|max:10240',
]);
$member = $request->user()->member;
if (!$member) {
return Redirect::route('profile.edit')->with('error', '請先建立會員資料');
}
// Delete old certificate if exists
if ($member->disability_certificate_path) {
Storage::disk('private')->delete($member->disability_certificate_path);
}
// Upload new certificate
$path = $request->file('disability_certificate')->store('disability-certificates', 'private');
// Update member record
$member->update([
'disability_certificate_path' => $path,
'disability_certificate_status' => Member::DISABILITY_STATUS_PENDING,
'disability_verified_by' => null,
'disability_verified_at' => null,
'disability_rejection_reason' => null,
]);
return Redirect::route('profile.edit')->with('status', 'disability-certificate-uploaded');
}
/**
* View disability certificate.
*/
public function viewDisabilityCertificate(Request $request)
{
$member = $request->user()->member;
if (!$member || !$member->disability_certificate_path) {
abort(404, '找不到身心障礙手冊');
}
if (!Storage::disk('private')->exists($member->disability_certificate_path)) {
abort(404, '檔案不存在');
}
return Storage::disk('private')->response($member->disability_certificate_path);
}
}

View File

@@ -22,7 +22,7 @@ class PublicDocumentController extends Controller
if (!$user) {
// Only public documents for guests
$query->where('access_level', 'public');
} elseif (!$user->is_admin && !$user->hasRole('admin')) {
} elseif (!$user->hasRole('admin')) {
// Members can see public + members-only
$query->whereIn('access_level', ['public', 'members']);
}
@@ -49,7 +49,7 @@ class PublicDocumentController extends Controller
'activeDocuments' => function($query) use ($user) {
if (!$user) {
$query->where('access_level', 'public');
} elseif (!$user->is_admin && !$user->hasRole('admin')) {
} elseif (!$user->hasRole('admin')) {
$query->whereIn('access_level', ['public', 'members']);
}
}

View File

@@ -17,7 +17,7 @@ class EnsureUserIsAdmin
}
// Allow access for admins or any user with explicit permissions (e.g. finance/cashier roles)
if (! $user->is_admin && ! $user->hasRole('admin') && $user->getAllPermissions()->isEmpty()) {
if (! $user->hasRole('admin') && $user->getAllPermissions()->isEmpty()) {
abort(403);
}