Add membership fee system with disability discount and fix document permissions

Features:
- Implement two fee types: entrance fee and annual fee (both NT$1,000)
- Add 50% discount for disability certificate holders
- Add disability certificate upload in member profile
- Integrate disability verification into cashier approval workflow
- Add membership fee settings in system admin

Document permissions:
- Fix hard-coded role logic in Document model
- Use permission-based authorization instead of role checks

Additional features:
- Add announcements, general ledger, and trial balance modules
- Add income management and accounting entries
- Add comprehensive test suite with factories
- Update UI translations to Traditional Chinese

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-01 09:56:01 +08:00
parent 83ce1f7fc8
commit 642b879dd4
207 changed files with 19487 additions and 3048 deletions

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* 移除 is_admin 欄位,統一使用 Spatie Permission admin 角色進行權限管理
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_admin');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_admin')->default(false)->after('email');
});
}
};

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (Schema::hasTable('cashier_ledger_entries')) {
return;
}
Schema::create('cashier_ledger_entries', function (Blueprint $table) {
$table->id();
$table->foreignId('finance_document_id')->nullable()->constrained()->nullOnDelete();
$table->date('entry_date');
$table->string('entry_type'); // receipt, payment
$table->string('payment_method'); // bank_transfer, check, cash
$table->string('bank_account')->nullable();
$table->decimal('amount', 12, 2);
$table->decimal('balance_before', 12, 2)->default(0);
$table->decimal('balance_after', 12, 2)->default(0);
$table->string('receipt_number')->nullable();
$table->string('transaction_reference')->nullable();
$table->foreignId('recorded_by_cashier_id')->constrained('users');
$table->timestamp('recorded_at');
$table->text('notes')->nullable();
$table->timestamps();
$table->index('entry_date');
$table->index('entry_type');
$table->index('bank_account');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cashier_ledger_entries');
}
};

View File

