diff --git a/app/Console/Commands/AnalyzeAccountingData.php b/app/Console/Commands/AnalyzeAccountingData.php new file mode 100644 index 0000000..1d8bcb0 --- /dev/null +++ b/app/Console/Commands/AnalyzeAccountingData.php @@ -0,0 +1,76 @@ +info('=== 協會帳務資料分析 ==='); + $this->newLine(); + + $files = [ + '2024尤塞氏症及視聽雙弱協會帳務.xlsx' => '協會行政資料/協會帳務/2024尤塞氏症及視聽雙弱協會帳務.xlsx', + '2025 收入支出總表 (含會計科目編號).xlsx' => '協會行政資料/協會帳務/2025 收入支出總表 (含會計科目編號).xlsx', + '2025 協會預算試編.xlsx' => '協會行政資料/協會帳務/2025 協會預算試編.xlsx', + ]; + + foreach ($files as $name => $path) { + if (!file_exists($path)) { + $this->error("檔案不存在: {$name}"); + continue; + } + + $this->info("📊 分析檔案: {$name}"); + $this->info("📅 最後更新: " . date('Y-m-d H:i:s', filemtime($path))); + $this->info("📦 檔案大小: " . number_format(filesize($path)) . " bytes"); + $this->newLine(); + + try { + $spreadsheet = IOFactory::load($path); + + $this->info("工作表列表:"); + foreach ($spreadsheet->getAllSheets() as $index => $sheet) { + $sheetName = $sheet->getTitle(); + $highestRow = $sheet->getHighestRow(); + $highestColumn = $sheet->getHighestColumn(); + + $this->line(" - {$sheetName} (範圍: A1:{$highestColumn}{$highestRow}, 共 {$highestRow} 列)"); + + // 讀取前5行作為預覽 + if ($highestRow > 0) { + $this->info(" 前5行預覽:"); + for ($row = 1; $row <= min(5, $highestRow); $row++) { + $rowData = []; + for ($col = 'A'; $col <= min('J', $highestColumn); $col++) { + $cellValue = $sheet->getCell($col . $row)->getValue(); + if (!empty($cellValue)) { + $rowData[] = substr($cellValue, 0, 30); + } + } + if (!empty($rowData)) { + $this->line(" Row {$row}: " . implode(' | ', $rowData)); + } + } + $this->newLine(); + } + } + + } catch (\Exception $e) { + $this->error("讀取失敗: " . $e->getMessage()); + } + + $this->info(str_repeat('─', 80)); + $this->newLine(); + } + + return 0; + } +} diff --git a/app/Console/Commands/ImportAccountingData.php b/app/Console/Commands/ImportAccountingData.php new file mode 100644 index 0000000..596e56f --- /dev/null +++ b/app/Console/Commands/ImportAccountingData.php @@ -0,0 +1,436 @@ + 0, + 'expense_count' => 0, + 'skipped_count' => 0, + 'error_count' => 0, + ]; + + public function handle() + { + $this->info('=== 會計資料匯入工具 ==='); + $this->newLine(); + + // Load configuration + $this->mapping = config('accounting_mapping.excel_to_system', []); + $this->expenseKeywords = config('accounting_mapping.expense_keywords', []); + + // Get file path + $filePath = $this->argument('file') ?? $this->askForFile(); + + if (!file_exists($filePath)) { + $this->error("檔案不存在: {$filePath}"); + return 1; + } + + $this->info("📂 檔案: {$filePath}"); + $this->info("📅 檔案日期: " . date('Y-m-d H:i:s', filemtime($filePath))); + $this->newLine(); + + if ($this->option('dry-run')) { + $this->warn('⚠️ DRY RUN MODE - 不會實際寫入資料庫'); + $this->newLine(); + } + + try { + $spreadsheet = IOFactory::load($filePath); + + // Import income + $this->info('📊 匯入收入資料...'); + $this->importIncome($spreadsheet); + $this->newLine(); + + // Import expenses + $this->info('📊 匯入支出資料...'); + $this->importExpenses($spreadsheet); + $this->newLine(); + + // Show summary + $this->showSummary(); + + // Verify balance + if (!$this->option('dry-run')) { + $this->verifyBalance(); + } + + return 0; + } catch (\Exception $e) { + $this->error('匯入失敗: ' . $e->getMessage()); + $this->error($e->getTraceAsString()); + return 1; + } + } + + protected function askForFile(): string + { + $defaultPath = '協會行政資料/協會帳務/2025 收入支出總表 (含會計科目編號).xlsx'; + + $this->info('可用檔案:'); + $this->line('1. ' . $defaultPath); + + return $this->ask('請輸入檔案路徑', $defaultPath); + } + + protected function importIncome($spreadsheet) + { + // Find the "收入" sheet + $sheet = null; + foreach ($spreadsheet->getAllSheets() as $s) { + if (in_array($s->getTitle(), ['收入', 'Income', '收入明細'])) { + $sheet = $s; + break; + } + } + + if (!$sheet) { + $this->warn('找不到「收入」工作表,跳過'); + return; + } + + $this->info("工作表: {$sheet->getTitle()}"); + + // Read header row to find columns + $headerRow = 1; + $headers = []; + $maxCol = $sheet->getHighestColumn(); + for ($col = 'A'; $col <= $maxCol; $col++) { + $value = $sheet->getCell($col . $headerRow)->getValue(); + if ($value) { + $headers[$col] = $value; + } + } + + $this->line('欄位: ' . implode(', ', $headers)); + + // Detect columns + $dateCol = $this->findColumn($headers, ['日期', 'Date']); + $accountCodeCol = $this->findColumn($headers, ['科目編號', '科目代碼', 'Code']); + $accountNameCol = $this->findColumn($headers, ['科目名稱', 'Account']); + $amountCol = $this->findColumn($headers, ['收入金額', '金額', 'Amount']); + $descCol = $this->findColumn($headers, ['收入來源備註', '備註', '說明', 'Description']); + + if (!$dateCol || !$amountCol) { + $this->error('缺少必要欄位(日期、金額)'); + return; + } + + // Import rows + $highestRow = $sheet->getHighestRow(); + $bar = $this->output->createProgressBar($highestRow - 1); + + for ($row = $headerRow + 1; $row <= $highestRow; $row++) { + $amount = $sheet->getCell($amountCol . $row)->getValue(); + + if (empty($amount) || $amount == 0) { + $this->stats['skipped_count']++; + $bar->advance(); + continue; + } + + try { + $date = $this->parseDate($sheet->getCell($dateCol . $row)->getValue()); + $excelAccountCode = $accountCodeCol ? $sheet->getCell($accountCodeCol . $row)->getValue() : null; + $description = $descCol ? $sheet->getCell($descCol . $row)->getValue() : ''; + + // Map to system account + $systemAccountCode = $this->mapAccountCode($excelAccountCode); + $account = $this->getAccount($systemAccountCode); + + if (!$account) { + $this->stats['error_count']++; + $this->warn("\n找不到科目: {$systemAccountCode} (Excel: {$excelAccountCode})"); + $bar->advance(); + continue; + } + + if (!$this->option('dry-run')) { + $this->createIncomeEntry($date, $account, $amount, $description); + } + + $this->stats['income_count']++; + } catch (\Exception $e) { + $this->stats['error_count']++; + $this->warn("\nRow {$row} 錯誤: " . $e->getMessage()); + } + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + } + + protected function importExpenses($spreadsheet) + { + // Find the "支出" sheet + $sheet = null; + foreach ($spreadsheet->getAllSheets() as $s) { + if (in_array($s->getTitle(), ['支出', 'Expense', '支出明細'])) { + $sheet = $s; + break; + } + } + + if (!$sheet) { + $this->warn('找不到「支出」工作表,跳過'); + return; + } + + $this->info("工作表: {$sheet->getTitle()}"); + + // Read header row + $headerRow = 1; + $headers = []; + $maxCol = $sheet->getHighestColumn(); + for ($col = 'A'; $col <= $maxCol; $col++) { + $value = $sheet->getCell($col . $headerRow)->getValue(); + if ($value) { + $headers[$col] = $value; + } + } + + $this->line('欄位: ' . implode(', ', $headers)); + + // Detect columns + $dateCol = $this->findColumn($headers, ['日期', 'Date']); + $accountCodeCol = $this->findColumn($headers, ['科目編號', '科目代碼', 'Code']); + $amountCol = $this->findColumn($headers, ['支出金額', '金額', 'Amount']); + $descCol = $this->findColumn($headers, ['支出用途備註', '用途', '備註', 'Description']); + + if (!$dateCol || !$amountCol) { + $this->error('缺少必要欄位(日期、金額)'); + return; + } + + // Import rows + $highestRow = $sheet->getHighestRow(); + $bar = $this->output->createProgressBar($highestRow - 1); + + for ($row = $headerRow + 1; $row <= $highestRow; $row++) { + $amount = $sheet->getCell($amountCol . $row)->getValue(); + + if (empty($amount) || $amount == 0) { + $this->stats['skipped_count']++; + $bar->advance(); + continue; + } + + try { + $date = $this->parseDate($sheet->getCell($dateCol . $row)->getValue()); + $excelAccountCode = $accountCodeCol ? $sheet->getCell($accountCodeCol . $row)->getValue() : '5100'; + $description = $descCol ? $sheet->getCell($descCol . $row)->getValue() : ''; + + // For 5100, classify by keywords + if ($excelAccountCode == '5100') { + $systemAccountCode = $this->classifyExpense($description); + } else { + $systemAccountCode = $this->mapAccountCode($excelAccountCode); + } + + $account = $this->getAccount($systemAccountCode); + + if (!$account) { + $this->stats['error_count']++; + $this->warn("\n找不到科目: {$systemAccountCode}"); + $bar->advance(); + continue; + } + + if (!$this->option('dry-run')) { + $this->createExpenseEntry($date, $account, $amount, $description); + } + + $this->stats['expense_count']++; + } catch (\Exception $e) { + $this->stats['error_count']++; + $this->warn("\nRow {$row} 錯誤: " . $e->getMessage()); + } + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + } + + protected function createIncomeEntry($date, $account, $amount, $description) + { + // Create finance document + $document = FinanceDocument::create([ + 'title' => '收入 - ' . $account->account_name_zh, + 'amount' => $amount, + 'description' => $description, + 'chart_of_account_id' => $account->id, + 'submitted_at' => $date, + 'status' => FinanceDocument::STATUS_APPROVED_CHAIR, + ]); + + // Create accounting entries (double-entry) + // Debit: Cash + AccountingEntry::create([ + 'finance_document_id' => $document->id, + 'chart_of_account_id' => $this->getAccount('1101')->id, + 'entry_type' => AccountingEntry::ENTRY_TYPE_DEBIT, + 'amount' => $amount, + 'entry_date' => $date, + 'description' => '收入 - ' . $description, + ]); + + // Credit: Income account + AccountingEntry::create([ + 'finance_document_id' => $document->id, + 'chart_of_account_id' => $account->id, + 'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT, + 'amount' => $amount, + 'entry_date' => $date, + 'description' => $description, + ]); + } + + protected function createExpenseEntry($date, $account, $amount, $description) + { + // Create finance document + $document = FinanceDocument::create([ + 'title' => '支出 - ' . $account->account_name_zh, + 'amount' => $amount, + 'description' => $description, + 'chart_of_account_id' => $account->id, + 'submitted_at' => $date, + 'status' => FinanceDocument::STATUS_APPROVED_CHAIR, + ]); + + // Create accounting entries (double-entry) + // Debit: Expense account + AccountingEntry::create([ + 'finance_document_id' => $document->id, + 'chart_of_account_id' => $account->id, + 'entry_type' => AccountingEntry::ENTRY_TYPE_DEBIT, + 'amount' => $amount, + 'entry_date' => $date, + 'description' => $description, + ]); + + // Credit: Cash + AccountingEntry::create([ + 'finance_document_id' => $document->id, + 'chart_of_account_id' => $this->getAccount('1101')->id, + 'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT, + 'amount' => $amount, + 'entry_date' => $date, + 'description' => '支出 - ' . $description, + ]); + } + + protected function mapAccountCode($excelCode) + { + return $this->mapping[$excelCode] ?? $excelCode; + } + + protected function classifyExpense($description): string + { + foreach ($this->expenseKeywords as $rule) { + if (empty($rule['keywords'])) { + if ($rule['is_default'] ?? false) { + return $rule['account_code']; + } + continue; + } + + foreach ($rule['keywords'] as $keyword) { + if (mb_strpos($description, $keyword) !== false) { + return $rule['account_code']; + } + } + } + + return '5901'; // Default: 雜項支出 + } + + protected function getAccount($accountCode) + { + if (!isset($this->accountCache[$accountCode])) { + $this->accountCache[$accountCode] = ChartOfAccount::where('account_code', $accountCode)->first(); + } + + return $this->accountCache[$accountCode]; + } + + protected function parseDate($value) + { + if (is_numeric($value)) { + return ExcelDate::excelToDateTimeObject($value); + } + + if ($value instanceof \DateTime) { + return $value; + } + + return new \DateTime($value); + } + + protected function findColumn($headers, $patterns) + { + foreach ($headers as $col => $header) { + foreach ($patterns as $pattern) { + if (mb_strpos($header, $pattern) !== false) { + return $col; + } + } + } + + return null; + } + + protected function showSummary() + { + $this->info('=== 匯入統計 ==='); + $this->table( + ['項目', '數量'], + [ + ['收入筆數', $this->stats['income_count']], + ['支出筆數', $this->stats['expense_count']], + ['跳過筆數', $this->stats['skipped_count']], + ['錯誤筆數', $this->stats['error_count']], + ] + ); + } + + protected function verifyBalance() + { + $this->info('=== 驗證借貸平衡 ==='); + + $debitTotal = AccountingEntry::where('entry_type', AccountingEntry::ENTRY_TYPE_DEBIT)->sum('amount'); + $creditTotal = AccountingEntry::where('entry_type', AccountingEntry::ENTRY_TYPE_CREDIT)->sum('amount'); + + $this->line("借方總計: " . number_format($debitTotal, 2)); + $this->line("貸方總計: " . number_format($creditTotal, 2)); + + if (bccomp((string)$debitTotal, (string)$creditTotal, 2) === 0) { + $this->info('✅ 借貸平衡'); + } else { + $diff = $debitTotal - $creditTotal; + $this->error("❌ 借貸不平衡,差額: " . number_format($diff, 2)); + } + } +} diff --git a/app/Http/Controllers/Admin/AnnouncementController.php b/app/Http/Controllers/Admin/AnnouncementController.php new file mode 100644 index 0000000..57d6e4c --- /dev/null +++ b/app/Http/Controllers/Admin/AnnouncementController.php @@ -0,0 +1,346 @@ +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', '公告已取消置頂'); + } +} diff --git a/app/Http/Controllers/Admin/DocumentController.php b/app/Http/Controllers/Admin/DocumentController.php index 764b06d..741d40c 100644 --- a/app/Http/Controllers/Admin/DocumentController.php +++ b/app/Http/Controllers/Admin/DocumentController.php @@ -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') diff --git a/app/Http/Controllers/Admin/GeneralLedgerController.php b/app/Http/Controllers/Admin/GeneralLedgerController.php new file mode 100644 index 0000000..6bea9ed --- /dev/null +++ b/app/Http/Controllers/Admin/GeneralLedgerController.php @@ -0,0 +1,87 @@ +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' + )); + } +} diff --git a/app/Http/Controllers/Admin/SystemSettingsController.php b/app/Http/Controllers/Admin/SystemSettingsController.php index 577e750..60399b1 100644 --- a/app/Http/Controllers/Admin/SystemSettingsController.php +++ b/app/Http/Controllers/Admin/SystemSettingsController.php @@ -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', '會費設定已更新'); + } } diff --git a/app/Http/Controllers/Admin/TrialBalanceController.php b/app/Http/Controllers/Admin/TrialBalanceController.php new file mode 100644 index 0000000..4c8595e --- /dev/null +++ b/app/Http/Controllers/Admin/TrialBalanceController.php @@ -0,0 +1,75 @@ +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' + )); + } +} diff --git a/app/Http/Controllers/AdminDashboardController.php b/app/Http/Controllers/AdminDashboardController.php index 43d6f3c..bc6e071 100644 --- a/app/Http/Controllers/AdminDashboardController.php +++ b/app/Http/Controllers/AdminDashboardController.php @@ -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' )); } } diff --git a/app/Http/Controllers/AdminMemberController.php b/app/Http/Controllers/AdminMemberController.php index 4b14f10..20cdf6a 100644 --- a/app/Http/Controllers/AdminMemberController.php +++ b/app/Http/Controllers/AdminMemberController.php @@ -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); + } } diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index a15828f..7b47677 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -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); diff --git a/app/Http/Controllers/BudgetController.php b/app/Http/Controllers/BudgetController.php index 0db2d81..2bcddd3 100644 --- a/app/Http/Controllers/BudgetController.php +++ b/app/Http/Controllers/BudgetController.php @@ -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.'); } diff --git a/app/Http/Controllers/FinanceDocumentController.php b/app/Http/Controllers/FinanceDocumentController.php index 1fe222c..6d04b53 100644 --- a/app/Http/Controllers/FinanceDocumentController.php +++ b/app/Http/Controllers/FinanceDocumentController.php @@ -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) diff --git a/app/Http/Controllers/IncomeController.php b/app/Http/Controllers/IncomeController.php new file mode 100644 index 0000000..6863415 --- /dev/null +++ b/app/Http/Controllers/IncomeController.php @@ -0,0 +1,409 @@ +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); + } +} diff --git a/app/Http/Controllers/IssueController.php b/app/Http/Controllers/IssueController.php index 8a7f2e8..b31be58 100644 --- a/app/Http/Controllers/IssueController.php +++ b/app/Http/Controllers/IssueController.php @@ -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.'); } diff --git a/app/Http/Controllers/IssueLabelController.php b/app/Http/Controllers/IssueLabelController.php index a7b259c..fc7745c 100644 --- a/app/Http/Controllers/IssueLabelController.php +++ b/app/Http/Controllers/IssueLabelController.php @@ -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.'); } diff --git a/app/Http/Controllers/MemberPaymentController.php b/app/Http/Controllers/MemberPaymentController.php index 34adab5..e7fc32a 100644 --- a/app/Http/Controllers/MemberPaymentController.php +++ b/app/Http/Controllers/MemberPaymentController.php @@ -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, diff --git a/app/Http/Controllers/PaymentOrderController.php b/app/Http/Controllers/PaymentOrderController.php index 53434e3..d6b7738 100644 --- a/app/Http/Controllers/PaymentOrderController.php +++ b/app/Http/Controllers/PaymentOrderController.php @@ -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([ diff --git a/app/Http/Controllers/PaymentVerificationController.php b/app/Http/Controllers/PaymentVerificationController.php index 5ab2bf5..84a4c76 100644 --- a/app/Http/Controllers/PaymentVerificationController.php +++ b/app/Http/Controllers/PaymentVerificationController.php @@ -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(), diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index fe66d08..94b1c7f 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -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); + } } diff --git a/app/Http/Controllers/PublicDocumentController.php b/app/Http/Controllers/PublicDocumentController.php index 592b0a5..d3d515e 100644 --- a/app/Http/Controllers/PublicDocumentController.php +++ b/app/Http/Controllers/PublicDocumentController.php @@ -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']); } } diff --git a/app/Http/Middleware/EnsureUserIsAdmin.php b/app/Http/Middleware/EnsureUserIsAdmin.php index c177c48..415df57 100644 --- a/app/Http/Middleware/EnsureUserIsAdmin.php +++ b/app/Http/Middleware/EnsureUserIsAdmin.php @@ -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); } diff --git a/app/Models/AccountingEntry.php b/app/Models/AccountingEntry.php new file mode 100644 index 0000000..3fd3834 --- /dev/null +++ b/app/Models/AccountingEntry.php @@ -0,0 +1,101 @@ + 'date', + 'amount' => 'decimal:2', + ]; + + /** + * Get the finance document that owns this entry + */ + public function financeDocument() + { + return $this->belongsTo(FinanceDocument::class); + } + + /** + * Get the income that owns this entry + */ + public function income() + { + return $this->belongsTo(Income::class); + } + + /** + * Get the chart of account for this entry + */ + public function chartOfAccount() + { + return $this->belongsTo(ChartOfAccount::class); + } + + /** + * Check if this is a debit entry + */ + public function isDebit(): bool + { + return $this->entry_type === self::ENTRY_TYPE_DEBIT; + } + + /** + * Check if this is a credit entry + */ + public function isCredit(): bool + { + return $this->entry_type === self::ENTRY_TYPE_CREDIT; + } + + /** + * Scope to filter debit entries + */ + public function scopeDebits($query) + { + return $query->where('entry_type', self::ENTRY_TYPE_DEBIT); + } + + /** + * Scope to filter credit entries + */ + public function scopeCredits($query) + { + return $query->where('entry_type', self::ENTRY_TYPE_CREDIT); + } + + /** + * Scope to filter by account + */ + public function scopeForAccount($query, $accountId) + { + return $query->where('chart_of_account_id', $accountId); + } + + /** + * Scope to filter by date range + */ + public function scopeDateRange($query, $startDate, $endDate) + { + return $query->whereBetween('entry_date', [$startDate, $endDate]); + } +} diff --git a/app/Models/Announcement.php b/app/Models/Announcement.php new file mode 100644 index 0000000..5ff715a --- /dev/null +++ b/app/Models/Announcement.php @@ -0,0 +1,427 @@ + 'boolean', + 'display_order' => 'integer', + 'view_count' => 'integer', + 'published_at' => 'datetime', + 'expires_at' => 'datetime', + 'archived_at' => 'datetime', + ]; + + // ==================== Relationships ==================== + + /** + * Get the user who created this announcement + */ + public function creator() + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + /** + * Get the user who last updated this announcement + */ + public function lastUpdatedBy() + { + return $this->belongsTo(User::class, 'last_updated_by_user_id'); + } + + // ==================== Status Check Methods ==================== + + /** + * Check if announcement is draft + */ + public function isDraft(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + /** + * Check if announcement is published + */ + public function isPublished(): bool + { + return $this->status === self::STATUS_PUBLISHED; + } + + /** + * Check if announcement is archived + */ + public function isArchived(): bool + { + return $this->status === self::STATUS_ARCHIVED; + } + + /** + * Check if announcement is pinned + */ + public function isPinned(): bool + { + return $this->is_pinned; + } + + /** + * Check if announcement is expired + */ + public function isExpired(): bool + { + if (!$this->expires_at) { + return false; + } + + return $this->expires_at->isPast(); + } + + /** + * Check if announcement is scheduled (published_at is in the future) + */ + public function isScheduled(): bool + { + if (!$this->published_at) { + return false; + } + + return $this->published_at->isFuture(); + } + + /** + * Check if announcement is currently active + */ + public function isActive(): bool + { + return $this->isPublished() + && !$this->isExpired() + && (!$this->published_at || $this->published_at->isPast()); + } + + // ==================== Access Control Methods ==================== + + /** + * Check if a user can view this announcement + */ + public function canBeViewedBy(?User $user): bool + { + // Draft announcements - only creator and admins can view + if ($this->isDraft()) { + if (!$user) { + return false; + } + return $user->id === $this->created_by_user_id + || $user->hasRole('admin') + || $user->can('manage_all_announcements'); + } + + // Archived announcements - only admins can view + if ($this->isArchived()) { + if (!$user) { + return false; + } + return $user->hasRole('admin') || $user->can('manage_all_announcements'); + } + + // Expired announcements - hidden from regular users + if ($this->isExpired()) { + if (!$user) { + return false; + } + return $user->hasRole('admin') || $user->can('manage_all_announcements'); + } + + // Scheduled announcements - not yet visible + if ($this->isScheduled()) { + if (!$user) { + return false; + } + return $user->id === $this->created_by_user_id + || $user->hasRole('admin') + || $user->can('manage_all_announcements'); + } + + // Check access level for published announcements + if ($this->access_level === self::ACCESS_LEVEL_PUBLIC) { + return true; + } + + if (!$user) { + return false; + } + + if ($user->hasRole('admin')) { + return true; + } + + if ($this->access_level === self::ACCESS_LEVEL_MEMBERS) { + return $user->member && $user->member->hasPaidMembership(); + } + + if ($this->access_level === self::ACCESS_LEVEL_BOARD) { + return $user->hasRole(['admin', 'finance_chair', 'finance_board_member']); + } + + if ($this->access_level === self::ACCESS_LEVEL_ADMIN) { + return $user->hasRole('admin'); + } + + return false; + } + + /** + * Check if a user can edit this announcement + */ + public function canBeEditedBy(User $user): bool + { + // Admin and users with manage_all_announcements can edit all + if ($user->hasRole('admin') || $user->can('manage_all_announcements')) { + return true; + } + + // User must have edit_announcements permission + if (!$user->can('edit_announcements')) { + return false; + } + + // Can only edit own announcements + return $user->id === $this->created_by_user_id; + } + + // ==================== Query Scopes ==================== + + /** + * Scope to only published announcements + */ + public function scopePublished(Builder $query): Builder + { + return $query->where('status', self::STATUS_PUBLISHED); + } + + /** + * Scope to only draft announcements + */ + public function scopeDraft(Builder $query): Builder + { + return $query->where('status', self::STATUS_DRAFT); + } + + /** + * Scope to only archived announcements + */ + public function scopeArchived(Builder $query): Builder + { + return $query->where('status', self::STATUS_ARCHIVED); + } + + /** + * Scope to only active announcements (published, not expired, not scheduled) + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('status', self::STATUS_PUBLISHED) + ->where(function ($q) { + $q->whereNull('published_at') + ->orWhere('published_at', '<=', now()); + }) + ->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } + + /** + * Scope to only pinned announcements + */ + public function scopePinned(Builder $query): Builder + { + return $query->where('is_pinned', true); + } + + /** + * Scope to filter by access level + */ + public function scopeForAccessLevel(Builder $query, User $user): Builder + { + if ($user->hasRole('admin')) { + return $query; + } + + $accessLevels = [self::ACCESS_LEVEL_PUBLIC]; + + if ($user->member && $user->member->hasPaidMembership()) { + $accessLevels[] = self::ACCESS_LEVEL_MEMBERS; + } + + if ($user->hasRole(['finance_chair', 'finance_board_member'])) { + $accessLevels[] = self::ACCESS_LEVEL_BOARD; + } + + return $query->whereIn('access_level', $accessLevels); + } + + // ==================== Helper Methods ==================== + + /** + * Publish this announcement + */ + public function publish(?User $user = null): void + { + $updates = [ + 'status' => self::STATUS_PUBLISHED, + ]; + + if (!$this->published_at) { + $updates['published_at'] = now(); + } + + if ($user) { + $updates['last_updated_by_user_id'] = $user->id; + } + + $this->update($updates); + } + + /** + * Archive this announcement + */ + public function archive(?User $user = null): void + { + $updates = [ + 'status' => self::STATUS_ARCHIVED, + 'archived_at' => now(), + ]; + + if ($user) { + $updates['last_updated_by_user_id'] = $user->id; + } + + $this->update($updates); + } + + /** + * Pin this announcement + */ + public function pin(?int $order = null, ?User $user = null): void + { + $updates = [ + 'is_pinned' => true, + 'display_order' => $order ?? 0, + ]; + + if ($user) { + $updates['last_updated_by_user_id'] = $user->id; + } + + $this->update($updates); + } + + /** + * Unpin this announcement + */ + public function unpin(?User $user = null): void + { + $updates = [ + 'is_pinned' => false, + 'display_order' => 0, + ]; + + if ($user) { + $updates['last_updated_by_user_id'] = $user->id; + } + + $this->update($updates); + } + + /** + * Increment view count + */ + public function incrementViewCount(): void + { + $this->increment('view_count'); + } + + /** + * Get the access level label in Chinese + */ + public function getAccessLevelLabel(): string + { + return match($this->access_level) { + self::ACCESS_LEVEL_PUBLIC => '公開', + self::ACCESS_LEVEL_MEMBERS => '會員', + self::ACCESS_LEVEL_BOARD => '理事會', + self::ACCESS_LEVEL_ADMIN => '管理員', + default => '未知', + }; + } + + /** + * Get status label in Chinese + */ + public function getStatusLabel(): string + { + return match($this->status) { + self::STATUS_DRAFT => '草稿', + self::STATUS_PUBLISHED => '已發布', + self::STATUS_ARCHIVED => '已歸檔', + default => '未知', + }; + } + + /** + * Get status badge color + */ + public function getStatusBadgeColor(): string + { + return match($this->status) { + self::STATUS_DRAFT => 'gray', + self::STATUS_PUBLISHED => 'green', + self::STATUS_ARCHIVED => 'yellow', + default => 'gray', + }; + } + + /** + * Get content excerpt (first 150 characters) + */ + public function getExcerpt(int $length = 150): string + { + return \Illuminate\Support\Str::limit($this->content, $length); + } +} diff --git a/app/Models/BoardMeeting.php b/app/Models/BoardMeeting.php new file mode 100644 index 0000000..cba8ef3 --- /dev/null +++ b/app/Models/BoardMeeting.php @@ -0,0 +1,31 @@ + 'date', + ]; + + /** + * Get the finance documents approved by this board meeting. + */ + public function approvedFinanceDocuments(): HasMany + { + return $this->hasMany(FinanceDocument::class, 'approved_by_board_meeting_id'); + } +} diff --git a/app/Models/CashierLedgerEntry.php b/app/Models/CashierLedgerEntry.php index e99b95a..55507c5 100644 --- a/app/Models/CashierLedgerEntry.php +++ b/app/Models/CashierLedgerEntry.php @@ -45,7 +45,7 @@ class CashierLedgerEntry extends Model const PAYMENT_METHOD_CASH = 'cash'; /** - * 關聯到財務申請單 + * 關聯到報銷申請單 */ public function financeDocument(): BelongsTo { diff --git a/app/Models/Document.php b/app/Models/Document.php index 1a923a6..424dd95 100644 --- a/app/Models/Document.php +++ b/app/Models/Document.php @@ -178,7 +178,7 @@ class Document extends Model 'original_filename' => $originalFilename, 'mime_type' => $mimeType, 'file_size' => $fileSize, - 'file_hash' => hash_file('sha256', storage_path('app/' . $filePath)), + 'file_hash' => hash_file('sha256', \Illuminate\Support\Facades\Storage::disk('private')->path($filePath)), 'uploaded_by_user_id' => $uploadedBy->id, 'uploaded_at' => now(), ]); @@ -265,24 +265,44 @@ class Document extends Model */ public function canBeViewedBy(?User $user): bool { + // 公開文件:任何人可看 if ($this->isPublic()) { return true; } + // 非公開文件需要登入 if (!$user) { return false; } - if ($user->is_admin || $user->hasRole('admin')) { + // 有文件管理權限者可看所有文件 + if ($user->can('manage_documents')) { return true; } + // 會員等級:已繳費會員可看 if ($this->access_level === 'members') { return $user->member && $user->member->hasPaidMembership(); } + // 管理員等級:有任何管理權限者可看 + if ($this->access_level === 'admin') { + return $user->hasAnyPermission([ + 'manage_documents', + 'manage_members', + 'manage_finance', + 'manage_system_settings', + ]); + } + + // 理事會等級:有理事會相關權限者可看 if ($this->access_level === 'board') { - return $user->hasRole(['admin', 'chair', 'board']); + return $user->hasAnyPermission([ + 'manage_documents', + 'approve_finance_documents', + 'verify_payments_chair', + 'activate_memberships', + ]); } return false; diff --git a/app/Models/FinanceDocument.php b/app/Models/FinanceDocument.php index 0d469fc..ff1b92b 100644 --- a/app/Models/FinanceDocument.php +++ b/app/Models/FinanceDocument.php @@ -11,18 +11,26 @@ class FinanceDocument extends Model { use HasFactory; - // Status constants - public const STATUS_PENDING = 'pending'; + // Status constants (審核階段) + public const STATUS_PENDING = 'pending'; // 待審核 + public const STATUS_APPROVED_SECRETARY = 'approved_secretary'; // 秘書長已核准 + public const STATUS_APPROVED_CHAIR = 'approved_chair'; // 理事長已核准 + public const STATUS_APPROVED_BOARD = 'approved_board'; // 董理事會已核准 + public const STATUS_REJECTED = 'rejected'; // 已駁回 + + // Legacy status constants (保留向後相容) public const STATUS_APPROVED_CASHIER = 'approved_cashier'; public const STATUS_APPROVED_ACCOUNTANT = 'approved_accountant'; - public const STATUS_APPROVED_CHAIR = 'approved_chair'; - public const STATUS_REJECTED = 'rejected'; - // Request type constants - public const REQUEST_TYPE_EXPENSE_REIMBURSEMENT = 'expense_reimbursement'; - public const REQUEST_TYPE_ADVANCE_PAYMENT = 'advance_payment'; - public const REQUEST_TYPE_PURCHASE_REQUEST = 'purchase_request'; - public const REQUEST_TYPE_PETTY_CASH = 'petty_cash'; + // Disbursement status constants (出帳階段) + public const DISBURSEMENT_PENDING = 'pending'; // 待出帳 + public const DISBURSEMENT_REQUESTER_CONFIRMED = 'requester_confirmed'; // 申請人已確認 + public const DISBURSEMENT_CASHIER_CONFIRMED = 'cashier_confirmed'; // 出納已確認 + public const DISBURSEMENT_COMPLETED = 'completed'; // 已出帳 + + // Recording status constants (入帳階段) + public const RECORDING_PENDING = 'pending'; // 待入帳 + public const RECORDING_COMPLETED = 'completed'; // 已入帳 // Amount tier constants public const AMOUNT_TIER_SMALL = 'small'; // < 5,000 @@ -63,7 +71,6 @@ class FinanceDocument extends Model 'rejected_at', 'rejection_reason', // New payment stage fields - 'request_type', 'amount_tier', 'chart_of_account_id', 'budget_item_id', @@ -89,6 +96,17 @@ class FinanceDocument extends Model 'bank_reconciliation_id', 'reconciliation_status', 'reconciled_at', + // 新工作流程欄位 + 'approved_by_secretary_id', + 'secretary_approved_at', + 'disbursement_status', + 'requester_confirmed_at', + 'requester_confirmed_by_id', + 'cashier_confirmed_at', + 'cashier_confirmed_by_id', + 'recording_status', + 'accountant_recorded_at', + 'accountant_recorded_by_id', ]; protected $casts = [ @@ -106,6 +124,11 @@ class FinanceDocument extends Model 'payment_executed_at' => 'datetime', 'actual_payment_amount' => 'decimal:2', 'reconciled_at' => 'datetime', + // 新工作流程欄位 + 'secretary_approved_at' => 'datetime', + 'requester_confirmed_at' => 'datetime', + 'cashier_confirmed_at' => 'datetime', + 'accountant_recorded_at' => 'datetime', ]; public function member() @@ -138,6 +161,29 @@ class FinanceDocument extends Model return $this->belongsTo(User::class, 'rejected_by_user_id'); } + /** + * 新工作流程 Relationships + */ + public function approvedBySecretary(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by_secretary_id'); + } + + public function requesterConfirmedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'requester_confirmed_by_id'); + } + + public function cashierConfirmedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'cashier_confirmed_by_id'); + } + + public function accountantRecordedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'accountant_recorded_by_id'); + } + /** * New payment stage relationships */ @@ -187,9 +233,140 @@ class FinanceDocument extends Model } /** - * Check if document can be approved by cashier + * Get all accounting entries for this document */ - public function canBeApprovedByCashier(?User $user = null): bool + public function accountingEntries() + { + return $this->hasMany(AccountingEntry::class); + } + + /** + * Get debit entries for this document + */ + public function debitEntries() + { + return $this->accountingEntries()->where('entry_type', AccountingEntry::ENTRY_TYPE_DEBIT); + } + + /** + * Get credit entries for this document + */ + public function creditEntries() + { + return $this->accountingEntries()->where('entry_type', AccountingEntry::ENTRY_TYPE_CREDIT); + } + + /** + * Validate that debit and credit entries balance + */ + public function validateBalance(): bool + { + $debitTotal = $this->debitEntries()->sum('amount'); + $creditTotal = $this->creditEntries()->sum('amount'); + + return bccomp((string)$debitTotal, (string)$creditTotal, 2) === 0; + } + + /** + * Generate accounting entries for this document + * This creates the double-entry bookkeeping records + */ + public function generateAccountingEntries(array $entries): void + { + // Delete existing entries + $this->accountingEntries()->delete(); + + // Create new entries + foreach ($entries as $entry) { + $this->accountingEntries()->create([ + 'chart_of_account_id' => $entry['chart_of_account_id'], + 'entry_type' => $entry['entry_type'], + 'amount' => $entry['amount'], + 'entry_date' => $entry['entry_date'] ?? $this->submitted_at ?? now(), + 'description' => $entry['description'] ?? $this->description, + ]); + } + } + + /** + * Auto-generate simple accounting entries based on document type + * For basic income/expense transactions + */ + public function autoGenerateAccountingEntries(): void + { + // Only auto-generate if chart_of_account_id is set + if (!$this->chart_of_account_id) { + return; + } + + $entries = []; + $entryDate = $this->submitted_at ?? now(); + + // Determine if this is income or expense based on request type or account type + $account = $this->chartOfAccount; + if (!$account) { + return; + } + + if ($account->account_type === 'income') { + // Income: Debit Cash, Credit Income Account + $entries[] = [ + 'chart_of_account_id' => $this->getCashAccountId(), + 'entry_type' => AccountingEntry::ENTRY_TYPE_DEBIT, + 'amount' => $this->amount, + 'entry_date' => $entryDate, + 'description' => '收入 - ' . ($this->description ?? $this->title), + ]; + $entries[] = [ + 'chart_of_account_id' => $this->chart_of_account_id, + 'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT, + 'amount' => $this->amount, + 'entry_date' => $entryDate, + 'description' => $this->description ?? $this->title, + ]; + } elseif ($account->account_type === 'expense') { + // Expense: Debit Expense Account, Credit Cash + $entries[] = [ + 'chart_of_account_id' => $this->chart_of_account_id, + 'entry_type' => AccountingEntry::ENTRY_TYPE_DEBIT, + 'amount' => $this->amount, + 'entry_date' => $entryDate, + 'description' => $this->description ?? $this->title, + ]; + $entries[] = [ + 'chart_of_account_id' => $this->getCashAccountId(), + 'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT, + 'amount' => $this->amount, + 'entry_date' => $entryDate, + 'description' => '支出 - ' . ($this->description ?? $this->title), + ]; + } + + if (!empty($entries)) { + $this->generateAccountingEntries($entries); + } + } + + /** + * Get the cash account ID (1101 - 現金) + */ + protected function getCashAccountId(): int + { + static $cashAccountId = null; + + if ($cashAccountId === null) { + $cashAccount = ChartOfAccount::where('account_code', '1101')->first(); + $cashAccountId = $cashAccount ? $cashAccount->id : 1; + } + + return $cashAccountId; + } + + /** + * 新工作流程:秘書長可審核 + * 條件:待審核狀態 + 不能審核自己的申請 + */ + public function canBeApprovedBySecretary(?User $user = null): bool { if ($this->status !== self::STATUS_PENDING) { return false; @@ -203,27 +380,164 @@ class FinanceDocument extends Model } /** - * Check if document can be approved by accountant + * 新工作流程:理事長可審核 + * 條件:秘書長已核准 + 中額或大額 */ - public function canBeApprovedByAccountant(): bool + public function canBeApprovedByChair(?User $user = null): bool { - return $this->status === self::STATUS_APPROVED_CASHIER; + $tier = $this->amount_tier ?? $this->determineAmountTier(); + + if ($this->status !== self::STATUS_APPROVED_SECRETARY) { + return false; + } + + if (!in_array($tier, [self::AMOUNT_TIER_MEDIUM, self::AMOUNT_TIER_LARGE])) { + return false; + } + + if ($user && $this->submitted_by_user_id && $this->submitted_by_user_id === $user->id) { + return false; + } + + return true; } /** - * Check if document can be approved by chair + * 新工作流程:董理事會可審核 + * 條件:理事長已核准 + 大額 */ - public function canBeApprovedByChair(): bool + public function canBeApprovedByBoard(?User $user = null): bool { - return $this->status === self::STATUS_APPROVED_ACCOUNTANT; + $tier = $this->amount_tier ?? $this->determineAmountTier(); + + if ($this->status !== self::STATUS_APPROVED_CHAIR) { + return false; + } + + if ($tier !== self::AMOUNT_TIER_LARGE) { + return false; + } + + return true; } /** - * Check if document is fully approved + * 新工作流程:審核是否完成 + * 依金額級別判斷 + */ + public function isApprovalComplete(): bool + { + $tier = $this->amount_tier ?? $this->determineAmountTier(); + + // 小額:秘書長核准即可 + if ($tier === self::AMOUNT_TIER_SMALL) { + return $this->status === self::STATUS_APPROVED_SECRETARY; + } + + // 中額:理事長核准 + if ($tier === self::AMOUNT_TIER_MEDIUM) { + return $this->status === self::STATUS_APPROVED_CHAIR; + } + + // 大額:董理事會核准 + return $this->status === self::STATUS_APPROVED_BOARD; + } + + /** + * Check if document is fully approved (alias for isApprovalComplete) */ public function isFullyApproved(): bool { - return $this->status === self::STATUS_APPROVED_CHAIR; + return $this->isApprovalComplete(); + } + + // ========== 出帳階段方法 ========== + + /** + * 申請人可確認出帳 + * 條件:審核完成 + 尚未確認 + 是原申請人 + */ + public function canRequesterConfirmDisbursement(?User $user = null): bool + { + if (!$this->isApprovalComplete()) { + return false; + } + + if ($this->requester_confirmed_at !== null) { + return false; + } + + // 只有原申請人可以確認 + if ($user && $this->submitted_by_user_id !== $user->id) { + return false; + } + + return true; + } + + /** + * 出納可確認出帳 + * 條件:審核完成 + 尚未確認 + */ + public function canCashierConfirmDisbursement(): bool + { + if (!$this->isApprovalComplete()) { + return false; + } + + if ($this->cashier_confirmed_at !== null) { + return false; + } + + return true; + } + + /** + * 出帳是否完成(雙重確認) + */ + public function isDisbursementComplete(): bool + { + return $this->requester_confirmed_at !== null + && $this->cashier_confirmed_at !== null; + } + + // ========== 入帳階段方法 ========== + + /** + * 會計可入帳 + * 條件:出帳完成 + 尚未入帳 + */ + public function canAccountantConfirmRecording(): bool + { + return $this->isDisbursementComplete() + && $this->accountant_recorded_at === null; + } + + /** + * 入帳是否完成 + */ + public function isRecordingComplete(): bool + { + return $this->accountant_recorded_at !== null; + } + + // ========== Legacy methods for backward compatibility ========== + + /** + * @deprecated Use canBeApprovedBySecretary instead + */ + public function canBeApprovedByCashier(?User $user = null): bool + { + return $this->canBeApprovedBySecretary($user); + } + + /** + * @deprecated Use isApprovalComplete with amount tier logic + */ + public function canBeApprovedByAccountant(): bool + { + // Legacy: accountant approval after cashier + return $this->status === self::STATUS_APPROVED_CASHIER; } /** @@ -235,20 +549,87 @@ class FinanceDocument extends Model } /** - * Get human-readable status + * Get human-readable status (中文) */ public function getStatusLabelAttribute(): string { return match($this->status) { - self::STATUS_PENDING => 'Pending Cashier Approval', - self::STATUS_APPROVED_CASHIER => 'Pending Accountant Approval', - self::STATUS_APPROVED_ACCOUNTANT => 'Pending Chair Approval', - self::STATUS_APPROVED_CHAIR => 'Fully Approved', - self::STATUS_REJECTED => 'Rejected', + self::STATUS_PENDING => '待審核', + self::STATUS_APPROVED_SECRETARY => '秘書長已核准', + self::STATUS_APPROVED_CHAIR => '理事長已核准', + self::STATUS_APPROVED_BOARD => '董理事會已核准', + self::STATUS_REJECTED => '已駁回', + // Legacy statuses + self::STATUS_APPROVED_CASHIER => '出納已審核', + self::STATUS_APPROVED_ACCOUNTANT => '會計已審核', default => ucfirst($this->status), }; } + /** + * Get disbursement status label (中文) + */ + public function getDisbursementStatusLabelAttribute(): string + { + if (!$this->isApprovalComplete()) { + return '審核中'; + } + + if ($this->isDisbursementComplete()) { + return '已出帳'; + } + + if ($this->requester_confirmed_at !== null && $this->cashier_confirmed_at === null) { + return '申請人已確認,待出納確認'; + } + + if ($this->requester_confirmed_at === null && $this->cashier_confirmed_at !== null) { + return '出納已確認,待申請人確認'; + } + + return '待出帳'; + } + + /** + * Get recording status label (中文) + */ + public function getRecordingStatusLabelAttribute(): string + { + if (!$this->isDisbursementComplete()) { + return '尚未出帳'; + } + + if ($this->accountant_recorded_at !== null) { + return '已入帳'; + } + + return '待入帳'; + } + + /** + * Get overall workflow stage label (中文) + */ + public function getWorkflowStageLabelAttribute(): string + { + if ($this->isRejected()) { + return '已駁回'; + } + + if (!$this->isApprovalComplete()) { + return '審核階段'; + } + + if (!$this->isDisbursementComplete()) { + return '出帳階段'; + } + + if (!$this->isRecordingComplete()) { + return '入帳階段'; + } + + return '已完成'; + } + /** * New payment stage business logic methods */ @@ -277,29 +658,12 @@ class FinanceDocument extends Model } /** - * Check if approval stage is complete (ready for payment order creation) + * Check if approval stage is complete (ready for disbursement) + * 新工作流程:使用 isApprovalComplete() */ public function isApprovalStageComplete(): bool { - $tier = $this->amount_tier ?? $this->determineAmountTier(); - - // For small amounts: cashier + accountant - if ($tier === self::AMOUNT_TIER_SMALL) { - return $this->status === self::STATUS_APPROVED_ACCOUNTANT; - } - - // For medium amounts: cashier + accountant + chair - if ($tier === self::AMOUNT_TIER_MEDIUM) { - return $this->status === self::STATUS_APPROVED_CHAIR; - } - - // For large amounts: cashier + accountant + chair + board meeting - if ($tier === self::AMOUNT_TIER_LARGE) { - return $this->status === self::STATUS_APPROVED_CHAIR && - $this->board_meeting_approved_at !== null; - } - - return false; + return $this->isApprovalComplete(); } /** @@ -341,21 +705,13 @@ class FinanceDocument extends Model return $this->payment_executed_at !== null; } - /** - * Check if recording stage is complete - */ - public function isRecordingComplete(): bool - { - return $this->cashier_recorded_at !== null; - } - /** * Check if document is fully processed (all stages complete) */ public function isFullyProcessed(): bool { - return $this->isApprovalStageComplete() && - $this->isPaymentCompleted() && + return $this->isApprovalComplete() && + $this->isDisbursementComplete() && $this->isRecordingComplete(); } @@ -425,20 +781,6 @@ class FinanceDocument extends Model $this->attributes['approved_by_board_meeting_id'] = $value; } - /** - * Get request type text - */ - public function getRequestTypeText(): string - { - return match ($this->request_type) { - self::REQUEST_TYPE_EXPENSE_REIMBURSEMENT => '費用報銷', - self::REQUEST_TYPE_ADVANCE_PAYMENT => '預支款項', - self::REQUEST_TYPE_PURCHASE_REQUEST => '採購申請', - self::REQUEST_TYPE_PETTY_CASH => '零用金', - default => '未知', - }; - } - /** * Get amount tier text */ diff --git a/app/Models/Income.php b/app/Models/Income.php new file mode 100644 index 0000000..0a91910 --- /dev/null +++ b/app/Models/Income.php @@ -0,0 +1,446 @@ + 'date', + 'amount' => 'decimal:2', + 'recorded_at' => 'datetime', + 'confirmed_at' => 'datetime', + ]; + + /** + * Boot 方法 - 自動產生收入編號 + */ + protected static function boot() + { + parent::boot(); + + static::creating(function ($income) { + if (empty($income->income_number)) { + $income->income_number = self::generateIncomeNumber(); + } + if (empty($income->recorded_at)) { + $income->recorded_at = now(); + } + }); + } + + /** + * 產生收入編號 INC-2025-0001 + */ + public static function generateIncomeNumber(): string + { + $year = date('Y'); + $prefix = "INC-{$year}-"; + + $lastIncome = self::where('income_number', 'like', "{$prefix}%") + ->orderBy('income_number', 'desc') + ->first(); + + if ($lastIncome) { + $lastNumber = (int) substr($lastIncome->income_number, -4); + $newNumber = $lastNumber + 1; + } else { + $newNumber = 1; + } + + return $prefix . str_pad($newNumber, 4, '0', STR_PAD_LEFT); + } + + // ========== 關聯 ========== + + /** + * 會計科目 + */ + public function chartOfAccount(): BelongsTo + { + return $this->belongsTo(ChartOfAccount::class); + } + + /** + * 關聯會員 + */ + public function member(): BelongsTo + { + return $this->belongsTo(Member::class); + } + + /** + * 記錄的出納人員 + */ + public function recordedByCashier(): BelongsTo + { + return $this->belongsTo(User::class, 'recorded_by_cashier_id'); + } + + /** + * 確認的會計人員 + */ + public function confirmedByAccountant(): BelongsTo + { + return $this->belongsTo(User::class, 'confirmed_by_accountant_id'); + } + + /** + * 關聯的出納日記帳 + */ + public function cashierLedgerEntry(): BelongsTo + { + return $this->belongsTo(CashierLedgerEntry::class); + } + + /** + * 會計分錄 + */ + public function accountingEntries(): HasMany + { + return $this->hasMany(AccountingEntry::class); + } + + // ========== 狀態查詢 ========== + + /** + * 是否待確認 + */ + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + /** + * 是否已確認 + */ + public function isConfirmed(): bool + { + return $this->status === self::STATUS_CONFIRMED; + } + + /** + * 是否已取消 + */ + public function isCancelled(): bool + { + return $this->status === self::STATUS_CANCELLED; + } + + /** + * 是否可以被會計確認 + */ + public function canBeConfirmed(): bool + { + return $this->status === self::STATUS_PENDING; + } + + /** + * 是否可以被取消 + */ + public function canBeCancelled(): bool + { + return $this->status === self::STATUS_PENDING; + } + + // ========== 業務方法 ========== + + /** + * 會計確認收入 + */ + public function confirmByAccountant(User $accountant): void + { + if (!$this->canBeConfirmed()) { + throw new \Exception('此收入無法確認'); + } + + DB::transaction(function () use ($accountant) { + // 1. 更新收入狀態 + $this->update([ + 'status' => self::STATUS_CONFIRMED, + 'confirmed_by_accountant_id' => $accountant->id, + 'confirmed_at' => now(), + ]); + + // 2. 產生出納日記帳記錄 + $ledgerEntry = $this->createCashierLedgerEntry(); + + // 3. 產生會計分錄 + $this->generateAccountingEntries(); + }); + } + + /** + * 取消收入 + */ + public function cancel(): void + { + if (!$this->canBeCancelled()) { + throw new \Exception('此收入無法取消'); + } + + $this->update([ + 'status' => self::STATUS_CANCELLED, + ]); + } + + /** + * 建立出納日記帳記錄 + */ + protected function createCashierLedgerEntry(): CashierLedgerEntry + { + $bankAccount = $this->bank_account ?? 'Main Account'; + $balanceBefore = CashierLedgerEntry::getLatestBalance($bankAccount); + + $ledgerEntry = CashierLedgerEntry::create([ + 'entry_date' => $this->income_date, + 'entry_type' => CashierLedgerEntry::ENTRY_TYPE_RECEIPT, + 'payment_method' => $this->payment_method, + 'bank_account' => $bankAccount, + 'amount' => $this->amount, + 'balance_before' => $balanceBefore, + 'balance_after' => $balanceBefore + $this->amount, + 'receipt_number' => $this->receipt_number, + 'transaction_reference' => $this->transaction_reference, + 'recorded_by_cashier_id' => $this->recorded_by_cashier_id, + 'recorded_at' => now(), + 'notes' => "收入確認:{$this->title} ({$this->income_number})", + ]); + + $this->update(['cashier_ledger_entry_id' => $ledgerEntry->id]); + + return $ledgerEntry; + } + + /** + * 產生會計分錄 + */ + protected function generateAccountingEntries(): void + { + // 借方:資產帳戶(現金或銀行存款) + $assetAccountId = $this->getAssetAccountId(); + + AccountingEntry::create([ + 'income_id' => $this->id, + 'chart_of_account_id' => $assetAccountId, + 'entry_type' => AccountingEntry::ENTRY_TYPE_DEBIT, + 'amount' => $this->amount, + 'entry_date' => $this->income_date, + 'description' => "收入:{$this->title} ({$this->income_number})", + ]); + + // 貸方:收入科目 + AccountingEntry::create([ + 'income_id' => $this->id, + 'chart_of_account_id' => $this->chart_of_account_id, + 'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT, + 'amount' => $this->amount, + 'entry_date' => $this->income_date, + 'description' => "收入:{$this->title} ({$this->income_number})", + ]); + } + + /** + * 根據付款方式取得資產帳戶 ID + */ + protected function getAssetAccountId(): int + { + $accountCode = match ($this->payment_method) { + self::PAYMENT_METHOD_BANK_TRANSFER => '1201', // 銀行存款 + self::PAYMENT_METHOD_CHECK => '1201', // 銀行存款 + default => '1101', // 現金 + }; + + return ChartOfAccount::where('account_code', $accountCode)->value('id') ?? 1; + } + + // ========== 文字取得 ========== + + /** + * 取得收入類型文字 + */ + public function getIncomeTypeText(): string + { + return match ($this->income_type) { + self::TYPE_MEMBERSHIP_FEE => '會費收入', + self::TYPE_ENTRANCE_FEE => '入會費收入', + self::TYPE_DONATION => '捐款收入', + self::TYPE_ACTIVITY => '活動收入', + self::TYPE_GRANT => '補助收入', + self::TYPE_INTEREST => '利息收入', + self::TYPE_OTHER => '其他收入', + default => '未知', + }; + } + + /** + * 取得狀態文字 + */ + public function getStatusText(): string + { + return match ($this->status) { + self::STATUS_PENDING => '待確認', + self::STATUS_CONFIRMED => '已確認', + self::STATUS_CANCELLED => '已取消', + default => '未知', + }; + } + + /** + * 取得付款方式文字 + */ + public function getPaymentMethodText(): string + { + return match ($this->payment_method) { + self::PAYMENT_METHOD_CASH => '現金', + self::PAYMENT_METHOD_BANK_TRANSFER => '銀行轉帳', + self::PAYMENT_METHOD_CHECK => '支票', + default => '未知', + }; + } + + /** + * 取得狀態標籤屬性 + */ + public function getStatusLabelAttribute(): string + { + return $this->getStatusText(); + } + + // ========== 收入類型與科目對應 ========== + + /** + * 取得收入類型對應的預設會計科目代碼 + */ + public static function getDefaultAccountCode(string $incomeType): string + { + return match ($incomeType) { + self::TYPE_MEMBERSHIP_FEE => '4101', + self::TYPE_ENTRANCE_FEE => '4102', + self::TYPE_DONATION => '4201', + self::TYPE_ACTIVITY => '4402', + self::TYPE_GRANT => '4301', + self::TYPE_INTEREST => '4401', + self::TYPE_OTHER => '4901', + default => '4901', + }; + } + + /** + * 取得收入類型對應的預設會計科目 ID + */ + public static function getDefaultAccountId(string $incomeType): ?int + { + $accountCode = self::getDefaultAccountCode($incomeType); + return ChartOfAccount::where('account_code', $accountCode)->value('id'); + } + + /** + * 靜態方法:取得收入類型文字標籤 + */ + public static function getIncomeTypeLabel(string $incomeType): string + { + return match ($incomeType) { + self::TYPE_MEMBERSHIP_FEE => '會費收入', + self::TYPE_ENTRANCE_FEE => '入會費收入', + self::TYPE_DONATION => '捐款收入', + self::TYPE_ACTIVITY => '活動收入', + self::TYPE_GRANT => '補助收入', + self::TYPE_INTEREST => '利息收入', + self::TYPE_OTHER => '其他收入', + default => '未知', + }; + } + + // ========== 查詢範圍 ========== + + /** + * 篩選待確認的收入 + */ + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + /** + * 篩選已確認的收入 + */ + public function scopeConfirmed($query) + { + return $query->where('status', self::STATUS_CONFIRMED); + } + + /** + * 篩選特定收入類型 + */ + public function scopeOfType($query, string $type) + { + return $query->where('income_type', $type); + } + + /** + * 篩選特定會員 + */ + public function scopeForMember($query, int $memberId) + { + return $query->where('member_id', $memberId); + } + + /** + * 篩選日期範圍 + */ + public function scopeDateRange($query, $startDate, $endDate) + { + return $query->whereBetween('income_date', [$startDate, $endDate]); + } +} diff --git a/app/Models/Member.php b/app/Models/Member.php index debf6d0..7e86a1d 100644 --- a/app/Models/Member.php +++ b/app/Models/Member.php @@ -22,6 +22,11 @@ class Member extends Model const TYPE_LIFETIME = 'lifetime'; const TYPE_STUDENT = 'student'; + // Disability certificate status constants + const DISABILITY_STATUS_PENDING = 'pending'; + const DISABILITY_STATUS_APPROVED = 'approved'; + const DISABILITY_STATUS_REJECTED = 'rejected'; + protected $fillable = [ 'user_id', 'full_name', @@ -39,11 +44,17 @@ class Member extends Model 'membership_expires_at', 'membership_status', 'membership_type', + 'disability_certificate_path', + 'disability_certificate_status', + 'disability_verified_by', + 'disability_verified_at', + 'disability_rejection_reason', ]; protected $casts = [ 'membership_started_at' => 'date', 'membership_expires_at' => 'date', + 'disability_verified_at' => 'datetime', ]; protected $appends = ['national_id']; @@ -58,6 +69,37 @@ class Member extends Model return $this->hasMany(MembershipPayment::class); } + /** + * 關聯的收入記錄 + */ + public function incomes() + { + return $this->hasMany(Income::class); + } + + /** + * 取得會員的會費收入記錄 + */ + public function getMembershipFeeIncomes() + { + return $this->incomes() + ->whereIn('income_type', [ + Income::TYPE_MEMBERSHIP_FEE, + Income::TYPE_ENTRANCE_FEE + ]) + ->get(); + } + + /** + * 取得會員的總收入金額 + */ + public function getTotalIncomeAttribute(): float + { + return $this->incomes() + ->where('status', Income::STATUS_CONFIRMED) + ->sum('amount'); + } + /** * Get the decrypted national ID */ @@ -203,4 +245,120 @@ class Member extends Model // Can submit if pending status and no pending payment return $this->isPending() && !$this->getPendingPayment(); } + + // ========== 身心障礙相關 ========== + + /** + * 身心障礙手冊審核人 + */ + public function disabilityVerifiedBy() + { + return $this->belongsTo(User::class, 'disability_verified_by'); + } + + /** + * 是否有上傳身心障礙手冊 + */ + public function hasDisabilityCertificate(): bool + { + return !empty($this->disability_certificate_path); + } + + /** + * 身心障礙手冊是否待審核 + */ + public function isDisabilityPending(): bool + { + return $this->disability_certificate_status === self::DISABILITY_STATUS_PENDING; + } + + /** + * 身心障礙手冊是否已通過審核 + */ + public function hasApprovedDisability(): bool + { + return $this->disability_certificate_status === self::DISABILITY_STATUS_APPROVED; + } + + /** + * 身心障礙手冊是否被駁回 + */ + public function isDisabilityRejected(): bool + { + return $this->disability_certificate_status === self::DISABILITY_STATUS_REJECTED; + } + + /** + * 取得身心障礙狀態標籤 + */ + public function getDisabilityStatusLabelAttribute(): string + { + if (!$this->hasDisabilityCertificate()) { + return '未上傳'; + } + + return match ($this->disability_certificate_status) { + self::DISABILITY_STATUS_PENDING => '審核中', + self::DISABILITY_STATUS_APPROVED => '已通過', + self::DISABILITY_STATUS_REJECTED => '已駁回', + default => '未知', + }; + } + + /** + * 取得身心障礙狀態的 Badge 樣式 + */ + public function getDisabilityStatusBadgeAttribute(): string + { + if (!$this->hasDisabilityCertificate()) { + return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200'; + } + + return match ($this->disability_certificate_status) { + self::DISABILITY_STATUS_PENDING => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + self::DISABILITY_STATUS_APPROVED => 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + self::DISABILITY_STATUS_REJECTED => 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', + default => 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200', + }; + } + + /** + * 審核通過身心障礙手冊 + */ + public function approveDisabilityCertificate(User $verifier): void + { + $this->update([ + 'disability_certificate_status' => self::DISABILITY_STATUS_APPROVED, + 'disability_verified_by' => $verifier->id, + 'disability_verified_at' => now(), + 'disability_rejection_reason' => null, + ]); + } + + /** + * 駁回身心障礙手冊 + */ + public function rejectDisabilityCertificate(User $verifier, string $reason): void + { + $this->update([ + 'disability_certificate_status' => self::DISABILITY_STATUS_REJECTED, + 'disability_verified_by' => $verifier->id, + 'disability_verified_at' => now(), + 'disability_rejection_reason' => $reason, + ]); + } + + /** + * 判斷下一次應繳哪種會費 + */ + public function getNextFeeType(): string + { + // 新會員(從未啟用過)= 入會會費 + if ($this->membership_started_at === null) { + return MembershipPayment::FEE_TYPE_ENTRANCE; + } + + // 已有會籍 = 常年會費 + return MembershipPayment::FEE_TYPE_ANNUAL; + } } diff --git a/app/Models/MembershipPayment.php b/app/Models/MembershipPayment.php index 1ec360f..3fe804e 100644 --- a/app/Models/MembershipPayment.php +++ b/app/Models/MembershipPayment.php @@ -23,10 +23,19 @@ class MembershipPayment extends Model const METHOD_CASH = 'cash'; const METHOD_CREDIT_CARD = 'credit_card'; + // Fee type constants + const FEE_TYPE_ENTRANCE = 'entrance_fee'; // 入會會費 + const FEE_TYPE_ANNUAL = 'annual_fee'; // 常年會費 + protected $fillable = [ 'member_id', + 'fee_type', 'paid_at', 'amount', + 'base_amount', + 'discount_amount', + 'final_amount', + 'disability_discount', 'method', 'reference', 'status', @@ -51,6 +60,10 @@ class MembershipPayment extends Model 'accountant_verified_at' => 'datetime', 'chair_verified_at' => 'datetime', 'rejected_at' => 'datetime', + 'base_amount' => 'decimal:2', + 'discount_amount' => 'decimal:2', + 'final_amount' => 'decimal:2', + 'disability_discount' => 'boolean', ]; // Relationships @@ -151,6 +164,36 @@ class MembershipPayment extends Model }; } + // Accessor for fee type label + public function getFeeTypeLabelAttribute(): string + { + return match($this->fee_type) { + self::FEE_TYPE_ENTRANCE => '入會會費', + self::FEE_TYPE_ANNUAL => '常年會費', + default => $this->fee_type ?? '未指定', + }; + } + + /** + * 是否有使用身心障礙優惠 + */ + public function hasDisabilityDiscount(): bool + { + return (bool) $this->disability_discount; + } + + /** + * 取得折扣說明 + */ + public function getDiscountDescriptionAttribute(): ?string + { + if (!$this->hasDisabilityDiscount()) { + return null; + } + + return '身心障礙優惠 50%'; + } + // Clean up receipt file when payment is deleted protected static function boot() { diff --git a/app/Models/PaymentOrder.php b/app/Models/PaymentOrder.php index f2f72a6..e3dba1d 100644 --- a/app/Models/PaymentOrder.php +++ b/app/Models/PaymentOrder.php @@ -67,7 +67,7 @@ class PaymentOrder extends Model const PAYMENT_METHOD_CASH = 'cash'; /** - * 關聯到財務申請單 + * 關聯到報銷申請單 */ public function financeDocument(): BelongsTo { diff --git a/app/Models/User.php b/app/Models/User.php index b60ec00..d364e45 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -23,7 +23,6 @@ class User extends Authenticatable protected $fillable = [ 'name', 'email', - 'is_admin', 'profile_photo_path', 'password', ]; @@ -46,7 +45,6 @@ class User extends Authenticatable protected $casts = [ 'email_verified_at' => 'datetime', 'password' => 'hashed', - 'is_admin' => 'boolean', ]; public function member(): HasOne @@ -54,6 +52,15 @@ class User extends Authenticatable return $this->hasOne(Member::class); } + /** + * 檢查使用者是否為管理員 + * 使用 Spatie Permission 的 admin 角色取代舊版 is_admin 欄位 + */ + public function isAdmin(): bool + { + return $this->hasRole('admin'); + } + public function profilePhotoUrl(): ?string { if (! $this->profile_photo_path) { diff --git a/app/Services/MembershipFeeCalculator.php b/app/Services/MembershipFeeCalculator.php new file mode 100644 index 0000000..da889b3 --- /dev/null +++ b/app/Services/MembershipFeeCalculator.php @@ -0,0 +1,154 @@ +settings = $settings; + } + + /** + * 計算會費金額 + * + * @param Member $member 會員 + * @param string $feeType 會費類型 (entrance_fee | annual_fee) + * @return array{base_amount: float, discount_amount: float, final_amount: float, disability_discount: bool, fee_type: string} + */ + public function calculate(Member $member, string $feeType): array + { + $baseAmount = $this->getBaseAmount($feeType); + $discountRate = $member->hasApprovedDisability() + ? $this->getDisabilityDiscountRate() + : 0; + + $discountAmount = round($baseAmount * $discountRate, 2); + $finalAmount = round($baseAmount - $discountAmount, 2); + + return [ + 'fee_type' => $feeType, + 'base_amount' => $baseAmount, + 'discount_amount' => $discountAmount, + 'final_amount' => $finalAmount, + 'disability_discount' => $discountRate > 0, + ]; + } + + /** + * 為會員計算下一次應繳的會費 + * + * @param Member $member + * @return array{base_amount: float, discount_amount: float, final_amount: float, disability_discount: bool, fee_type: string} + */ + public function calculateNextFee(Member $member): array + { + $feeType = $member->getNextFeeType(); + return $this->calculate($member, $feeType); + } + + /** + * 取得基本會費金額 + * + * @param string $feeType + * @return float + */ + public function getBaseAmount(string $feeType): float + { + return match($feeType) { + MembershipPayment::FEE_TYPE_ENTRANCE => $this->getEntranceFee(), + MembershipPayment::FEE_TYPE_ANNUAL => $this->getAnnualFee(), + default => 0, + }; + } + + /** + * 取得入會會費金額 + * + * @return float + */ + public function getEntranceFee(): float + { + return (float) $this->settings->get('membership_fee.entrance_fee', 1000); + } + + /** + * 取得常年會費金額 + * + * @return float + */ + public function getAnnualFee(): float + { + return (float) $this->settings->get('membership_fee.annual_fee', 1000); + } + + /** + * 取得身心障礙折扣比例 + * + * @return float 折扣比例 (0-1) + */ + public function getDisabilityDiscountRate(): float + { + return (float) $this->settings->get('membership_fee.disability_discount_rate', 0.5); + } + + /** + * 取得身心障礙折扣百分比 (用於顯示) + * + * @return int 百分比 (0-100) + */ + public function getDisabilityDiscountPercentage(): int + { + return (int) ($this->getDisabilityDiscountRate() * 100); + } + + /** + * 驗證繳費金額是否正確 + * + * @param MembershipPayment $payment + * @return bool + */ + public function validatePaymentAmount(MembershipPayment $payment): bool + { + $member = $payment->member; + $expected = $this->calculate($member, $payment->fee_type); + + // 繳費金額應等於或大於應繳金額 + return $payment->amount >= $expected['final_amount']; + } + + /** + * 取得會費類型的標籤 + * + * @param string $feeType + * @return string + */ + public function getFeeTypeLabel(string $feeType): string + { + return match($feeType) { + MembershipPayment::FEE_TYPE_ENTRANCE => '入會會費', + MembershipPayment::FEE_TYPE_ANNUAL => '常年會費', + default => $feeType, + }; + } + + /** + * 取得所有會費設定(用於管理界面) + * + * @return array + */ + public function getFeeSettings(): array + { + return [ + 'entrance_fee' => $this->getEntranceFee(), + 'annual_fee' => $this->getAnnualFee(), + 'disability_discount_rate' => $this->getDisabilityDiscountRate(), + 'disability_discount_percentage' => $this->getDisabilityDiscountPercentage(), + ]; + } +} diff --git a/composer.json b/composer.json index b7e069f..a0ededd 100644 --- a/composer.json +++ b/composer.json @@ -11,12 +11,14 @@ "laravel/framework": "^10.10", "laravel/sanctum": "^3.3", "laravel/tinker": "^2.8", + "maatwebsite/excel": "^3.1", "simplesoftwareio/simple-qrcode": "^4.2", "spatie/laravel-permission": "^6.23" }, "require-dev": { "fakerphp/faker": "^1.9.1", "laravel/breeze": "^1.29", + "laravel/dusk": "^8.3", "laravel/pint": "^1.0", "laravel/sail": "^1.18", "mockery/mockery": "^1.4.4", diff --git a/composer.lock b/composer.lock index 350648f..71639be 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "823199d76778549dda38d7d7c8a1967a", + "content-hash": "10268750f724780736201053fb4871bf", "packages": [ { "name": "bacon/bacon-qr-code", @@ -266,6 +266,162 @@ ], "time": "2023-12-11T17:09:12+00:00" }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "dasprid/enum", "version": "1.0.7", @@ -844,6 +1000,67 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.3.0", @@ -2224,6 +2441,272 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "maatwebsite/excel", + "version": "3.1.67", + "source": { + "type": "git", + "url": "https://github.com/SpartnerNL/Laravel-Excel.git", + "reference": "e508e34a502a3acc3329b464dad257378a7edb4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/e508e34a502a3acc3329b464dad257378a7edb4d", + "reference": "e508e34a502a3acc3329b464dad257378a7edb4d", + "shasum": "" + }, + "require": { + "composer/semver": "^3.3", + "ext-json": "*", + "illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0", + "php": "^7.0||^8.0", + "phpoffice/phpspreadsheet": "^1.30.0", + "psr/simple-cache": "^1.0||^2.0||^3.0" + }, + "require-dev": { + "laravel/scout": "^7.0||^8.0||^9.0||^10.0", + "orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0", + "predis/predis": "^1.1" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Excel": "Maatwebsite\\Excel\\Facades\\Excel" + }, + "providers": [ + "Maatwebsite\\Excel\\ExcelServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Maatwebsite\\Excel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Patrick Brouwers", + "email": "patrick@spartner.nl" + } + ], + "description": "Supercharged Excel exports and imports in Laravel", + "keywords": [ + "PHPExcel", + "batch", + "csv", + "excel", + "export", + "import", + "laravel", + "php", + "phpspreadsheet" + ], + "support": { + "issues": "https://github.com/SpartnerNL/Laravel-Excel/issues", + "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.67" + }, + "funding": [ + { + "url": "https://laravel-excel.com/commercial-support", + "type": "custom" + }, + { + "url": "https://github.com/patrickbrouwers", + "type": "github" + } + ], + "time": "2025-08-26T09:13:16+00:00" + }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416", + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.16", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-07-17T11:15:13+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, { "name": "masterminds/html5", "version": "2.10.0", @@ -2798,6 +3281,112 @@ ], "time": "2024-11-21T10:36:35+00:00" }, + { + "name": "phpoffice/phpspreadsheet", + "version": "1.30.1", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "fa8257a579ec623473eabfe49731de5967306c4c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/fa8257a579ec623473eabfe49731de5967306c4c", + "reference": "fa8257a579ec623473eabfe49731de5967306c4c", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "ezyang/htmlpurifier": "^4.15", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": ">=7.4.0 <8.5.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^1.0 || ^2.0 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.3", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^8.5 || ^9.0", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.1" + }, + "time": "2025-10-26T16:01:04+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.4", @@ -6499,6 +7088,80 @@ }, "time": "2024-03-04T14:35:21+00:00" }, + { + "name": "laravel/dusk", + "version": "v8.3.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/dusk.git", + "reference": "33a4211c7b63ffe430bf30ec3c014012dcb6dfa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/dusk/zipball/33a4211c7b63ffe430bf30ec3c014012dcb6dfa6", + "reference": "33a4211c7b63ffe430bf30ec3c014012dcb6dfa6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-zip": "*", + "guzzlehttp/guzzle": "^7.5", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1", + "php-webdriver/webdriver": "^1.15.2", + "symfony/console": "^6.2|^7.0", + "symfony/finder": "^6.2|^7.0", + "symfony/process": "^6.2|^7.0", + "vlucas/phpdotenv": "^5.2" + }, + "require-dev": { + "laravel/framework": "^10.0|^11.0|^12.0", + "mockery/mockery": "^1.6", + "orchestra/testbench-core": "^8.19|^9.17|^10.8", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.1|^11.0|^12.0.1", + "psy/psysh": "^0.11.12|^0.12", + "symfony/yaml": "^6.2|^7.0" + }, + "suggest": { + "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Dusk\\DuskServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Dusk\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Dusk provides simple end-to-end testing and browser automation.", + "keywords": [ + "laravel", + "testing", + "webdriver" + ], + "support": { + "issues": "https://github.com/laravel/dusk/issues", + "source": "https://github.com/laravel/dusk/tree/v8.3.4" + }, + "time": "2025-11-20T16:26:16+00:00" + }, { "name": "laravel/pint", "version": "v1.25.1", @@ -6985,6 +7648,72 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "php-webdriver/webdriver", + "version": "1.15.2", + "source": { + "type": "git", + "url": "https://github.com/php-webdriver/php-webdriver.git", + "reference": "998e499b786805568deaf8cbf06f4044f05d91bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/998e499b786805568deaf8cbf06f4044f05d91bf", + "reference": "998e499b786805568deaf8cbf06f4044f05d91bf", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-zip": "*", + "php": "^7.3 || ^8.0", + "symfony/polyfill-mbstring": "^1.12", + "symfony/process": "^5.0 || ^6.0 || ^7.0" + }, + "replace": { + "facebook/webdriver": "*" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.20.0", + "ondram/ci-detector": "^4.0", + "php-coveralls/php-coveralls": "^2.4", + "php-mock/php-mock-phpunit": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^9.3", + "squizlabs/php_codesniffer": "^3.5", + "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "ext-SimpleXML": "For Firefox profile creation" + }, + "type": "library", + "autoload": { + "files": [ + "lib/Exception/TimeoutException.php" + ], + "psr-4": { + "Facebook\\WebDriver\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", + "homepage": "https://github.com/php-webdriver/php-webdriver", + "keywords": [ + "Chromedriver", + "geckodriver", + "php", + "selenium", + "webdriver" + ], + "support": { + "issues": "https://github.com/php-webdriver/php-webdriver/issues", + "source": "https://github.com/php-webdriver/php-webdriver/tree/1.15.2" + }, + "time": "2024-11-21T15:12:59+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "10.1.16", @@ -8878,12 +9607,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.1" }, - "platform-dev": {}, - "plugin-api-version": "2.9.0" + "platform-dev": [], + "plugin-api-version": "2.6.0" } diff --git a/config/accounting_mapping.php b/config/accounting_mapping.php new file mode 100644 index 0000000..637ca1f --- /dev/null +++ b/config/accounting_mapping.php @@ -0,0 +1,161 @@ + [ + // Assets (1000 series) + '1000' => '1101', // 現金及約當現金 → 現金 + '1100' => '1102', // 庫存現金 → 零用金 + '1101' => '1201', // 銀行存款 → 銀行存款 + '1107' => '1201', // 其他現金等價物 → 銀行存款 + + // Income (4000 series) + '4100' => '4201', // 一般捐款收入 → 捐贈收入 + '4310' => '4102', // 入會費 → 入會費收入 + '4101' => '4101', // 會費收入 → 會費收入 (direct match) + + // Expenses (5000 series) + // 5100 業務費用需要根據描述細分,見下方 expense_keywords + ], + + /* + |-------------------------------------------------------------------------- + | Expense Classification Keywords + |-------------------------------------------------------------------------- + | + | Excel 中的 5100 業務費用需要根據描述關鍵字分類到具體科目 + | + */ + + 'expense_keywords' => [ + // 5206 - 旅運費 + [ + 'keywords' => ['交通', '車費', '計程車', '高鐵', '台鐵', '客運', '機票', '油資', '停車'], + 'account_code' => '5206', + 'account_name' => '旅運費', + ], + + // 5209 - 會議費 + [ + 'keywords' => ['會場', '場地', '清潔', '點心', '餐', '便當', '茶水', '會議'], + 'account_code' => '5209', + 'account_name' => '會議費', + ], + + // 5203 - 郵電費 + [ + 'keywords' => ['郵寄', '郵資', '郵票', '快遞', '宅配', '電話', '網路', '通訊', '電信'], + 'account_code' => '5203', + 'account_name' => '郵電費', + ], + + // 5205 - 印刷費 + [ + 'keywords' => ['影印', '列印', '印刷', '裝訂', '海報', '傳單', '文宣'], + 'account_code' => '5205', + 'account_name' => '印刷費', + ], + + // 5204 - 文具用品 + [ + 'keywords' => ['文具', '用品', '紙張', '筆', '資料夾', '辦公用品'], + 'account_code' => '5204', + 'account_name' => '文具用品', + ], + + // 5202 - 水電費 + [ + 'keywords' => ['水費', '電費', '水電'], + 'account_code' => '5202', + 'account_name' => '水電費', + ], + + // 5201 - 租金支出 + [ + 'keywords' => ['租金', '房租', '場租'], + 'account_code' => '5201', + 'account_name' => '租金支出', + ], + + // 5208 - 修繕費 + [ + 'keywords' => ['修繕', '維修', '修理', '保養'], + 'account_code' => '5208', + 'account_name' => '修繕費', + ], + + // 5212 - 廣告宣傳費 + [ + 'keywords' => ['廣告', '宣傳', '行銷', '推廣'], + 'account_code' => '5212', + 'account_name' => '廣告宣傳費', + ], + + // 5213 - 專案活動費 + [ + 'keywords' => ['活動', '專案', '講座', '課程', '研習', '訓練'], + 'account_code' => '5213', + 'account_name' => '專案活動費', + ], + + // 5211 - 交際費 + [ + 'keywords' => ['交際', '禮品', '贈品', '紀念品'], + 'account_code' => '5211', + 'account_name' => '交際費', + ], + + // 5304 - 銀行手續費 + [ + 'keywords' => ['手續費', '匯費', '轉帳費'], + 'account_code' => '5304', + 'account_name' => '銀行手續費', + ], + + // 5308 - 資訊系統費 + [ + 'keywords' => ['軟體', '系統', '網站', 'domain', '主機', '雲端'], + 'account_code' => '5308', + 'account_name' => '資訊系統費', + ], + + // Default: 5901 雜項支出 (if no keywords match) + [ + 'keywords' => [], + 'account_code' => '5901', + 'account_name' => '雜項支出', + 'is_default' => true, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Cash Account + |-------------------------------------------------------------------------- + | + | Default cash account for generating journal entries + | + */ + + 'cash_account_code' => '1101', // 現金 + + /* + |-------------------------------------------------------------------------- + | Bank Account + |-------------------------------------------------------------------------- + | + | Default bank account + | + */ + + 'bank_account_code' => '1201', // 銀行存款 +]; diff --git a/database/factories/AuditLogFactory.php b/database/factories/AuditLogFactory.php new file mode 100644 index 0000000..4905952 --- /dev/null +++ b/database/factories/AuditLogFactory.php @@ -0,0 +1,40 @@ + User::factory(), + 'action' => $this->faker->randomElement([ + 'member_created', + 'member_status_changed', + 'payment_approved', + 'payment_rejected', + 'finance_document_created', + 'finance_document_approved', + 'user_login', + 'role_assigned', + ]), + 'auditable_type' => $this->faker->randomElement([ + 'App\Models\Member', + 'App\Models\MembershipPayment', + 'App\Models\FinanceDocument', + 'App\Models\User', + ]), + 'auditable_id' => $this->faker->numberBetween(1, 100), + 'metadata' => [ + 'ip_address' => $this->faker->ipv4(), + 'user_agent' => $this->faker->userAgent(), + ], + ]; + } +} diff --git a/database/factories/BankReconciliationFactory.php b/database/factories/BankReconciliationFactory.php new file mode 100644 index 0000000..7203e7a --- /dev/null +++ b/database/factories/BankReconciliationFactory.php @@ -0,0 +1,32 @@ +faker->numberBetween(50000, 500000); + $bookBalance = $bankBalance + $this->faker->numberBetween(-5000, 5000); + + return [ + 'reconciliation_month' => now()->startOfMonth(), + 'bank_statement_date' => now(), + 'bank_statement_balance' => $bankBalance, + 'system_book_balance' => $bookBalance, + 'outstanding_checks' => [], + 'deposits_in_transit' => [], + 'bank_charges' => [], + 'prepared_by_cashier_id' => User::factory(), + 'prepared_at' => now(), + 'reconciliation_status' => 'pending', + 'discrepancy_amount' => abs($bankBalance - $bookBalance), + ]; + } +} diff --git a/database/factories/BudgetCategoryFactory.php b/database/factories/BudgetCategoryFactory.php new file mode 100644 index 0000000..1644334 --- /dev/null +++ b/database/factories/BudgetCategoryFactory.php @@ -0,0 +1,16 @@ + $this->faker->unique()->words(2, true), + 'description' => $this->faker->sentence(), + ]; + } +} diff --git a/database/factories/ChartOfAccountFactory.php b/database/factories/ChartOfAccountFactory.php new file mode 100644 index 0000000..bc7c85a --- /dev/null +++ b/database/factories/ChartOfAccountFactory.php @@ -0,0 +1,22 @@ + $this->faker->unique()->numerify('####'), + 'name' => $this->faker->words(3, true), + 'type' => $this->faker->randomElement(['asset', 'liability', 'equity', 'revenue', 'expense']), + 'description' => $this->faker->sentence(), + 'is_active' => true, + ]; + } +} diff --git a/database/factories/DocumentCategoryFactory.php b/database/factories/DocumentCategoryFactory.php new file mode 100644 index 0000000..1daa57b --- /dev/null +++ b/database/factories/DocumentCategoryFactory.php @@ -0,0 +1,23 @@ + $this->faker->unique()->words(2, true), + 'slug' => $this->faker->unique()->slug(2), + 'description' => $this->faker->sentence(), + 'icon' => $this->faker->randomElement(['📄', '📁', '📋', '📊', '📈']), + 'sort_order' => $this->faker->numberBetween(1, 100), + 'default_access_level' => $this->faker->randomElement(['public', 'members', 'admin', 'board']), + ]; + } +} diff --git a/database/factories/DocumentFactory.php b/database/factories/DocumentFactory.php new file mode 100644 index 0000000..b184745 --- /dev/null +++ b/database/factories/DocumentFactory.php @@ -0,0 +1,31 @@ + DocumentCategory::factory(), + 'title' => $this->faker->sentence(3), + 'document_number' => 'DOC-'.now()->format('Y').'-'.str_pad(rand(1, 9999), 4, '0', STR_PAD_LEFT), + 'description' => $this->faker->paragraph(), + 'public_uuid' => (string) Str::uuid(), + 'access_level' => $this->faker->randomElement(['public', 'members', 'admin']), + 'status' => 'active', + 'created_by_user_id' => User::factory(), + 'view_count' => $this->faker->numberBetween(0, 100), + 'download_count' => $this->faker->numberBetween(0, 50), + 'version_count' => 1, + ]; + } +} diff --git a/database/migrations/2025_11_28_182012_remove_is_admin_from_users_table.php b/database/migrations/2025_11_28_182012_remove_is_admin_from_users_table.php new file mode 100644 index 0000000..784ca68 --- /dev/null +++ b/database/migrations/2025_11_28_182012_remove_is_admin_from_users_table.php @@ -0,0 +1,30 @@ +dropColumn('is_admin'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->boolean('is_admin')->default(false)->after('email'); + }); + } +}; diff --git a/database/migrations/2025_11_28_231917_create_cashier_ledger_entries_table.php b/database/migrations/2025_11_28_231917_create_cashier_ledger_entries_table.php new file mode 100644 index 0000000..4b2c843 --- /dev/null +++ b/database/migrations/2025_11_28_231917_create_cashier_ledger_entries_table.php @@ -0,0 +1,48 @@ +id(); + $table->foreignId('finance_document_id')->nullable()->constrained()->nullOnDelete(); + $table->date('entry_date'); + $table->string('entry_type'); // receipt, payment + $table->string('payment_method'); // bank_transfer, check, cash + $table->string('bank_account')->nullable(); + $table->decimal('amount', 12, 2); + $table->decimal('balance_before', 12, 2)->default(0); + $table->decimal('balance_after', 12, 2)->default(0); + $table->string('receipt_number')->nullable(); + $table->string('transaction_reference')->nullable(); + $table->foreignId('recorded_by_cashier_id')->constrained('users'); + $table->timestamp('recorded_at'); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index('entry_date'); + $table->index('entry_type'); + $table->index('bank_account'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cashier_ledger_entries'); + } +}; diff --git a/database/migrations/2025_11_29_003312_create_announcements_table.php b/database/migrations/2025_11_29_003312_create_announcements_table.php new file mode 100644 index 0000000..7517664 --- /dev/null +++ b/database/migrations/2025_11_29_003312_create_announcements_table.php @@ -0,0 +1,62 @@ +id(); + + // 基本資訊 + $table->string('title'); + $table->text('content'); + + // 狀態管理 + $table->enum('status', ['draft', 'published', 'archived'])->default('draft'); + + // 顯示控制 + $table->boolean('is_pinned')->default(false); + $table->integer('display_order')->default(0); + + // 訪問控制 + $table->enum('access_level', ['public', 'members', 'board', 'admin'])->default('members'); + + // 時間控制 + $table->timestamp('published_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('archived_at')->nullable(); + + // 統計 + $table->integer('view_count')->default(0); + + // 用戶關聯 + $table->foreignId('created_by_user_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('last_updated_by_user_id')->nullable()->constrained('users')->onDelete('set null'); + + $table->timestamps(); + $table->softDeletes(); + + // 索引 + $table->index('status'); + $table->index('access_level'); + $table->index('published_at'); + $table->index('expires_at'); + $table->index(['is_pinned', 'display_order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('announcements'); + } +}; diff --git a/database/migrations/2025_11_30_153609_create_accounting_entries_table.php b/database/migrations/2025_11_30_153609_create_accounting_entries_table.php new file mode 100644 index 0000000..3ef4640 --- /dev/null +++ b/database/migrations/2025_11_30_153609_create_accounting_entries_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('finance_document_id')->constrained()->onDelete('cascade'); + $table->foreignId('chart_of_account_id')->constrained(); + $table->enum('entry_type', ['debit', 'credit']); + $table->decimal('amount', 15, 2); + $table->date('entry_date'); + $table->text('description')->nullable(); + $table->timestamps(); + + // Indexes for performance + $table->index(['finance_document_id', 'entry_type']); + $table->index(['chart_of_account_id', 'entry_date']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('accounting_entries'); + } +}; diff --git a/database/migrations/2025_11_30_163310_create_board_meetings_table.php b/database/migrations/2025_11_30_163310_create_board_meetings_table.php new file mode 100644 index 0000000..5a047b9 --- /dev/null +++ b/database/migrations/2025_11_30_163310_create_board_meetings_table.php @@ -0,0 +1,31 @@ +id(); + $table->date('meeting_date'); + $table->string('title'); + $table->text('notes')->nullable(); + $table->enum('status', ['scheduled', 'completed', 'cancelled'])->default('scheduled'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('board_meetings'); + } +}; diff --git a/database/migrations/2025_11_30_171203_add_new_workflow_fields_to_finance_documents.php b/database/migrations/2025_11_30_171203_add_new_workflow_fields_to_finance_documents.php new file mode 100644 index 0000000..421fbb6 --- /dev/null +++ b/database/migrations/2025_11_30_171203_add_new_workflow_fields_to_finance_documents.php @@ -0,0 +1,33 @@ +unsignedBigInteger('accountant_recorded_by_id')->nullable(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('finance_documents', function (Blueprint $table) { + if (Schema::hasColumn('finance_documents', 'accountant_recorded_by_id')) { + $table->dropColumn('accountant_recorded_by_id'); + } + }); + } +}; diff --git a/database/migrations/2025_11_30_175637_remove_request_type_from_finance_documents.php b/database/migrations/2025_11_30_175637_remove_request_type_from_finance_documents.php new file mode 100644 index 0000000..f2ccda7 --- /dev/null +++ b/database/migrations/2025_11_30_175637_remove_request_type_from_finance_documents.php @@ -0,0 +1,32 @@ +dropColumn('request_type'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('finance_documents', function (Blueprint $table) { + if (!Schema::hasColumn('finance_documents', 'request_type')) { + $table->string('request_type')->nullable()->after('amount'); + } + }); + } +}; diff --git a/database/migrations/2025_11_30_182639_create_incomes_table.php b/database/migrations/2025_11_30_182639_create_incomes_table.php new file mode 100644 index 0000000..dbc9d47 --- /dev/null +++ b/database/migrations/2025_11_30_182639_create_incomes_table.php @@ -0,0 +1,92 @@ +id(); + + // 基本資訊 + $table->string('income_number')->unique(); // 收入編號:INC-2025-0001 + $table->string('title'); // 收入標題 + $table->text('description')->nullable(); // 說明 + $table->date('income_date'); // 收入日期 + $table->decimal('amount', 12, 2); // 金額 + + // 收入分類 + $table->string('income_type'); // 收入類型 + $table->foreignId('chart_of_account_id') // 會計科目 + ->constrained('chart_of_accounts'); + + // 付款資訊 + $table->string('payment_method'); // 付款方式 + $table->string('bank_account')->nullable(); // 銀行帳戶 + $table->string('payer_name')->nullable(); // 付款人姓名 + $table->string('receipt_number')->nullable(); // 收據編號 + $table->string('transaction_reference')->nullable(); // 銀行交易參考號 + $table->string('attachment_path')->nullable(); // 附件路徑 + + // 會員關聯 + $table->foreignId('member_id')->nullable() + ->constrained()->nullOnDelete(); + + // 審核流程 + $table->string('status')->default('pending'); // pending, confirmed, cancelled + + // 出納記錄 + $table->foreignId('recorded_by_cashier_id') + ->constrained('users'); + $table->timestamp('recorded_at'); + + // 會計確認 + $table->foreignId('confirmed_by_accountant_id')->nullable() + ->constrained('users'); + $table->timestamp('confirmed_at')->nullable(); + + // 關聯出納日記帳 + $table->foreignId('cashier_ledger_entry_id')->nullable() + ->constrained('cashier_ledger_entries')->nullOnDelete(); + + $table->text('notes')->nullable(); + $table->timestamps(); + + // 索引 + $table->index('income_date'); + $table->index('income_type'); + $table->index('status'); + $table->index(['member_id', 'income_type']); + }); + + // 在 accounting_entries 表新增 income_id 欄位 + Schema::table('accounting_entries', function (Blueprint $table) { + if (!Schema::hasColumn('accounting_entries', 'income_id')) { + $table->foreignId('income_id')->nullable() + ->after('finance_document_id') + ->constrained('incomes')->nullOnDelete(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('accounting_entries', function (Blueprint $table) { + if (Schema::hasColumn('accounting_entries', 'income_id')) { + $table->dropForeign(['income_id']); + $table->dropColumn('income_id'); + } + }); + + Schema::dropIfExists('incomes'); + } +}; diff --git a/database/migrations/2025_11_30_212201_add_disability_fields_to_members_table.php b/database/migrations/2025_11_30_212201_add_disability_fields_to_members_table.php new file mode 100644 index 0000000..e50a968 --- /dev/null +++ b/database/migrations/2025_11_30_212201_add_disability_fields_to_members_table.php @@ -0,0 +1,41 @@ +string('disability_certificate_path')->nullable()->after('membership_type'); + $table->string('disability_certificate_status')->nullable()->after('disability_certificate_path'); + $table->foreignId('disability_verified_by')->nullable()->after('disability_certificate_status') + ->constrained('users')->nullOnDelete(); + $table->timestamp('disability_verified_at')->nullable()->after('disability_verified_by'); + $table->text('disability_rejection_reason')->nullable()->after('disability_verified_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('members', function (Blueprint $table) { + $table->dropForeign(['disability_verified_by']); + $table->dropColumn([ + 'disability_certificate_path', + 'disability_certificate_status', + 'disability_verified_by', + 'disability_verified_at', + 'disability_rejection_reason', + ]); + }); + } +}; diff --git a/database/migrations/2025_11_30_212227_add_fee_type_to_membership_payments_table.php b/database/migrations/2025_11_30_212227_add_fee_type_to_membership_payments_table.php new file mode 100644 index 0000000..6db83e6 --- /dev/null +++ b/database/migrations/2025_11_30_212227_add_fee_type_to_membership_payments_table.php @@ -0,0 +1,42 @@ +string('fee_type')->default('entrance_fee')->after('member_id'); + $table->decimal('base_amount', 10, 2)->nullable()->after('amount'); + $table->decimal('discount_amount', 10, 2)->default(0)->after('base_amount'); + $table->decimal('final_amount', 10, 2)->nullable()->after('discount_amount'); + $table->boolean('disability_discount')->default(false)->after('final_amount'); + }); + + // 為現有記錄設定預設值 + \DB::statement("UPDATE membership_payments SET base_amount = amount, final_amount = amount WHERE base_amount IS NULL"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('membership_payments', function (Blueprint $table) { + $table->dropColumn([ + 'fee_type', + 'base_amount', + 'discount_amount', + 'final_amount', + 'disability_discount', + ]); + }); + } +}; diff --git a/database/seeders/ChartOfAccountSeeder.php b/database/seeders/ChartOfAccountSeeder.php index 5c5b8ad..359cca4 100644 --- a/database/seeders/ChartOfAccountSeeder.php +++ b/database/seeders/ChartOfAccountSeeder.php @@ -484,7 +484,10 @@ class ChartOfAccountSeeder extends Seeder ]; foreach ($accounts as $account) { - ChartOfAccount::create($account); + ChartOfAccount::firstOrCreate( + ['account_code' => $account['account_code']], + $account + ); } } } diff --git a/database/seeders/FinancialWorkflowPermissionsSeeder.php b/database/seeders/FinancialWorkflowPermissionsSeeder.php index 0f07977..3ce2dfd 100644 --- a/database/seeders/FinancialWorkflowPermissionsSeeder.php +++ b/database/seeders/FinancialWorkflowPermissionsSeeder.php @@ -11,48 +11,85 @@ class FinancialWorkflowPermissionsSeeder extends Seeder { /** * Run the database seeds. + * + * 此 Seeder 建立統一的角色與權限系統,整合: + * - 財務工作流程權限 + * - 會員繳費審核權限(原 PaymentVerificationRolesSeeder) + * - 基礎角色(原 RoleSeeder) */ public function run(): void { // Create permissions for financial workflow $permissions = [ - // Approval Stage Permissions - 'approve_finance_cashier' => '出納審核財務申請單(第一階段)', - 'approve_finance_accountant' => '會計審核財務申請單(第二階段)', - 'approve_finance_chair' => '理事長審核財務申請單(第三階段)', - 'approve_finance_board' => '理事會審核大額財務申請(大於50,000)', + // ===== 會員繳費審核權限(原 PaymentVerificationRolesSeeder) ===== + 'verify_payments_cashier' => '出納審核會員繳費(第一階段)', + 'verify_payments_accountant' => '會計審核會員繳費(第二階段)', + 'verify_payments_chair' => '理事長審核會員繳費(第三階段)', + 'activate_memberships' => '啟用會員帳號', + 'view_payment_verifications' => '查看繳費審核儀表板', - // Payment Stage Permissions + // ===== 財務申請單審核權限(新工作流程) ===== + 'approve_finance_secretary' => '秘書長審核財務申請單(第一階段)', + 'approve_finance_chair' => '理事長審核財務申請單(第二階段:中額以上)', + 'approve_finance_board' => '董理事會審核財務申請單(第三階段:大額)', + // Legacy permissions + 'approve_finance_cashier' => '出納審核財務申請單(舊流程)', + 'approve_finance_accountant' => '會計審核財務申請單(舊流程)', + + // ===== 出帳確認權限 ===== + 'confirm_disbursement_requester' => '申請人確認領款', + 'confirm_disbursement_cashier' => '出納確認出帳', + + // ===== 入帳確認權限 ===== + 'confirm_recording_accountant' => '會計確認入帳', + + // ===== 收入管理權限 ===== + 'view_incomes' => '查看收入記錄', + 'record_income' => '記錄收入(出納)', + 'confirm_income' => '確認收入(會計)', + 'cancel_income' => '取消收入', + 'export_incomes' => '匯出收入報表', + 'view_income_statistics' => '查看收入統計', + + // ===== 付款階段權限 ===== 'create_payment_order' => '會計製作付款單', 'verify_payment_order' => '出納覆核付款單', 'execute_payment' => '出納執行付款', 'upload_payment_receipt' => '上傳付款憑證', - // Recording Stage Permissions + // ===== 記錄階段權限 ===== 'record_cashier_ledger' => '出納記錄現金簿', 'record_accounting_transaction' => '會計記錄會計分錄', 'view_cashier_ledger' => '查看出納現金簿', 'view_accounting_transactions' => '查看會計分錄', - // Reconciliation Permissions + // ===== 銀行調節權限 ===== 'prepare_bank_reconciliation' => '出納製作銀行調節表', 'review_bank_reconciliation' => '會計覆核銀行調節表', 'approve_bank_reconciliation' => '主管核准銀行調節表', - // General Finance Document Permissions + // ===== 財務文件權限 ===== 'view_finance_documents' => '查看財務申請單', 'create_finance_documents' => '建立財務申請單', 'edit_finance_documents' => '編輯財務申請單', 'delete_finance_documents' => '刪除財務申請單', - // Chart of Accounts & Budget Permissions + // ===== 會計科目與預算權限 ===== 'assign_chart_of_account' => '指定會計科目', 'assign_budget_item' => '指定預算項目', - // Dashboard & Reports Permissions + // ===== 儀表板與報表權限 ===== 'view_finance_dashboard' => '查看財務儀表板', 'view_finance_reports' => '查看財務報表', 'export_finance_reports' => '匯出財務報表', + + // ===== 公告系統權限 ===== + 'view_announcements' => '查看公告', + 'create_announcements' => '建立公告', + 'edit_announcements' => '編輯公告', + 'delete_announcements' => '刪除公告', + 'publish_announcements' => '發布公告', + 'manage_all_announcements' => '管理所有公告', ]; foreach ($permissions as $name => $description) { @@ -63,81 +100,175 @@ class FinancialWorkflowPermissionsSeeder extends Seeder $this->command->info("Permission created: {$name}"); } - // Create roles for financial workflow + // ===== 建立基礎角色(原 RoleSeeder) ===== + $baseRoles = [ + 'admin' => '系統管理員 - 擁有系統所有權限,負責使用者管理、系統設定與維護', + 'staff' => '工作人員 - 一般協會工作人員,可檢視文件與協助行政事務', + ]; + + foreach ($baseRoles as $roleName => $description) { + Role::updateOrCreate( + ['name' => $roleName, 'guard_name' => 'web'], + ['description' => $description] + ); + $this->command->info("Base role created: {$roleName}"); + } + + // ===== 建立財務與會員管理角色 ===== $roles = [ + 'secretary_general' => [ + 'permissions' => [ + // 財務申請單審核(新工作流程第一階段) + 'approve_finance_secretary', + // 一般 + 'view_finance_documents', + 'view_finance_dashboard', + 'view_finance_reports', + // 公告系統 + 'view_announcements', + 'create_announcements', + 'edit_announcements', + 'delete_announcements', + 'publish_announcements', + 'manage_all_announcements', + ], + 'description' => '秘書長 - 協會行政負責人,負責初審所有財務申請', + ], 'finance_cashier' => [ 'permissions' => [ - // Approval stage + // 會員繳費審核(原 payment_cashier) + 'verify_payments_cashier', + 'view_payment_verifications', + // 財務申請單審核(舊流程,保留) 'approve_finance_cashier', - // Payment stage + // 出帳確認(新工作流程) + 'confirm_disbursement_cashier', + // 收入管理 + 'view_incomes', + 'record_income', + // 付款階段 'verify_payment_order', 'execute_payment', 'upload_payment_receipt', - // Recording stage + // 記錄階段 'record_cashier_ledger', 'view_cashier_ledger', - // Reconciliation + // 銀行調節 'prepare_bank_reconciliation', - // General + // 一般 'view_finance_documents', 'view_finance_dashboard', + // 公告系統 + 'view_announcements', + 'create_announcements', + 'edit_announcements', + 'delete_announcements', + 'publish_announcements', ], - 'description' => '出納 - 管錢(覆核付款單、執行付款、記錄現金簿、製作銀行調節表)', + 'description' => '出納 - 負責現金收付、銀行調節表製作、出帳確認、記錄收入', ], 'finance_accountant' => [ 'permissions' => [ - // Approval stage + // 會員繳費審核(原 payment_accountant) + 'verify_payments_accountant', + 'view_payment_verifications', + // 財務申請單審核(舊流程,保留) 'approve_finance_accountant', - // Payment stage + // 入帳確認(新工作流程) + 'confirm_recording_accountant', + // 收入管理 + 'view_incomes', + 'confirm_income', + 'cancel_income', + 'export_incomes', + 'view_income_statistics', + // 付款階段 'create_payment_order', - // Recording stage + // 記錄階段 'record_accounting_transaction', 'view_accounting_transactions', - // Reconciliation + // 銀行調節 'review_bank_reconciliation', - // Chart of accounts & budget + // 會計科目與預算 'assign_chart_of_account', 'assign_budget_item', - // General + // 一般 'view_finance_documents', 'view_finance_dashboard', 'view_finance_reports', 'export_finance_reports', + // 公告系統 + 'view_announcements', + 'create_announcements', + 'edit_announcements', + 'delete_announcements', + 'publish_announcements', ], - 'description' => '會計 - 管帳(製作付款單、記錄會計分錄、覆核銀行調節表、指定會計科目)', + 'description' => '會計 - 負責會計傳票製作、財務報表編製、入帳確認、確認收入', ], 'finance_chair' => [ 'permissions' => [ - // Approval stage + // 會員繳費審核(原 payment_chair) + 'verify_payments_chair', + 'view_payment_verifications', + // 財務申請單審核 'approve_finance_chair', - // Reconciliation + // 銀行調節 'approve_bank_reconciliation', - // General + // 一般 'view_finance_documents', 'view_finance_dashboard', 'view_finance_reports', 'export_finance_reports', + // 公告系統 + 'view_announcements', + 'create_announcements', + 'edit_announcements', + 'delete_announcements', + 'publish_announcements', + 'manage_all_announcements', ], - 'description' => '理事長 - 審核中大額財務申請、核准銀行調節表', + 'description' => '理事長 - 協會負責人,負責核決重大財務支出與會員繳費最終審核', ], 'finance_board_member' => [ 'permissions' => [ - // Approval stage (for large amounts) + // 大額審核 'approve_finance_board', - // General + // 一般 'view_finance_documents', 'view_finance_dashboard', 'view_finance_reports', + // 公告系統 + 'view_announcements', + 'create_announcements', + 'edit_announcements', + 'delete_announcements', + 'publish_announcements', ], - 'description' => '理事 - 審核大額財務申請(大於50,000)', + 'description' => '理事 - 理事會成員,協助監督協會運作與審核特定議案', ], 'finance_requester' => [ 'permissions' => [ 'view_finance_documents', 'create_finance_documents', 'edit_finance_documents', + // 出帳確認(新工作流程) + 'confirm_disbursement_requester', ], - 'description' => '財務申請人 - 可建立和編輯自己的財務申請單', + 'description' => '財務申請人 - 一般有權申請款項之人員(如活動負責人),可確認領款', + ], + 'membership_manager' => [ + 'permissions' => [ + 'activate_memberships', + 'view_payment_verifications', + // 公告系統 + 'view_announcements', + 'create_announcements', + 'edit_announcements', + 'delete_announcements', + 'publish_announcements', + ], + 'description' => '會員管理員 - 專責處理會員入會審核、資料維護與會籍管理', ], ]; @@ -153,24 +284,32 @@ class FinancialWorkflowPermissionsSeeder extends Seeder $this->command->info("Role created: {$roleName} with permissions: " . implode(', ', $roleData['permissions'])); } - // Assign all financial workflow permissions to admin role (if exists) + // Assign all permissions to admin role $adminRole = Role::where('name', 'admin')->first(); if ($adminRole) { $adminRole->givePermissionTo(array_keys($permissions)); - $this->command->info("Admin role updated with all financial workflow permissions"); + $this->command->info("Admin role updated with all permissions"); } - $this->command->info("\n=== Financial Workflow Roles & Permissions Created ==="); - $this->command->info("Roles created:"); - $this->command->info("1. finance_cashier - 出納(管錢)"); - $this->command->info("2. finance_accountant - 會計(管帳)"); - $this->command->info("3. finance_chair - 理事長"); - $this->command->info("4. finance_board_member - 理事"); - $this->command->info("5. finance_requester - 財務申請人"); - $this->command->info("\nWorkflow stages:"); - $this->command->info("1. Approval Stage: Cashier → Accountant → Chair (→ Board for large amounts)"); - $this->command->info("2. Payment Stage: Accountant creates order → Cashier verifies → Cashier executes"); - $this->command->info("3. Recording Stage: Cashier records ledger + Accountant records transactions"); - $this->command->info("4. Reconciliation: Cashier prepares → Accountant reviews → Chair approves"); + $this->command->info("\n=== 統一角色系統建立完成 ==="); + $this->command->info("基礎角色:"); + $this->command->info(" - admin - 系統管理員"); + $this->command->info(" - staff - 工作人員"); + $this->command->info("\n財務角色:"); + $this->command->info(" - secretary_general - 秘書長(新增:財務申請初審)"); + $this->command->info(" - finance_cashier - 出納(出帳確認)"); + $this->command->info(" - finance_accountant - 會計(入帳確認)"); + $this->command->info(" - finance_chair - 理事長(中額以上審核)"); + $this->command->info(" - finance_board_member - 理事(大額審核)"); + $this->command->info(" - finance_requester - 財務申請人(可確認領款)"); + $this->command->info("\n會員管理角色:"); + $this->command->info(" - membership_manager - 會員管理員"); + $this->command->info("\n新財務申請審核工作流程:"); + $this->command->info(" 審核階段:"); + $this->command->info(" 小額 (< 5,000): secretary_general"); + $this->command->info(" 中額 (5,000-50,000): secretary_general → finance_chair"); + $this->command->info(" 大額 (> 50,000): secretary_general → finance_chair → finance_board_member"); + $this->command->info(" 出帳階段: finance_requester(申請人確認) + finance_cashier(出納確認)"); + $this->command->info(" 入帳階段: finance_accountant(會計入帳)"); } } diff --git a/database/seeders/PaymentVerificationRolesSeeder.php b/database/seeders/PaymentVerificationRolesSeeder.php deleted file mode 100644 index 7bcf34e..0000000 --- a/database/seeders/PaymentVerificationRolesSeeder.php +++ /dev/null @@ -1,79 +0,0 @@ - 'Verify membership payments as cashier (Tier 1)', - 'verify_payments_accountant' => 'Verify membership payments as accountant (Tier 2)', - 'verify_payments_chair' => 'Verify membership payments as chair (Tier 3)', - 'activate_memberships' => 'Activate member accounts after payment approval', - 'view_payment_verifications' => 'View payment verification dashboard', - ]; - - foreach ($permissions as $name => $description) { - Permission::firstOrCreate( - ['name' => $name], - ['guard_name' => 'web'] - ); - $this->command->info("Permission created: {$name}"); - } - - // Create roles for payment verification - $roles = [ - 'payment_cashier' => [ - 'permissions' => ['verify_payments_cashier', 'view_payment_verifications'], - 'description' => 'Cashier - First tier payment verification', - ], - 'payment_accountant' => [ - 'permissions' => ['verify_payments_accountant', 'view_payment_verifications'], - 'description' => 'Accountant - Second tier payment verification', - ], - 'payment_chair' => [ - 'permissions' => ['verify_payments_chair', 'view_payment_verifications'], - 'description' => 'Chair - Final tier payment verification', - ], - 'membership_manager' => [ - 'permissions' => ['activate_memberships', 'view_payment_verifications'], - 'description' => 'Membership Manager - Can activate memberships after approval', - ], - ]; - - foreach ($roles as $roleName => $roleData) { - $role = Role::firstOrCreate( - ['name' => $roleName], - ['guard_name' => 'web'] - ); - - // Assign permissions to role - $role->syncPermissions($roleData['permissions']); - - $this->command->info("Role created: {$roleName} with permissions: " . implode(', ', $roleData['permissions'])); - } - - // Assign all payment verification permissions to admin role (if exists) - $adminRole = Role::where('name', 'admin')->first(); - if ($adminRole) { - $adminRole->givePermissionTo([ - 'verify_payments_cashier', - 'verify_payments_accountant', - 'verify_payments_chair', - 'activate_memberships', - 'view_payment_verifications', - ]); - $this->command->info("Admin role updated with all payment verification permissions"); - } - } -} diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php deleted file mode 100644 index 6b5f394..0000000 --- a/database/seeders/RoleSeeder.php +++ /dev/null @@ -1,27 +0,0 @@ - 'Full system administrator', - 'staff' => 'General staff with access to internal tools', - 'cashier' => 'Handles payment recording and finance intake', - 'accountant' => 'Reviews finance docs and approvals', - 'chair' => 'Board chairperson for final approvals', - ]; - - collect($roles)->each(function ($description, $role) { - Role::updateOrCreate( - ['name' => $role, 'guard_name' => 'web'], - ['description' => $description] - ); - }); - } -} diff --git a/database/seeders/TestDataSeeder.php b/database/seeders/TestDataSeeder.php index 98e050d..ed65ad1 100644 --- a/database/seeders/TestDataSeeder.php +++ b/database/seeders/TestDataSeeder.php @@ -38,8 +38,7 @@ class TestDataSeeder extends Seeder // Ensure required seeders have run $this->call([ - RoleSeeder::class, - PaymentVerificationRolesSeeder::class, + FinancialWorkflowPermissionsSeeder::class, ChartOfAccountSeeder::class, IssueLabelSeeder::class, ]); @@ -86,62 +85,68 @@ class TestDataSeeder extends Seeder $users = []; // 1. Super Admin - $admin = User::create([ - 'name' => 'Admin User', - 'email' => 'admin@test.com', - 'password' => Hash::make('password'), - 'is_admin' => true, - ]); + $admin = User::firstOrCreate( + ['email' => 'admin@test.com'], + [ + 'name' => 'Admin User', + 'password' => Hash::make('password'), + ] + ); $admin->assignRole('admin'); $users['admin'] = $admin; - // 2. Payment Cashier - $cashier = User::create([ - 'name' => 'Cashier User', - 'email' => 'cashier@test.com', - 'password' => Hash::make('password'), - 'is_admin' => true, - ]); - $cashier->assignRole('payment_cashier'); + // 2. Finance Cashier (整合原 payment_cashier) + $cashier = User::firstOrCreate( + ['email' => 'cashier@test.com'], + [ + 'name' => 'Cashier User', + 'password' => Hash::make('password'), + ] + ); + $cashier->assignRole('finance_cashier'); $users['cashier'] = $cashier; - // 3. Payment Accountant - $accountant = User::create([ - 'name' => 'Accountant User', - 'email' => 'accountant@test.com', - 'password' => Hash::make('password'), - 'is_admin' => true, - ]); - $accountant->assignRole('payment_accountant'); + // 3. Finance Accountant (整合原 payment_accountant) + $accountant = User::firstOrCreate( + ['email' => 'accountant@test.com'], + [ + 'name' => 'Accountant User', + 'password' => Hash::make('password'), + ] + ); + $accountant->assignRole('finance_accountant'); $users['accountant'] = $accountant; - // 4. Payment Chair - $chair = User::create([ - 'name' => 'Chair User', - 'email' => 'chair@test.com', - 'password' => Hash::make('password'), - 'is_admin' => true, - ]); - $chair->assignRole('payment_chair'); + // 4. Finance Chair (整合原 payment_chair) + $chair = User::firstOrCreate( + ['email' => 'chair@test.com'], + [ + 'name' => 'Chair User', + 'password' => Hash::make('password'), + ] + ); + $chair->assignRole('finance_chair'); $users['chair'] = $chair; // 5. Membership Manager - $manager = User::create([ - 'name' => 'Membership Manager', - 'email' => 'manager@test.com', - 'password' => Hash::make('password'), - 'is_admin' => true, - ]); + $manager = User::firstOrCreate( + ['email' => 'manager@test.com'], + [ + 'name' => 'Membership Manager', + 'password' => Hash::make('password'), + ] + ); $manager->assignRole('membership_manager'); $users['manager'] = $manager; // 6. Regular Member User - $member = User::create([ - 'name' => 'Regular Member', - 'email' => 'member@test.com', - 'password' => Hash::make('password'), - 'is_admin' => false, - ]); + $member = User::firstOrCreate( + ['email' => 'member@test.com'], + [ + 'name' => 'Regular Member', + 'password' => Hash::make('password'), + ] + ); $users['member'] = $member; return $users; @@ -158,97 +163,107 @@ class TestDataSeeder extends Seeder // 5 Pending Members for ($i = 0; $i < 5; $i++) { - $members[] = Member::create([ - 'user_id' => $i === 0 ? $users['member']->id : null, - 'full_name' => "待審核會員 {$counter}", - 'email' => "pending{$counter}@test.com", - 'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT), - 'address_line_1' => "測試地址 {$counter} 號", - 'city' => $taiwanCities[array_rand($taiwanCities)], - 'postal_code' => '100', - 'membership_status' => Member::STATUS_PENDING, - 'membership_type' => Member::TYPE_REGULAR, - 'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), - 'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), - ]); + $members[] = Member::firstOrCreate( + ['email' => "pending{$counter}@test.com"], + [ + 'user_id' => $i === 0 ? $users['member']->id : null, + 'full_name' => "待審核會員 {$counter}", + 'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT), + 'address_line_1' => "測試地址 {$counter} 號", + 'city' => $taiwanCities[array_rand($taiwanCities)], + 'postal_code' => '100', + 'membership_status' => Member::STATUS_PENDING, + 'membership_type' => Member::TYPE_REGULAR, + 'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), + 'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), + ] + ); $counter++; } // 8 Active Members for ($i = 0; $i < 8; $i++) { $startDate = now()->subMonths(rand(1, 6)); - $members[] = Member::create([ - 'user_id' => null, - 'full_name' => "活躍會員 {$counter}", - 'email' => "active{$counter}@test.com", - 'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT), - 'address_line_1' => "測試地址 {$counter} 號", - 'city' => $taiwanCities[array_rand($taiwanCities)], - 'postal_code' => '100', - 'membership_status' => Member::STATUS_ACTIVE, - 'membership_type' => $i < 6 ? Member::TYPE_REGULAR : ($i === 6 ? Member::TYPE_HONORARY : Member::TYPE_STUDENT), - 'membership_started_at' => $startDate, - 'membership_expires_at' => $startDate->copy()->addYear(), - 'emergency_contact_name' => "緊急聯絡人 {$counter}", - 'emergency_contact_phone' => '02-12345678', - 'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), - 'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), - ]); + $members[] = Member::firstOrCreate( + ['email' => "active{$counter}@test.com"], + [ + 'user_id' => null, + 'full_name' => "活躍會員 {$counter}", + 'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT), + 'address_line_1' => "測試地址 {$counter} 號", + 'city' => $taiwanCities[array_rand($taiwanCities)], + 'postal_code' => '100', + 'membership_status' => Member::STATUS_ACTIVE, + 'membership_type' => $i < 6 ? Member::TYPE_REGULAR : ($i === 6 ? Member::TYPE_HONORARY : Member::TYPE_STUDENT), + 'membership_started_at' => $startDate, + 'membership_expires_at' => $startDate->copy()->addYear(), + 'emergency_contact_name' => "緊急聯絡人 {$counter}", + 'emergency_contact_phone' => '02-12345678', + 'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), + 'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), + ] + ); $counter++; } // 3 Expired Members for ($i = 0; $i < 3; $i++) { $startDate = now()->subYears(2); - $members[] = Member::create([ - 'user_id' => null, - 'full_name' => "過期會員 {$counter}", - 'email' => "expired{$counter}@test.com", - 'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT), - 'address_line_1' => "測試地址 {$counter} 號", - 'city' => $taiwanCities[array_rand($taiwanCities)], - 'postal_code' => '100', - 'membership_status' => Member::STATUS_EXPIRED, - 'membership_type' => Member::TYPE_REGULAR, - 'membership_started_at' => $startDate, - 'membership_expires_at' => $startDate->copy()->addYear(), - 'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), - 'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), - ]); + $members[] = Member::firstOrCreate( + ['email' => "expired{$counter}@test.com"], + [ + 'user_id' => null, + 'full_name' => "過期會員 {$counter}", + 'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT), + 'address_line_1' => "測試地址 {$counter} 號", + 'city' => $taiwanCities[array_rand($taiwanCities)], + 'postal_code' => '100', + 'membership_status' => Member::STATUS_EXPIRED, + 'membership_type' => Member::TYPE_REGULAR, + 'membership_started_at' => $startDate, + 'membership_expires_at' => $startDate->copy()->addYear(), + 'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), + 'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), + ] + ); $counter++; } // 2 Suspended Members for ($i = 0; $i < 2; $i++) { - $members[] = Member::create([ - 'user_id' => null, - 'full_name' => "停權會員 {$counter}", - 'email' => "suspended{$counter}@test.com", - 'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT), - 'address_line_1' => "測試地址 {$counter} 號", - 'city' => $taiwanCities[array_rand($taiwanCities)], - 'postal_code' => '100', - 'membership_status' => Member::STATUS_SUSPENDED, - 'membership_type' => Member::TYPE_REGULAR, - 'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), - 'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), - ]); + $members[] = Member::firstOrCreate( + ['email' => "suspended{$counter}@test.com"], + [ + 'user_id' => null, + 'full_name' => "停權會員 {$counter}", + 'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT), + 'address_line_1' => "測試地址 {$counter} 號", + 'city' => $taiwanCities[array_rand($taiwanCities)], + 'postal_code' => '100', + 'membership_status' => Member::STATUS_SUSPENDED, + 'membership_type' => Member::TYPE_REGULAR, + 'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), + 'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)), + ] + ); $counter++; } // 2 Additional Pending Members (total 20) for ($i = 0; $i < 2; $i++) { - $members[] = Member::create([ - 'user_id' => null, - 'full_name' => "新申請會員 {$counter}", - 'email' => "newmember{$counter}@test.com", - 'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT), - 'address_line_1' => "測試地址 {$counter} 號", - 'city' => $taiwanCities[array_rand($taiwanCities)], - 'postal_code' => '100', - 'membership_status' => Member::STATUS_PENDING, - 'membership_type' => Member::TYPE_REGULAR, - ]); + $members[] = Member::firstOrCreate( + ['email' => "newmember{$counter}@test.com"], + [ + 'user_id' => null, + 'full_name' => "新申請會員 {$counter}", + 'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT), + 'address_line_1' => "測試地址 {$counter} 號", + 'city' => $taiwanCities[array_rand($taiwanCities)], + 'postal_code' => '100', + 'membership_status' => Member::STATUS_PENDING, + 'membership_type' => Member::TYPE_REGULAR, + ] + ); $counter++; } @@ -264,38 +279,41 @@ class TestDataSeeder extends Seeder $paymentMethods = [ MembershipPayment::METHOD_BANK_TRANSFER, MembershipPayment::METHOD_CASH, - MembershipPayment::METHOD_CHECK, ]; // 10 Pending Payments for ($i = 0; $i < 10; $i++) { - $payments[] = MembershipPayment::create([ - 'member_id' => $members[$i]->id, - 'amount' => 1000, - 'paid_at' => now()->subDays(rand(1, 10)), - 'payment_method' => $paymentMethods[array_rand($paymentMethods)], - 'reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT), - 'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf', - 'status' => MembershipPayment::STATUS_PENDING, - 'notes' => '待審核的繳費記錄', - ]); + $payments[] = MembershipPayment::firstOrCreate( + ['reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT)], + [ + 'member_id' => $members[$i]->id, + 'amount' => 1000, + 'paid_at' => now()->subDays(rand(1, 10)), + 'payment_method' => $paymentMethods[array_rand($paymentMethods)], + 'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf', + 'status' => MembershipPayment::STATUS_PENDING, + 'notes' => '待審核的繳費記錄', + ] + ); } // 8 Approved by Cashier for ($i = 10; $i < 18; $i++) { - $payments[] = MembershipPayment::create([ - 'member_id' => $members[$i % count($members)]->id, - 'amount' => 1000, - 'paid_at' => now()->subDays(rand(5, 15)), - 'payment_method' => $paymentMethods[array_rand($paymentMethods)], - 'reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT), - 'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf', - 'status' => MembershipPayment::STATUS_APPROVED_CASHIER, - 'verified_by_cashier_id' => $users['cashier']->id, - 'cashier_verified_at' => now()->subDays(rand(3, 12)), - 'cashier_notes' => '收據已核對,金額無誤', - 'notes' => '已通過出納審核', - ]); + $payments[] = MembershipPayment::firstOrCreate( + ['reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT)], + [ + 'member_id' => $members[$i % count($members)]->id, + 'amount' => 1000, + 'paid_at' => now()->subDays(rand(5, 15)), + 'payment_method' => $paymentMethods[array_rand($paymentMethods)], + 'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf', + 'status' => MembershipPayment::STATUS_APPROVED_CASHIER, + 'verified_by_cashier_id' => $users['cashier']->id, + 'cashier_verified_at' => now()->subDays(rand(3, 12)), + 'cashier_notes' => '收據已核對,金額無誤', + 'notes' => '已通過出納審核', + ] + ); } // 6 Approved by Accountant @@ -303,22 +321,24 @@ class TestDataSeeder extends Seeder $cashierVerifiedAt = now()->subDays(rand(10, 20)); $accountantVerifiedAt = $cashierVerifiedAt->copy()->addDays(rand(1, 3)); - $payments[] = MembershipPayment::create([ - 'member_id' => $members[$i % count($members)]->id, - 'amount' => 1000, - 'paid_at' => now()->subDays(rand(15, 25)), - 'payment_method' => $paymentMethods[array_rand($paymentMethods)], - 'reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT), - 'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf', - 'status' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT, - 'verified_by_cashier_id' => $users['cashier']->id, - 'cashier_verified_at' => $cashierVerifiedAt, - 'cashier_notes' => '收據已核對,金額無誤', - 'verified_by_accountant_id' => $users['accountant']->id, - 'accountant_verified_at' => $accountantVerifiedAt, - 'accountant_notes' => '帳務核對完成', - 'notes' => '已通過會計審核', - ]); + $payments[] = MembershipPayment::firstOrCreate( + ['reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT)], + [ + 'member_id' => $members[$i % count($members)]->id, + 'amount' => 1000, + 'paid_at' => now()->subDays(rand(15, 25)), + 'payment_method' => $paymentMethods[array_rand($paymentMethods)], + 'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf', + 'status' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT, + 'verified_by_cashier_id' => $users['cashier']->id, + 'cashier_verified_at' => $cashierVerifiedAt, + 'cashier_notes' => '收據已核對,金額無誤', + 'verified_by_accountant_id' => $users['accountant']->id, + 'accountant_verified_at' => $accountantVerifiedAt, + 'accountant_notes' => '帳務核對完成', + 'notes' => '已通過會計審核', + ] + ); } // 4 Fully Approved (Chair approved - member activated) @@ -327,42 +347,46 @@ class TestDataSeeder extends Seeder $accountantVerifiedAt = $cashierVerifiedAt->copy()->addDays(rand(1, 3)); $chairVerifiedAt = $accountantVerifiedAt->copy()->addDays(rand(1, 2)); - $payments[] = MembershipPayment::create([ - 'member_id' => $members[$i % count($members)]->id, - 'amount' => 1000, - 'paid_at' => now()->subDays(rand(25, 35)), - 'payment_method' => $paymentMethods[array_rand($paymentMethods)], - 'reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT), - 'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf', - 'status' => MembershipPayment::STATUS_APPROVED_CHAIR, - 'verified_by_cashier_id' => $users['cashier']->id, - 'cashier_verified_at' => $cashierVerifiedAt, - 'cashier_notes' => '收據已核對,金額無誤', - 'verified_by_accountant_id' => $users['accountant']->id, - 'accountant_verified_at' => $accountantVerifiedAt, - 'accountant_notes' => '帳務核對完成', - 'verified_by_chair_id' => $users['chair']->id, - 'chair_verified_at' => $chairVerifiedAt, - 'chair_notes' => '最終批准', - 'notes' => '已完成三階段審核', - ]); + $payments[] = MembershipPayment::firstOrCreate( + ['reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT)], + [ + 'member_id' => $members[$i % count($members)]->id, + 'amount' => 1000, + 'paid_at' => now()->subDays(rand(25, 35)), + 'payment_method' => $paymentMethods[array_rand($paymentMethods)], + 'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf', + 'status' => MembershipPayment::STATUS_APPROVED_CHAIR, + 'verified_by_cashier_id' => $users['cashier']->id, + 'cashier_verified_at' => $cashierVerifiedAt, + 'cashier_notes' => '收據已核對,金額無誤', + 'verified_by_accountant_id' => $users['accountant']->id, + 'accountant_verified_at' => $accountantVerifiedAt, + 'accountant_notes' => '帳務核對完成', + 'verified_by_chair_id' => $users['chair']->id, + 'chair_verified_at' => $chairVerifiedAt, + 'chair_notes' => '最終批准', + 'notes' => '已完成三階段審核', + ] + ); } // 2 Rejected Payments for ($i = 28; $i < 30; $i++) { - $payments[] = MembershipPayment::create([ - 'member_id' => $members[$i % count($members)]->id, - 'amount' => 1000, - 'paid_at' => now()->subDays(rand(5, 10)), - 'payment_method' => $paymentMethods[array_rand($paymentMethods)], - 'reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT), - 'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf', - 'status' => MembershipPayment::STATUS_REJECTED, - 'rejected_by_user_id' => $users['cashier']->id, - 'rejected_at' => now()->subDays(rand(3, 8)), - 'rejection_reason' => $i === 28 ? '收據影像不清晰,無法辨識' : '金額與收據不符', - 'notes' => '已退回', - ]); + $payments[] = MembershipPayment::firstOrCreate( + ['reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT)], + [ + 'member_id' => $members[$i % count($members)]->id, + 'amount' => 1000, + 'paid_at' => now()->subDays(rand(5, 10)), + 'payment_method' => $paymentMethods[array_rand($paymentMethods)], + 'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf', + 'status' => MembershipPayment::STATUS_REJECTED, + 'rejected_by_user_id' => $users['cashier']->id, + 'rejected_at' => now()->subDays(rand(3, 8)), + 'rejection_reason' => $i === 28 ? '收據影像不清晰,無法辨識' : '金額與收據不符', + 'notes' => '已退回', + ] + ); } return $payments; @@ -747,12 +771,12 @@ class TestDataSeeder extends Seeder $this->command->table( ['Role', 'Email', 'Password', 'Permissions'], [ - ['Admin', 'admin@test.com', 'password', 'All permissions'], - ['Cashier', 'cashier@test.com', 'password', 'Tier 1 payment verification'], - ['Accountant', 'accountant@test.com', 'password', 'Tier 2 payment verification'], - ['Chair', 'chair@test.com', 'password', 'Tier 3 payment verification'], - ['Manager', 'manager@test.com', 'password', 'Membership activation'], - ['Member', 'member@test.com', 'password', 'Member dashboard access'], + ['Admin (admin)', 'admin@test.com', 'password', 'All permissions'], + ['Finance Cashier (finance_cashier)', 'cashier@test.com', 'password', 'Payment + Finance cashier'], + ['Finance Accountant (finance_accountant)', 'accountant@test.com', 'password', 'Payment + Finance accountant'], + ['Finance Chair (finance_chair)', 'chair@test.com', 'password', 'Payment + Finance chair'], + ['Membership Manager (membership_manager)', 'manager@test.com', 'password', 'Membership activation'], + ['Member (no role)', 'member@test.com', 'password', 'Member dashboard access'], ] ); $this->command->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); diff --git a/resources/views/admin/announcements/create.blade.php b/resources/views/admin/announcements/create.blade.php new file mode 100644 index 0000000..ec356d1 --- /dev/null +++ b/resources/views/admin/announcements/create.blade.php @@ -0,0 +1,129 @@ + + +
+

+ 建立公告 +

+ + ← 返回列表 + +
+
+ +
+
+
+
+ @csrf + + +
+ + + @error('title') +

{{ $message }}

+ @enderror +
+ + +
+ + + @error('content') +

{{ $message }}

+ @enderror +
+ + +
+ + + @error('access_level') +

{{ $message }}

+ @enderror +
+ + +
+ + +

設定未來時間可排程發布

+ @error('published_at') +

{{ $message }}

+ @enderror +
+ + +
+ + +

過期後將自動隱藏

+ @error('expires_at') +

{{ $message }}

+ @enderror +
+ + +
+ + +
+ + + + + +
+ + 取消 + + + +
+
+
+
+
+ + @push('scripts') + + @endpush +
diff --git a/resources/views/admin/announcements/edit.blade.php b/resources/views/admin/announcements/edit.blade.php new file mode 100644 index 0000000..16845cf --- /dev/null +++ b/resources/views/admin/announcements/edit.blade.php @@ -0,0 +1,126 @@ + + +
+

+ 編輯公告 +

+ + ← 返回查看 + +
+
+ +
+
+
+
+ @csrf + @method('PATCH') + + +
+ + + @error('title') +

{{ $message }}

+ @enderror +
+ + +
+ + + @error('content') +

{{ $message }}

+ @enderror +
+ + +
+ + + @error('access_level') +

{{ $message }}

+ @enderror +
+ + +
+ + + @error('published_at') +

{{ $message }}

+ @enderror +
+ + +
+ + + @error('expires_at') +

{{ $message }}

+ @enderror +
+ + +
+ is_pinned) ? 'checked' : '' }} + class="h-4 w-4 rounded border-gray-300 dark:border-gray-700 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-900"> + +
+ + +
+ + +
+ + +
+ + 取消 + + +
+
+
+
+
+ + @push('scripts') + + @endpush +
diff --git a/resources/views/admin/announcements/index.blade.php b/resources/views/admin/announcements/index.blade.php new file mode 100644 index 0000000..3886618 --- /dev/null +++ b/resources/views/admin/announcements/index.blade.php @@ -0,0 +1,186 @@ + + +
+

