Add membership fee system with disability discount and fix document permissions
Features: - Implement two fee types: entrance fee and annual fee (both NT$1,000) - Add 50% discount for disability certificate holders - Add disability certificate upload in member profile - Integrate disability verification into cashier approval workflow - Add membership fee settings in system admin Document permissions: - Fix hard-coded role logic in Document model - Use permission-based authorization instead of role checks Additional features: - Add announcements, general ledger, and trial balance modules - Add income management and accounting entries - Add comprehensive test suite with factories - Update UI translations to Traditional Chinese 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
76
app/Console/Commands/AnalyzeAccountingData.php
Normal file
76
app/Console/Commands/AnalyzeAccountingData.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
class AnalyzeAccountingData extends Command
|
||||
{
|
||||
protected $signature = 'analyze:accounting';
|
||||
protected $description = 'Analyze accounting data files';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
436
app/Console/Commands/ImportAccountingData.php
Normal file
436
app/Console/Commands/ImportAccountingData.php
Normal file
@@ -0,0 +1,436 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AccountingEntry;
|
||||
use App\Models\ChartOfAccount;
|
||||
use App\Models\FinanceDocument;
|
||||
use Illuminate\Console\Command;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
|
||||
class ImportAccountingData extends Command
|
||||
{
|
||||
protected $signature = 'import:accounting-data
|
||||
{file? : Path to Excel file}
|
||||
{--dry-run : Preview without importing}';
|
||||
|
||||
protected $description = 'Import accounting data from Excel files';
|
||||
|
||||
protected $mapping;
|
||||
protected $expenseKeywords;
|
||||
protected $accountCache = [];
|
||||
protected $stats = [
|
||||
'income_count' => 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
346
app/Http/Controllers/Admin/AnnouncementController.php
Normal file
346
app/Http/Controllers/Admin/AnnouncementController.php
Normal file
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Announcement;
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AnnouncementController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('can:view_announcements')->only(['index', 'show']);
|
||||
$this->middleware('can:create_announcements')->only(['create', 'store']);
|
||||
$this->middleware('can:edit_announcements')->only(['edit', 'update']);
|
||||
$this->middleware('can:delete_announcements')->only(['destroy']);
|
||||
$this->middleware('can:publish_announcements')->only(['publish', 'archive']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a listing of announcements
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Announcement::with(['creator', 'lastUpdatedBy'])
|
||||
->orderByDesc('is_pinned')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
// Filter by status
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// Filter by access level
|
||||
if ($request->filled('access_level')) {
|
||||
$query->where('access_level', $request->access_level);
|
||||
}
|
||||
|
||||
// Filter by pinned
|
||||
if ($request->filled('pinned')) {
|
||||
$query->where('is_pinned', $request->pinned === 'yes');
|
||||
}
|
||||
|
||||
// Search
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('title', 'like', "%{$search}%")
|
||||
->orWhere('content', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$announcements = $query->paginate(20);
|
||||
|
||||
// Statistics
|
||||
$stats = [
|
||||
'total' => Announcement::count(),
|
||||
'draft' => Announcement::draft()->count(),
|
||||
'published' => Announcement::published()->count(),
|
||||
'archived' => Announcement::archived()->count(),
|
||||
'pinned' => Announcement::pinned()->count(),
|
||||
];
|
||||
|
||||
return view('admin.announcements.index', compact('announcements', 'stats'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new announcement
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
return view('admin.announcements.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created announcement
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'content' => 'required|string',
|
||||
'access_level' => 'required|in:public,members,board,admin',
|
||||
'published_at' => 'nullable|date',
|
||||
'expires_at' => 'nullable|date|after:published_at',
|
||||
'is_pinned' => 'boolean',
|
||||
'display_order' => 'nullable|integer',
|
||||
'save_action' => 'required|in:draft,publish',
|
||||
]);
|
||||
|
||||
$announcement = Announcement::create([
|
||||
'title' => $validated['title'],
|
||||
'content' => $validated['content'],
|
||||
'access_level' => $validated['access_level'],
|
||||
'status' => $validated['save_action'] === 'publish' ? Announcement::STATUS_PUBLISHED : Announcement::STATUS_DRAFT,
|
||||
'published_at' => $validated['save_action'] === 'publish' ? ($validated['published_at'] ?? now()) : null,
|
||||
'expires_at' => $validated['expires_at'] ?? null,
|
||||
'is_pinned' => $validated['is_pinned'] ?? false,
|
||||
'display_order' => $validated['display_order'] ?? 0,
|
||||
'created_by_user_id' => auth()->id(),
|
||||
'last_updated_by_user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Audit log
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'announcement.created',
|
||||
'description' => "建立公告:{$announcement->title} (狀態:{$announcement->getStatusLabel()})",
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
$message = $validated['save_action'] === 'publish' ? '公告已成功發布' : '公告已儲存為草稿';
|
||||
|
||||
return redirect()
|
||||
->route('admin.announcements.show', $announcement)
|
||||
->with('status', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified announcement
|
||||
*/
|
||||
public function show(Announcement $announcement)
|
||||
{
|
||||
// Check if user can view this announcement
|
||||
if (!$announcement->canBeViewedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限查看此公告');
|
||||
}
|
||||
|
||||
$announcement->load(['creator', 'lastUpdatedBy']);
|
||||
|
||||
// Increment view count if viewing published announcement
|
||||
if ($announcement->isPublished()) {
|
||||
$announcement->incrementViewCount();
|
||||
}
|
||||
|
||||
return view('admin.announcements.show', compact('announcement'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified announcement
|
||||
*/
|
||||
public function edit(Announcement $announcement)
|
||||
{
|
||||
// Check if user can edit this announcement
|
||||
if (!$announcement->canBeEditedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限編輯此公告');
|
||||
}
|
||||
|
||||
return view('admin.announcements.edit', compact('announcement'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified announcement
|
||||
*/
|
||||
public function update(Request $request, Announcement $announcement)
|
||||
{
|
||||
// Check if user can edit this announcement
|
||||
if (!$announcement->canBeEditedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限編輯此公告');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'content' => 'required|string',
|
||||
'access_level' => 'required|in:public,members,board,admin',
|
||||
'published_at' => 'nullable|date',
|
||||
'expires_at' => 'nullable|date|after:published_at',
|
||||
'is_pinned' => 'boolean',
|
||||
'display_order' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$announcement->update([
|
||||
'title' => $validated['title'],
|
||||
'content' => $validated['content'],
|
||||
'access_level' => $validated['access_level'],
|
||||
'published_at' => $validated['published_at'],
|
||||
'expires_at' => $validated['expires_at'] ?? null,
|
||||
'is_pinned' => $validated['is_pinned'] ?? false,
|
||||
'display_order' => $validated['display_order'] ?? 0,
|
||||
'last_updated_by_user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Audit log
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'announcement.updated',
|
||||
'description' => "更新公告:{$announcement->title}",
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('admin.announcements.show', $announcement)
|
||||
->with('status', '公告已成功更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified announcement (soft delete)
|
||||
*/
|
||||
public function destroy(Announcement $announcement)
|
||||
{
|
||||
// Check if user can delete this announcement
|
||||
if (!$announcement->canBeEditedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限刪除此公告');
|
||||
}
|
||||
|
||||
$title = $announcement->title;
|
||||
$announcement->delete();
|
||||
|
||||
// Audit log
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'announcement.deleted',
|
||||
'description' => "刪除公告:{$title}",
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('admin.announcements.index')
|
||||
->with('status', '公告已成功刪除');
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a draft announcement
|
||||
*/
|
||||
public function publish(Announcement $announcement)
|
||||
{
|
||||
// Check permission
|
||||
if (!auth()->user()->can('publish_announcements')) {
|
||||
abort(403, '您沒有權限發布公告');
|
||||
}
|
||||
|
||||
// Check if user can edit this announcement
|
||||
if (!$announcement->canBeEditedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限發布此公告');
|
||||
}
|
||||
|
||||
if ($announcement->isPublished()) {
|
||||
return back()->with('error', '此公告已經發布');
|
||||
}
|
||||
|
||||
$announcement->publish(auth()->user());
|
||||
|
||||
// Audit log
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'announcement.published',
|
||||
'description' => "發布公告:{$announcement->title}",
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
return back()->with('status', '公告已成功發布');
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive an announcement
|
||||
*/
|
||||
public function archive(Announcement $announcement)
|
||||
{
|
||||
// Check permission
|
||||
if (!auth()->user()->can('publish_announcements')) {
|
||||
abort(403, '您沒有權限歸檔公告');
|
||||
}
|
||||
|
||||
// Check if user can edit this announcement
|
||||
if (!$announcement->canBeEditedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限歸檔此公告');
|
||||
}
|
||||
|
||||
if ($announcement->isArchived()) {
|
||||
return back()->with('error', '此公告已經歸檔');
|
||||
}
|
||||
|
||||
$announcement->archive(auth()->user());
|
||||
|
||||
// Audit log
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'announcement.archived',
|
||||
'description' => "歸檔公告:{$announcement->title}",
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
return back()->with('status', '公告已成功歸檔');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin an announcement
|
||||
*/
|
||||
public function pin(Request $request, Announcement $announcement)
|
||||
{
|
||||
// Check permission
|
||||
if (!auth()->user()->can('edit_announcements')) {
|
||||
abort(403, '您沒有權限置頂公告');
|
||||
}
|
||||
|
||||
// Check if user can edit this announcement
|
||||
if (!$announcement->canBeEditedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限置頂此公告');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'display_order' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$announcement->pin($validated['display_order'] ?? 0, auth()->user());
|
||||
|
||||
// Audit log
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'announcement.pinned',
|
||||
'description' => "置頂公告:{$announcement->title}",
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
return back()->with('status', '公告已成功置頂');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpin an announcement
|
||||
*/
|
||||
public function unpin(Announcement $announcement)
|
||||
{
|
||||
// Check permission
|
||||
if (!auth()->user()->can('edit_announcements')) {
|
||||
abort(403, '您沒有權限取消置頂公告');
|
||||
}
|
||||
|
||||
// Check if user can edit this announcement
|
||||
if (!$announcement->canBeEditedBy(auth()->user())) {
|
||||
abort(403, '您沒有權限取消置頂此公告');
|
||||
}
|
||||
|
||||
$announcement->unpin(auth()->user());
|
||||
|
||||
// Audit log
|
||||
AuditLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => 'announcement.unpinned',
|
||||
'description' => "取消置頂公告:{$announcement->title}",
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
return back()->with('status', '公告已取消置頂');
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
87
app/Http/Controllers/Admin/GeneralLedgerController.php
Normal file
87
app/Http/Controllers/Admin/GeneralLedgerController.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AccountingEntry;
|
||||
use App\Models\ChartOfAccount;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GeneralLedgerController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the general ledger
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$accounts = ChartOfAccount::where('is_active', true)
|
||||
->orderBy('account_code')
|
||||
->get();
|
||||
|
||||
$selectedAccountId = $request->input('account_id');
|
||||
$startDate = $request->input('start_date', now()->startOfYear()->format('Y-m-d'));
|
||||
$endDate = $request->input('end_date', now()->format('Y-m-d'));
|
||||
|
||||
$entries = null;
|
||||
$selectedAccount = null;
|
||||
$openingBalance = 0;
|
||||
$debitTotal = 0;
|
||||
$creditTotal = 0;
|
||||
$closingBalance = 0;
|
||||
|
||||
if ($selectedAccountId) {
|
||||
$selectedAccount = ChartOfAccount::findOrFail($selectedAccountId);
|
||||
|
||||
// Get opening balance (all entries before start date)
|
||||
$openingDebit = AccountingEntry::where('chart_of_account_id', $selectedAccountId)
|
||||
->where('entry_date', '<', $startDate)
|
||||
->where('entry_type', AccountingEntry::ENTRY_TYPE_DEBIT)
|
||||
->sum('amount');
|
||||
|
||||
$openingCredit = AccountingEntry::where('chart_of_account_id', $selectedAccountId)
|
||||
->where('entry_date', '<', $startDate)
|
||||
->where('entry_type', AccountingEntry::ENTRY_TYPE_CREDIT)
|
||||
->sum('amount');
|
||||
|
||||
// Calculate opening balance based on account type
|
||||
if (in_array($selectedAccount->account_type, ['asset', 'expense'])) {
|
||||
// Assets and Expenses: Debit increases, Credit decreases
|
||||
$openingBalance = $openingDebit - $openingCredit;
|
||||
} else {
|
||||
// Liabilities, Equity, Income: Credit increases, Debit decreases
|
||||
$openingBalance = $openingCredit - $openingDebit;
|
||||
}
|
||||
|
||||
// Get entries for the period
|
||||
$entries = AccountingEntry::with(['financeDocument', 'chartOfAccount'])
|
||||
->where('chart_of_account_id', $selectedAccountId)
|
||||
->whereBetween('entry_date', [$startDate, $endDate])
|
||||
->orderBy('entry_date')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
// Calculate totals for the period
|
||||
$debitTotal = $entries->where('entry_type', AccountingEntry::ENTRY_TYPE_DEBIT)->sum('amount');
|
||||
$creditTotal = $entries->where('entry_type', AccountingEntry::ENTRY_TYPE_CREDIT)->sum('amount');
|
||||
|
||||
// Calculate closing balance
|
||||
if (in_array($selectedAccount->account_type, ['asset', 'expense'])) {
|
||||
$closingBalance = $openingBalance + $debitTotal - $creditTotal;
|
||||
} else {
|
||||
$closingBalance = $openingBalance + $creditTotal - $debitTotal;
|
||||
}
|
||||
}
|
||||
|
||||
return view('admin.general-ledger.index', compact(
|
||||
'accounts',
|
||||
'selectedAccount',
|
||||
'entries',
|
||||
'startDate',
|
||||
'endDate',
|
||||
'openingBalance',
|
||||
'debitTotal',
|
||||
'creditTotal',
|
||||
'closingBalance'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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', '會費設定已更新');
|
||||
}
|
||||
}
|
||||
|
||||
75
app/Http/Controllers/Admin/TrialBalanceController.php
Normal file
75
app/Http/Controllers/Admin/TrialBalanceController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AccountingEntry;
|
||||
use App\Models\ChartOfAccount;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TrialBalanceController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the trial balance
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$startDate = $request->input('start_date', now()->startOfYear()->format('Y-m-d'));
|
||||
$endDate = $request->input('end_date', now()->format('Y-m-d'));
|
||||
|
||||
// Get all active accounts with their balances
|
||||
$accounts = ChartOfAccount::where('is_active', true)
|
||||
->orderBy('account_code')
|
||||
->get()
|
||||
->map(function ($account) use ($startDate, $endDate) {
|
||||
// Get debit and credit totals for this account
|
||||
$debitTotal = AccountingEntry::where('chart_of_account_id', $account->id)
|
||||
->whereBetween('entry_date', [$startDate, $endDate])
|
||||
->where('entry_type', AccountingEntry::ENTRY_TYPE_DEBIT)
|
||||
->sum('amount');
|
||||
|
||||
$creditTotal = AccountingEntry::where('chart_of_account_id', $account->id)
|
||||
->whereBetween('entry_date', [$startDate, $endDate])
|
||||
->where('entry_type', AccountingEntry::ENTRY_TYPE_CREDIT)
|
||||
->sum('amount');
|
||||
|
||||
// Only include accounts with activity
|
||||
if ($debitTotal == 0 && $creditTotal == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'account' => $account,
|
||||
'debit_total' => $debitTotal,
|
||||
'credit_total' => $creditTotal,
|
||||
];
|
||||
})
|
||||
->filter() // Remove null entries
|
||||
->values();
|
||||
|
||||
// Calculate grand totals
|
||||
$grandDebitTotal = $accounts->sum('debit_total');
|
||||
$grandCreditTotal = $accounts->sum('credit_total');
|
||||
|
||||
// Check if balanced
|
||||
$isBalanced = bccomp((string)$grandDebitTotal, (string)$grandCreditTotal, 2) === 0;
|
||||
$difference = $grandDebitTotal - $grandCreditTotal;
|
||||
|
||||
// Group accounts by type
|
||||
$accountsByType = $accounts->groupBy(function ($item) {
|
||||
return $item['account']->account_type;
|
||||
});
|
||||
|
||||
return view('admin.trial-balance.index', compact(
|
||||
'accounts',
|
||||
'accountsByType',
|
||||
'startDate',
|
||||
'endDate',
|
||||
'grandDebitTotal',
|
||||
'grandCreditTotal',
|
||||
'isBalanced',
|
||||
'difference'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
409
app/Http/Controllers/IncomeController.php
Normal file
409
app/Http/Controllers/IncomeController.php
Normal file
@@ -0,0 +1,409 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ChartOfAccount;
|
||||
use App\Models\Income;
|
||||
use App\Models\Member;
|
||||
use App\Support\AuditLogger;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class IncomeController extends Controller
|
||||
{
|
||||
/**
|
||||
* 收入列表
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Income::query()
|
||||
->with(['chartOfAccount', 'member', 'recordedByCashier', 'confirmedByAccountant'])
|
||||
->orderByDesc('income_date')
|
||||
->orderByDesc('id');
|
||||
|
||||
// 篩選狀態
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// 篩選收入類型
|
||||
if ($request->filled('income_type')) {
|
||||
$query->where('income_type', $request->income_type);
|
||||
}
|
||||
|
||||
// 篩選付款方式
|
||||
if ($request->filled('payment_method')) {
|
||||
$query->where('payment_method', $request->payment_method);
|
||||
}
|
||||
|
||||
// 篩選會員
|
||||
if ($request->filled('member_id')) {
|
||||
$query->where('member_id', $request->member_id);
|
||||
}
|
||||
|
||||
// 篩選日期範圍
|
||||
if ($request->filled('date_from')) {
|
||||
$query->where('income_date', '>=', $request->date_from);
|
||||
}
|
||||
if ($request->filled('date_to')) {
|
||||
$query->where('income_date', '<=', $request->date_to);
|
||||
}
|
||||
|
||||
$incomes = $query->paginate(20);
|
||||
|
||||
// 統計資料
|
||||
$statistics = [
|
||||
'pending_count' => Income::pending()->count(),
|
||||
'pending_amount' => Income::pending()->sum('amount'),
|
||||
'confirmed_count' => Income::confirmed()->count(),
|
||||
'confirmed_amount' => Income::confirmed()->sum('amount'),
|
||||
];
|
||||
|
||||
return view('admin.incomes.index', [
|
||||
'incomes' => $incomes,
|
||||
'statistics' => $statistics,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增收入表單
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
// 取得收入類會計科目
|
||||
$chartOfAccounts = ChartOfAccount::where('account_type', 'income')
|
||||
->where('is_active', true)
|
||||
->orderBy('account_code')
|
||||
->get();
|
||||
|
||||
// 取得會員列表(可選關聯)
|
||||
$members = Member::orderBy('full_name')->get();
|
||||
|
||||
// 預選會員
|
||||
$selectedMember = null;
|
||||
if ($request->filled('member_id')) {
|
||||
$selectedMember = Member::find($request->member_id);
|
||||
}
|
||||
|
||||
return view('admin.incomes.create', [
|
||||
'chartOfAccounts' => $chartOfAccounts,
|
||||
'members' => $members,
|
||||
'selectedMember' => $selectedMember,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存收入(出納記錄)
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'income_date' => ['required', 'date'],
|
||||
'amount' => ['required', 'numeric', 'min:0.01'],
|
||||
'income_type' => ['required', 'in:membership_fee,entrance_fee,donation,activity,grant,interest,other'],
|
||||
'chart_of_account_id' => ['required', 'exists:chart_of_accounts,id'],
|
||||
'payment_method' => ['required', 'in:cash,bank_transfer,check'],
|
||||
'bank_account' => ['nullable', 'string', 'max:255'],
|
||||
'payer_name' => ['nullable', 'string', 'max:255'],
|
||||
'member_id' => ['nullable', 'exists:members,id'],
|
||||
'receipt_number' => ['nullable', 'string', 'max:255'],
|
||||
'transaction_reference' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
'attachment' => ['nullable', 'file', 'max:10240'],
|
||||
]);
|
||||
|
||||
// 處理附件上傳
|
||||
$attachmentPath = null;
|
||||
if ($request->hasFile('attachment')) {
|
||||
$attachmentPath = $request->file('attachment')->store('incomes', 'local');
|
||||
}
|
||||
|
||||
$income = Income::create([
|
||||
'title' => $validated['title'],
|
||||
'income_date' => $validated['income_date'],
|
||||
'amount' => $validated['amount'],
|
||||
'income_type' => $validated['income_type'],
|
||||
'chart_of_account_id' => $validated['chart_of_account_id'],
|
||||
'payment_method' => $validated['payment_method'],
|
||||
'bank_account' => $validated['bank_account'] ?? null,
|
||||
'payer_name' => $validated['payer_name'] ?? null,
|
||||
'member_id' => $validated['member_id'] ?? null,
|
||||
'receipt_number' => $validated['receipt_number'] ?? null,
|
||||
'transaction_reference' => $validated['transaction_reference'] ?? null,
|
||||
'description' => $validated['description'] ?? null,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
'attachment_path' => $attachmentPath,
|
||||
'status' => Income::STATUS_PENDING,
|
||||
'recorded_by_cashier_id' => $request->user()->id,
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
AuditLogger::log('income.created', $income, $validated);
|
||||
|
||||
return redirect()
|
||||
->route('admin.incomes.show', $income)
|
||||
->with('status', '收入記錄已建立,等待會計確認。收入編號:' . $income->income_number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收入詳情
|
||||
*/
|
||||
public function show(Income $income)
|
||||
{
|
||||
$income->load([
|
||||
'chartOfAccount',
|
||||
'member',
|
||||
'recordedByCashier',
|
||||
'confirmedByAccountant',
|
||||
'cashierLedgerEntry',
|
||||
'accountingEntries.chartOfAccount',
|
||||
]);
|
||||
|
||||
return view('admin.incomes.show', [
|
||||
'income' => $income,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 會計確認收入
|
||||
*/
|
||||
public function confirm(Request $request, Income $income)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// 檢查權限
|
||||
$canConfirm = $user->hasRole('admin') ||
|
||||
$user->hasRole('finance_accountant');
|
||||
|
||||
if (!$canConfirm) {
|
||||
abort(403, '您無權確認此收入。');
|
||||
}
|
||||
|
||||
if (!$income->canBeConfirmed()) {
|
||||
return redirect()
|
||||
->route('admin.incomes.show', $income)
|
||||
->with('error', '此收入無法確認。');
|
||||
}
|
||||
|
||||
try {
|
||||
$income->confirmByAccountant($user);
|
||||
|
||||
AuditLogger::log('income.confirmed', $income, [
|
||||
'confirmed_by' => $user->name,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('admin.incomes.show', $income)
|
||||
->with('status', '收入已確認。已自動產生出納日記帳和會計分錄。');
|
||||
} catch (\Exception $e) {
|
||||
return redirect()
|
||||
->route('admin.incomes.show', $income)
|
||||
->with('error', '確認失敗:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消收入
|
||||
*/
|
||||
public function cancel(Request $request, Income $income)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// 檢查權限
|
||||
$canCancel = $user->hasRole('admin') ||
|
||||
$user->hasRole('finance_accountant');
|
||||
|
||||
if (!$canCancel) {
|
||||
abort(403, '您無權取消此收入。');
|
||||
}
|
||||
|
||||
if (!$income->canBeCancelled()) {
|
||||
return redirect()
|
||||
->route('admin.incomes.show', $income)
|
||||
->with('error', '此收入無法取消。');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'cancel_reason' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$income->cancel();
|
||||
|
||||
AuditLogger::log('income.cancelled', $income, [
|
||||
'cancelled_by' => $user->name,
|
||||
'reason' => $validated['cancel_reason'] ?? null,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('admin.incomes.show', $income)
|
||||
->with('status', '收入已取消。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 收入統計
|
||||
*/
|
||||
public function statistics(Request $request)
|
||||
{
|
||||
$year = $request->input('year', date('Y'));
|
||||
$month = $request->input('month');
|
||||
|
||||
// 依收入類型統計
|
||||
$byTypeQuery = Income::confirmed()
|
||||
->whereYear('income_date', $year);
|
||||
|
||||
if ($month) {
|
||||
$byTypeQuery->whereMonth('income_date', $month);
|
||||
}
|
||||
|
||||
$byType = $byTypeQuery
|
||||
->selectRaw('income_type, SUM(amount) as total_amount, COUNT(*) as count')
|
||||
->groupBy('income_type')
|
||||
->get();
|
||||
|
||||
// 依月份統計
|
||||
$byMonth = Income::confirmed()
|
||||
->whereYear('income_date', $year)
|
||||
->selectRaw("CAST(strftime('%m', income_date) AS INTEGER) as month, SUM(amount) as total_amount, COUNT(*) as count")
|
||||
->groupBy('month')
|
||||
->orderBy('month')
|
||||
->get();
|
||||
|
||||
// 依會計科目統計
|
||||
$byAccountQuery = Income::confirmed()
|
||||
->whereYear('income_date', $year);
|
||||
|
||||
if ($month) {
|
||||
$byAccountQuery->whereMonth('income_date', $month);
|
||||
}
|
||||
|
||||
$byAccountResults = $byAccountQuery
|
||||
->selectRaw('chart_of_account_id, SUM(amount) as total_amount, COUNT(*) as count')
|
||||
->groupBy('chart_of_account_id')
|
||||
->get();
|
||||
|
||||
// 手動載入會計科目關聯
|
||||
$accountIds = $byAccountResults->pluck('chart_of_account_id')->filter()->unique();
|
||||
$accounts = \App\Models\ChartOfAccount::whereIn('id', $accountIds)->get()->keyBy('id');
|
||||
|
||||
$byAccount = $byAccountResults->map(function ($item) use ($accounts) {
|
||||
$item->chartOfAccount = $accounts->get($item->chart_of_account_id);
|
||||
return $item;
|
||||
});
|
||||
|
||||
// 總計
|
||||
$totalQuery = Income::confirmed()
|
||||
->whereYear('income_date', $year);
|
||||
|
||||
if ($month) {
|
||||
$totalQuery->whereMonth('income_date', $month);
|
||||
}
|
||||
|
||||
$total = [
|
||||
'amount' => $totalQuery->sum('amount'),
|
||||
'count' => $totalQuery->count(),
|
||||
];
|
||||
|
||||
return view('admin.incomes.statistics', [
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
'byType' => $byType,
|
||||
'byMonth' => $byMonth,
|
||||
'byAccount' => $byAccount,
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 匯出收入
|
||||
*/
|
||||
public function export(Request $request)
|
||||
{
|
||||
$query = Income::confirmed()
|
||||
->with(['chartOfAccount', 'member', 'recordedByCashier'])
|
||||
->orderByDesc('income_date');
|
||||
|
||||
// 篩選日期範圍
|
||||
if ($request->filled('date_from')) {
|
||||
$query->where('income_date', '>=', $request->date_from);
|
||||
}
|
||||
if ($request->filled('date_to')) {
|
||||
$query->where('income_date', '<=', $request->date_to);
|
||||
}
|
||||
|
||||
$incomes = $query->get();
|
||||
|
||||
// 產生 CSV
|
||||
$filename = 'incomes_' . date('Y-m-d_His') . '.csv';
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||
];
|
||||
|
||||
$callback = function () use ($incomes) {
|
||||
$file = fopen('php://output', 'w');
|
||||
|
||||
// BOM for Excel UTF-8
|
||||
fprintf($file, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
|
||||
// Header
|
||||
fputcsv($file, [
|
||||
'收入編號',
|
||||
'日期',
|
||||
'標題',
|
||||
'金額',
|
||||
'收入類型',
|
||||
'會計科目',
|
||||
'付款方式',
|
||||
'付款人',
|
||||
'會員',
|
||||
'收據編號',
|
||||
'狀態',
|
||||
'記錄人',
|
||||
'確認人',
|
||||
]);
|
||||
|
||||
foreach ($incomes as $income) {
|
||||
fputcsv($file, [
|
||||
$income->income_number,
|
||||
$income->income_date->format('Y-m-d'),
|
||||
$income->title,
|
||||
$income->amount,
|
||||
$income->getIncomeTypeText(),
|
||||
$income->chartOfAccount->account_name_zh ?? '',
|
||||
$income->getPaymentMethodText(),
|
||||
$income->payer_name,
|
||||
$income->member->full_name ?? '',
|
||||
$income->receipt_number,
|
||||
$income->getStatusText(),
|
||||
$income->recordedByCashier->name ?? '',
|
||||
$income->confirmedByAccountant->name ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($file);
|
||||
};
|
||||
|
||||
return response()->stream($callback, 200, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下載附件
|
||||
*/
|
||||
public function download(Income $income)
|
||||
{
|
||||
if (!$income->attachment_path) {
|
||||
abort(404, '找不到附件。');
|
||||
}
|
||||
|
||||
$path = storage_path('app/' . $income->attachment_path);
|
||||
|
||||
if (!file_exists($path)) {
|
||||
abort(404, '附件檔案不存在。');
|
||||
}
|
||||
|
||||
return response()->download($path);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
101
app/Models/AccountingEntry.php
Normal file
101
app/Models/AccountingEntry.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AccountingEntry extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
const ENTRY_TYPE_DEBIT = 'debit';
|
||||
const ENTRY_TYPE_CREDIT = 'credit';
|
||||
|
||||
protected $fillable = [
|
||||
'finance_document_id',
|
||||
'income_id',
|
||||
'chart_of_account_id',
|
||||
'entry_type',
|
||||
'amount',
|
||||
'entry_date',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'entry_date' => '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]);
|
||||
}
|
||||
}
|
||||
427
app/Models/Announcement.php
Normal file
427
app/Models/Announcement.php
Normal file
@@ -0,0 +1,427 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class Announcement extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
// ==================== Constants ====================
|
||||
|
||||
const STATUS_DRAFT = 'draft';
|
||||
const STATUS_PUBLISHED = 'published';
|
||||
const STATUS_ARCHIVED = 'archived';
|
||||
|
||||
const ACCESS_LEVEL_PUBLIC = 'public';
|
||||
const ACCESS_LEVEL_MEMBERS = 'members';
|
||||
const ACCESS_LEVEL_BOARD = 'board';
|
||||
const ACCESS_LEVEL_ADMIN = 'admin';
|
||||
|
||||
// ==================== Configuration ====================
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'content',
|
||||
'status',
|
||||
'is_pinned',
|
||||
'display_order',
|
||||
'access_level',
|
||||
'published_at',
|
||||
'expires_at',
|
||||
'archived_at',
|
||||
'view_count',
|
||||
'created_by_user_id',
|
||||
'last_updated_by_user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_pinned' => '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);
|
||||
}
|
||||
}
|
||||
31
app/Models/BoardMeeting.php
Normal file
31
app/Models/BoardMeeting.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class BoardMeeting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'meeting_date',
|
||||
'title',
|
||||
'notes',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'meeting_date' => 'date',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the finance documents approved by this board meeting.
|
||||
*/
|
||||
public function approvedFinanceDocuments(): HasMany
|
||||
{
|
||||
return $this->hasMany(FinanceDocument::class, 'approved_by_board_meeting_id');
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class CashierLedgerEntry extends Model
|
||||
const PAYMENT_METHOD_CASH = 'cash';
|
||||
|
||||
/**
|
||||
* 關聯到財務申請單
|
||||
* 關聯到報銷申請單
|
||||
*/
|
||||
public function financeDocument(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
446
app/Models/Income.php
Normal file
446
app/Models/Income.php
Normal file
@@ -0,0 +1,446 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Income extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
// 收入類型常數
|
||||
const TYPE_MEMBERSHIP_FEE = 'membership_fee'; // 會費收入
|
||||
const TYPE_ENTRANCE_FEE = 'entrance_fee'; // 入會費收入
|
||||
const TYPE_DONATION = 'donation'; // 捐款收入
|
||||
const TYPE_ACTIVITY = 'activity'; // 活動收入
|
||||
const TYPE_GRANT = 'grant'; // 補助收入
|
||||
const TYPE_INTEREST = 'interest'; // 利息收入
|
||||
const TYPE_OTHER = 'other'; // 其他收入
|
||||
|
||||
// 狀態常數
|
||||
const STATUS_PENDING = 'pending'; // 待確認
|
||||
const STATUS_CONFIRMED = 'confirmed'; // 已確認
|
||||
const STATUS_CANCELLED = 'cancelled'; // 已取消
|
||||
|
||||
// 付款方式常數
|
||||
const PAYMENT_METHOD_CASH = 'cash';
|
||||
const PAYMENT_METHOD_BANK_TRANSFER = 'bank_transfer';
|
||||
const PAYMENT_METHOD_CHECK = 'check';
|
||||
|
||||
protected $fillable = [
|
||||
'income_number',
|
||||
'title',
|
||||
'description',
|
||||
'income_date',
|
||||
'amount',
|
||||
'income_type',
|
||||
'chart_of_account_id',
|
||||
'payment_method',
|
||||
'bank_account',
|
||||
'payer_name',
|
||||
'receipt_number',
|
||||
'transaction_reference',
|
||||
'attachment_path',
|
||||
'member_id',
|
||||
'status',
|
||||
'recorded_by_cashier_id',
|
||||
'recorded_at',
|
||||
'confirmed_by_accountant_id',
|
||||
'confirmed_at',
|
||||
'cashier_ledger_entry_id',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'income_date' => '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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -67,7 +67,7 @@ class PaymentOrder extends Model
|
||||
const PAYMENT_METHOD_CASH = 'cash';
|
||||
|
||||
/**
|
||||
* 關聯到財務申請單
|
||||
* 關聯到報銷申請單
|
||||
*/
|
||||
public function financeDocument(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
154
app/Services/MembershipFeeCalculator.php
Normal file
154
app/Services/MembershipFeeCalculator.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\MembershipPayment;
|
||||
|
||||
class MembershipFeeCalculator
|
||||
{
|
||||
protected SettingsService $settings;
|
||||
|
||||
public function __construct(SettingsService $settings)
|
||||
{
|
||||
$this->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user