@@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('announcements', function (Blueprint $table) {
$table->id();
// 基本資訊
$table->string('title');
$table->text('content');
// 狀態管理
$table->enum('status', ['draft', 'published', 'archived'])->default('draft');
// 顯示控制
$table->boolean('is_pinned')->default(false);
$table->integer('display_order')->default(0);
// 訪問控制
$table->enum('access_level', ['public', 'members', 'board', 'admin'])->default('members');
// 時間控制
$table->timestamp('published_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('archived_at')->nullable();
// 統計
$table->integer('view_count')->default(0);
// 用戶關聯
$table->foreignId('created_by_user_id')->constrained('users')->onDelete('cascade');
$table->foreignId('last_updated_by_user_id')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
// 索引
$table->index('status');
$table->index('access_level');
$table->index('published_at');
$table->index('expires_at');
$table->index(['is_pinned', 'display_order']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('announcements');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('accounting_entries', function (Blueprint $table) {
$table->id();
$table->foreignId('finance_document_id')->constrained()->onDelete('cascade');
$table->foreignId('chart_of_account_id')->constrained();
$table->enum('entry_type', ['debit', 'credit']);
$table->decimal('amount', 15, 2);
$table->date('entry_date');
$table->text('description')->nullable();
$table->timestamps();
// Indexes for performance
$table->index(['finance_document_id', 'entry_type']);
$table->index(['chart_of_account_id', 'entry_date']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('accounting_entries');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('board_meetings', function (Blueprint $table) {
$table->id();
$table->date('meeting_date');
$table->string('title');
$table->text('notes')->nullable();
$table->enum('status', ['scheduled', 'completed', 'cancelled'])->default('scheduled');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('board_meetings');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('finance_documents', function (Blueprint $table) {
// 只新增尚未存在的欄位
if (!Schema::hasColumn('finance_documents', 'accountant_recorded_by_id')) {
$table->unsignedBigInteger('accountant_recorded_by_id')->nullable();
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('finance_documents', function (Blueprint $table) {
if (Schema::hasColumn('finance_documents', 'accountant_recorded_by_id')) {
$table->dropColumn('accountant_recorded_by_id');
}
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('finance_documents', function (Blueprint $table) {
if (Schema::hasColumn('finance_documents', 'request_type')) {
$table->dropColumn('request_type');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('finance_documents', function (Blueprint $table) {
if (!Schema::hasColumn('finance_documents', 'request_type')) {
$table->string('request_type')->nullable()->after('amount');
}
});
}
};

View File

@@ -0,0 +1,92 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('incomes', function (Blueprint $table) {
$table->id();
// 基本資訊
$table->string('income_number')->unique(); // 收入編號INC-2025-0001
$table->string('title'); // 收入標題
$table->text('description')->nullable(); // 說明
$table->date('income_date'); // 收入日期
$table->decimal('amount', 12, 2); // 金額
// 收入分類
$table->string('income_type'); // 收入類型
$table->foreignId('chart_of_account_id') // 會計科目
->constrained('chart_of_accounts');
// 付款資訊
$table->string('payment_method'); // 付款方式
$table->string('bank_account')->nullable(); // 銀行帳戶
$table->string('payer_name')->nullable(); // 付款人姓名
$table->string('receipt_number')->nullable(); // 收據編號
$table->string('transaction_reference')->nullable(); // 銀行交易參考號
$table->string('attachment_path')->nullable(); // 附件路徑
// 會員關聯
$table->foreignId('member_id')->nullable()
->constrained()->nullOnDelete();
// 審核流程
$table->string('status')->default('pending'); // pending, confirmed, cancelled
// 出納記錄
$table->foreignId('recorded_by_cashier_id')
->constrained('users');
$table->timestamp('recorded_at');
// 會計確認
$table->foreignId('confirmed_by_accountant_id')->nullable()
->constrained('users');
$table->timestamp('confirmed_at')->nullable();
// 關聯出納日記帳
$table->foreignId('cashier_ledger_entry_id')->nullable()
->constrained('cashier_ledger_entries')->nullOnDelete();
$table->text('notes')->nullable();
$table->timestamps();
// 索引
$table->index('income_date');
$table->index('income_type');
$table->index('status');
$table->index(['member_id', 'income_type']);
});
// 在 accounting_entries 表新增 income_id 欄位
Schema::table('accounting_entries', function (Blueprint $table) {
if (!Schema::hasColumn('accounting_entries', 'income_id')) {
$table->foreignId('income_id')->nullable()
->after('finance_document_id')
->constrained('incomes')->nullOnDelete();
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('accounting_entries', function (Blueprint $table) {
if (Schema::hasColumn('accounting_entries', 'income_id')) {
$table->dropForeign(['income_id']);
$table->dropColumn('income_id');
}
});
Schema::dropIfExists('incomes');
}
};

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('members', function (Blueprint $table) {
// 身心障礙手冊相關欄位
$table->string('disability_certificate_path')->nullable()->after('membership_type');
$table->string('disability_certificate_status')->nullable()->after('disability_certificate_path');
$table->foreignId('disability_verified_by')->nullable()->after('disability_certificate_status')
->constrained('users')->nullOnDelete();
$table->timestamp('disability_verified_at')->nullable()->after('disability_verified_by');
$table->text('disability_rejection_reason')->nullable()->after('disability_verified_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('members', function (Blueprint $table) {
$table->dropForeign(['disability_verified_by']);
$table->dropColumn([
'disability_certificate_path',
'disability_certificate_status',
'disability_verified_by',
'disability_verified_at',
'disability_rejection_reason',
]);
});
}
};

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('membership_payments', function (Blueprint $table) {
// 會費類型欄位
$table->string('fee_type')->default('entrance_fee')->after('member_id');
$table->decimal('base_amount', 10, 2)->nullable()->after('amount');
$table->decimal('discount_amount', 10, 2)->default(0)->after('base_amount');
$table->decimal('final_amount', 10, 2)->nullable()->after('discount_amount');
$table->boolean('disability_discount')->default(false)->after('final_amount');
});
// 為現有記錄設定預設值
\DB::statement("UPDATE membership_payments SET base_amount = amount, final_amount = amount WHERE base_amount IS NULL");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('membership_payments', function (Blueprint $table) {
$table->dropColumn([
'fee_type',
'base_amount',
'discount_amount',
'final_amount',
'disability_discount',
]);
});
}
};