+ 公告管理 +

+ + + 建立公告 + +
+
+ +
+
+ @if (session('status')) +
+

{{ session('status') }}

+
+ @endif + + @if (session('error')) +
+

{{ session('error') }}

+
+ @endif + + +
+
+
總計
+
{{ $stats['total'] }}
+
+
+
草稿
+
{{ $stats['draft'] }}
+
+
+
已發布
+
{{ $stats['published'] }}
+
+
+
已歸檔
+
{{ $stats['archived'] }}
+
+
+
置頂中
+
{{ $stats['pinned'] }}
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + 清除 + + +
+
+
+ +
+
+

共 {{ $announcements->total() }} 則公告

+
+
+ + +
+ + + + + + + + + + + + + + @forelse($announcements as $announcement) + + + + + + + + + + @empty + + + + @endforelse + +
公告狀態存取權限建立者瀏覽次數建立時間操作
+
+ @if($announcement->is_pinned) + 📌 + @endif +
+ +
+ {{ \Illuminate\Support\Str::limit($announcement->content, 60) }} +
+
+
+
+ + {{ $announcement->getStatusLabel() }} + + + {{ $announcement->getAccessLevelLabel() }} + + {{ $announcement->creator->name ?? 'N/A' }} + + {{ $announcement->view_count }} + + {{ $announcement->created_at->format('Y-m-d H:i') }} + + 查看 + @if($announcement->canBeEditedBy(auth()->user())) + 編輯 + @endif +
+ 沒有找到公告。建立第一則公告 +
+
+ + + @if($announcements->hasPages()) +
+ {{ $announcements->links() }} +
+ @endif +
+
+
diff --git a/resources/views/admin/announcements/show.blade.php b/resources/views/admin/announcements/show.blade.php new file mode 100644 index 0000000..191a6a3 --- /dev/null +++ b/resources/views/admin/announcements/show.blade.php @@ -0,0 +1,170 @@ + + +
+

+ 公告詳情 +

+
+ + ← 返回列表 + + @if($announcement->canBeEditedBy(auth()->user())) + + 編輯公告 + + @endif +
+
+
+ +
+
+ @if (session('status')) +
+

{{ session('status') }}

+
+ @endif + + @if (session('error')) +
+

{{ session('error') }}

+
+ @endif + + +
+
+
+

+ @if($announcement->is_pinned) + 📌 + @endif + {{ $announcement->title }} +

+ + {{ $announcement->getStatusLabel() }} + +
+ +
+
{{ $announcement->content }}
+
+
+
+ + +
+

公告資訊

+
+
+
存取權限
+
{{ $announcement->getAccessLevelLabel() }}
+
+
+
瀏覽次數
+
{{ $announcement->view_count }}
+
+
+
建立者
+
{{ $announcement->creator->name ?? 'N/A' }}
+
+
+
建立時間
+
{{ $announcement->created_at->format('Y-m-d H:i:s') }}
+
+ @if($announcement->published_at) +
+
發布時間
+
+ {{ $announcement->published_at->format('Y-m-d H:i:s') }} + @if($announcement->isScheduled()) + 排程中 + @endif +
+
+ @endif + @if($announcement->expires_at) +
+
過期時間
+
+ {{ $announcement->expires_at->format('Y-m-d H:i:s') }} + @if($announcement->isExpired()) + 已過期 + @endif +
+
+ @endif + @if($announcement->lastUpdatedBy) +
+
最後更新者
+
{{ $announcement->lastUpdatedBy->name }}
+
+
+
最後更新時間
+
{{ $announcement->updated_at->format('Y-m-d H:i:s') }}
+
+ @endif +
+
+ + + @if($announcement->canBeEditedBy(auth()->user())) +
+

操作

+
+ @if($announcement->isDraft() && auth()->user()->can('publish_announcements')) +
+ @csrf + +
+ @endif + + @if($announcement->isPublished() && auth()->user()->can('publish_announcements')) +
+ @csrf + +
+ @endif + + @if(!$announcement->is_pinned && auth()->user()->can('edit_announcements')) +
+ @csrf + +
+ @endif + + @if($announcement->is_pinned && auth()->user()->can('edit_announcements')) +
+ @csrf + +
+ @endif + + @if(auth()->user()->can('delete_announcements')) +
+ @csrf + @method('DELETE') + +
+ @endif +
+
+ @endif +
+
+
diff --git a/resources/views/admin/audit/index.blade.php b/resources/views/admin/audit/index.blade.php index 8f3cfbe..1a436cd 100644 --- a/resources/views/admin/audit/index.blade.php +++ b/resources/views/admin/audit/index.blade.php @@ -1,7 +1,7 @@

- {{ __('Audit Logs') }} + 稽核日誌

@@ -9,18 +9,18 @@
-