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

@@ -38,8 +38,7 @@ class TestDataSeeder extends Seeder
// Ensure required seeders have run
$this->call([
RoleSeeder::class,
PaymentVerificationRolesSeeder::class,
FinancialWorkflowPermissionsSeeder::class,
ChartOfAccountSeeder::class,
IssueLabelSeeder::class,
]);
@@ -86,62 +85,68 @@ class TestDataSeeder extends Seeder
$users = [];
// 1. Super Admin
$admin = User::create([
'name' => 'Admin User',
'email' => 'admin@test.com',
'password' => Hash::make('password'),
'is_admin' => true,
]);
$admin = User::firstOrCreate(
['email' => 'admin@test.com'],
[
'name' => 'Admin User',
'password' => Hash::make('password'),
]
);
$admin->assignRole('admin');
$users['admin'] = $admin;
// 2. Payment Cashier
$cashier = User::create([
'name' => 'Cashier User',
'email' => 'cashier@test.com',
'password' => Hash::make('password'),
'is_admin' => true,
]);
$cashier->assignRole('payment_cashier');
// 2. Finance Cashier (整合原 payment_cashier)
$cashier = User::firstOrCreate(
['email' => 'cashier@test.com'],
[
'name' => 'Cashier User',
'password' => Hash::make('password'),
]
);
$cashier->assignRole('finance_cashier');
$users['cashier'] = $cashier;
// 3. Payment Accountant
$accountant = User::create([
'name' => 'Accountant User',
'email' => 'accountant@test.com',
'password' => Hash::make('password'),
'is_admin' => true,
]);
$accountant->assignRole('payment_accountant');
// 3. Finance Accountant (整合原 payment_accountant)
$accountant = User::firstOrCreate(
['email' => 'accountant@test.com'],
[
'name' => 'Accountant User',
'password' => Hash::make('password'),
]
);
$accountant->assignRole('finance_accountant');
$users['accountant'] = $accountant;
// 4. Payment Chair
$chair = User::create([
'name' => 'Chair User',
'email' => 'chair@test.com',
'password' => Hash::make('password'),
'is_admin' => true,
]);
$chair->assignRole('payment_chair');
// 4. Finance Chair (整合原 payment_chair)
$chair = User::firstOrCreate(
['email' => 'chair@test.com'],
[
'name' => 'Chair User',
'password' => Hash::make('password'),
]
);
$chair->assignRole('finance_chair');
$users['chair'] = $chair;
// 5. Membership Manager
$manager = User::create([
'name' => 'Membership Manager',
'email' => 'manager@test.com',
'password' => Hash::make('password'),
'is_admin' => true,
]);
$manager = User::firstOrCreate(
['email' => 'manager@test.com'],
[
'name' => 'Membership Manager',
'password' => Hash::make('password'),
]
);
$manager->assignRole('membership_manager');
$users['manager'] = $manager;
// 6. Regular Member User
$member = User::create([
'name' => 'Regular Member',
'email' => 'member@test.com',
'password' => Hash::make('password'),
'is_admin' => false,
]);
$member = User::firstOrCreate(
['email' => 'member@test.com'],
[
'name' => 'Regular Member',
'password' => Hash::make('password'),
]
);
$users['member'] = $member;
return $users;
@@ -158,97 +163,107 @@ class TestDataSeeder extends Seeder
// 5 Pending Members
for ($i = 0; $i < 5; $i++) {
$members[] = Member::create([
'user_id' => $i === 0 ? $users['member']->id : null,
'full_name' => "待審核會員 {$counter}",
'email' => "pending{$counter}@test.com",
'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT),
'address_line_1' => "測試地址 {$counter}",
'city' => $taiwanCities[array_rand($taiwanCities)],
'postal_code' => '100',
'membership_status' => Member::STATUS_PENDING,
'membership_type' => Member::TYPE_REGULAR,
'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
]);
$members[] = Member::firstOrCreate(
['email' => "pending{$counter}@test.com"],
[
'user_id' => $i === 0 ? $users['member']->id : null,
'full_name' => "待審核會員 {$counter}",
'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT),
'address_line_1' => "測試地址 {$counter}",
'city' => $taiwanCities[array_rand($taiwanCities)],
'postal_code' => '100',
'membership_status' => Member::STATUS_PENDING,
'membership_type' => Member::TYPE_REGULAR,
'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
]
);
$counter++;
}
// 8 Active Members
for ($i = 0; $i < 8; $i++) {
$startDate = now()->subMonths(rand(1, 6));
$members[] = Member::create([
'user_id' => null,
'full_name' => "活躍會員 {$counter}",
'email' => "active{$counter}@test.com",
'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT),
'address_line_1' => "測試地址 {$counter}",
'city' => $taiwanCities[array_rand($taiwanCities)],
'postal_code' => '100',
'membership_status' => Member::STATUS_ACTIVE,
'membership_type' => $i < 6 ? Member::TYPE_REGULAR : ($i === 6 ? Member::TYPE_HONORARY : Member::TYPE_STUDENT),
'membership_started_at' => $startDate,
'membership_expires_at' => $startDate->copy()->addYear(),
'emergency_contact_name' => "緊急聯絡人 {$counter}",
'emergency_contact_phone' => '02-12345678',
'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
]);
$members[] = Member::firstOrCreate(
['email' => "active{$counter}@test.com"],
[
'user_id' => null,
'full_name' => "活躍會員 {$counter}",
'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT),
'address_line_1' => "測試地址 {$counter}",
'city' => $taiwanCities[array_rand($taiwanCities)],
'postal_code' => '100',
'membership_status' => Member::STATUS_ACTIVE,
'membership_type' => $i < 6 ? Member::TYPE_REGULAR : ($i === 6 ? Member::TYPE_HONORARY : Member::TYPE_STUDENT),
'membership_started_at' => $startDate,
'membership_expires_at' => $startDate->copy()->addYear(),
'emergency_contact_name' => "緊急聯絡人 {$counter}",
'emergency_contact_phone' => '02-12345678',
'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
]
);
$counter++;
}
// 3 Expired Members
for ($i = 0; $i < 3; $i++) {
$startDate = now()->subYears(2);
$members[] = Member::create([
'user_id' => null,
'full_name' => "過期會員 {$counter}",
'email' => "expired{$counter}@test.com",
'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT),
'address_line_1' => "測試地址 {$counter}",
'city' => $taiwanCities[array_rand($taiwanCities)],
'postal_code' => '100',
'membership_status' => Member::STATUS_EXPIRED,
'membership_type' => Member::TYPE_REGULAR,
'membership_started_at' => $startDate,
'membership_expires_at' => $startDate->copy()->addYear(),
'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
]);
$members[] = Member::firstOrCreate(
['email' => "expired{$counter}@test.com"],
[
'user_id' => null,
'full_name' => "過期會員 {$counter}",
'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT),
'address_line_1' => "測試地址 {$counter}",
'city' => $taiwanCities[array_rand($taiwanCities)],
'postal_code' => '100',
'membership_status' => Member::STATUS_EXPIRED,
'membership_type' => Member::TYPE_REGULAR,
'membership_started_at' => $startDate,
'membership_expires_at' => $startDate->copy()->addYear(),
'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
]
);
$counter++;
}
// 2 Suspended Members
for ($i = 0; $i < 2; $i++) {
$members[] = Member::create([
'user_id' => null,
'full_name' => "停權會員 {$counter}",
'email' => "suspended{$counter}@test.com",
'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT),
'address_line_1' => "測試地址 {$counter}",
'city' => $taiwanCities[array_rand($taiwanCities)],
'postal_code' => '100',
'membership_status' => Member::STATUS_SUSPENDED,
'membership_type' => Member::TYPE_REGULAR,
'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
]);
$members[] = Member::firstOrCreate(
['email' => "suspended{$counter}@test.com"],
[
'user_id' => null,
'full_name' => "停權會員 {$counter}",
'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT),
'address_line_1' => "測試地址 {$counter}",
'city' => $taiwanCities[array_rand($taiwanCities)],
'postal_code' => '100',
'membership_status' => Member::STATUS_SUSPENDED,
'membership_type' => Member::TYPE_REGULAR,
'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
]
);
$counter++;
}
// 2 Additional Pending Members (total 20)
for ($i = 0; $i < 2; $i++) {
$members[] = Member::create([
'user_id' => null,
'full_name' => "新申請會員 {$counter}",
'email' => "newmember{$counter}@test.com",
'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT),
'address_line_1' => "測試地址 {$counter}",
'city' => $taiwanCities[array_rand($taiwanCities)],
'postal_code' => '100',
'membership_status' => Member::STATUS_PENDING,
'membership_type' => Member::TYPE_REGULAR,
]);
$members[] = Member::firstOrCreate(
['email' => "newmember{$counter}@test.com"],
[
'user_id' => null,
'full_name' => "新申請會員 {$counter}",
'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT),
'address_line_1' => "測試地址 {$counter}",
'city' => $taiwanCities[array_rand($taiwanCities)],
'postal_code' => '100',
'membership_status' => Member::STATUS_PENDING,
'membership_type' => Member::TYPE_REGULAR,
]
);
$counter++;
}
@@ -264,38 +279,41 @@ class TestDataSeeder extends Seeder
$paymentMethods = [
MembershipPayment::METHOD_BANK_TRANSFER,
MembershipPayment::METHOD_CASH,
MembershipPayment::METHOD_CHECK,
];
// 10 Pending Payments
for ($i = 0; $i < 10; $i++) {
$payments[] = MembershipPayment::create([
'member_id' => $members[$i]->id,
'amount' => 1000,
'paid_at' => now()->subDays(rand(1, 10)),
'payment_method' => $paymentMethods[array_rand($paymentMethods)],
'reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT),
'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf',
'status' => MembershipPayment::STATUS_PENDING,
'notes' => '待審核的繳費記錄',
]);
$payments[] = MembershipPayment::firstOrCreate(
['reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT)],
[
'member_id' => $members[$i]->id,
'amount' => 1000,
'paid_at' => now()->subDays(rand(1, 10)),
'payment_method' => $paymentMethods[array_rand($paymentMethods)],
'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf',
'status' => MembershipPayment::STATUS_PENDING,
'notes' => '待審核的繳費記錄',
]
);
}
// 8 Approved by Cashier
for ($i = 10; $i < 18; $i++) {
$payments[] = MembershipPayment::create([
'member_id' => $members[$i % count($members)]->id,
'amount' => 1000,
'paid_at' => now()->subDays(rand(5, 15)),
'payment_method' => $paymentMethods[array_rand($paymentMethods)],
'reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT),
'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf',
'status' => MembershipPayment::STATUS_APPROVED_CASHIER,
'verified_by_cashier_id' => $users['cashier']->id,
'cashier_verified_at' => now()->subDays(rand(3, 12)),
'cashier_notes' => '收據已核對,金額無誤',
'notes' => '已通過出納審核',
]);
$payments[] = MembershipPayment::firstOrCreate(
['reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT)],
[
'member_id' => $members[$i % count($members)]->id,
'amount' => 1000,
'paid_at' => now()->subDays(rand(5, 15)),
'payment_method' => $paymentMethods[array_rand($paymentMethods)],
'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf',
'status' => MembershipPayment::STATUS_APPROVED_CASHIER,
'verified_by_cashier_id' => $users['cashier']->id,
'cashier_verified_at' => now()->subDays(rand(3, 12)),
'cashier_notes' => '收據已核對,金額無誤',
'notes' => '已通過出納審核',
]
);
}
// 6 Approved by Accountant
@@ -303,22 +321,24 @@ class TestDataSeeder extends Seeder
$cashierVerifiedAt = now()->subDays(rand(10, 20));
$accountantVerifiedAt = $cashierVerifiedAt->copy()->addDays(rand(1, 3));
$payments[] = MembershipPayment::create([
'member_id' => $members[$i % count($members)]->id,
'amount' => 1000,
'paid_at' => now()->subDays(rand(15, 25)),
'payment_method' => $paymentMethods[array_rand($paymentMethods)],
'reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT),
'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf',
'status' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT,
'verified_by_cashier_id' => $users['cashier']->id,
'cashier_verified_at' => $cashierVerifiedAt,
'cashier_notes' => '收據已核對,金額無誤',
'verified_by_accountant_id' => $users['accountant']->id,
'accountant_verified_at' => $accountantVerifiedAt,
'accountant_notes' => '帳務核對完成',
'notes' => '已通過會計審核',
]);
$payments[] = MembershipPayment::firstOrCreate(
['reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT)],
[
'member_id' => $members[$i % count($members)]->id,
'amount' => 1000,
'paid_at' => now()->subDays(rand(15, 25)),
'payment_method' => $paymentMethods[array_rand($paymentMethods)],
'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf',
'status' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT,
'verified_by_cashier_id' => $users['cashier']->id,
'cashier_verified_at' => $cashierVerifiedAt,
'cashier_notes' => '收據已核對,金額無誤',
'verified_by_accountant_id' => $users['accountant']->id,
'accountant_verified_at' => $accountantVerifiedAt,
'accountant_notes' => '帳務核對完成',
'notes' => '已通過會計審核',
]
);
}
// 4 Fully Approved (Chair approved - member activated)
@@ -327,42 +347,46 @@ class TestDataSeeder extends Seeder
$accountantVerifiedAt = $cashierVerifiedAt->copy()->addDays(rand(1, 3));
$chairVerifiedAt = $accountantVerifiedAt->copy()->addDays(rand(1, 2));
$payments[] = MembershipPayment::create([
'member_id' => $members[$i % count($members)]->id,
'amount' => 1000,
'paid_at' => now()->subDays(rand(25, 35)),
'payment_method' => $paymentMethods[array_rand($paymentMethods)],
'reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT),
'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf',
'status' => MembershipPayment::STATUS_APPROVED_CHAIR,
'verified_by_cashier_id' => $users['cashier']->id,
'cashier_verified_at' => $cashierVerifiedAt,
'cashier_notes' => '收據已核對,金額無誤',
'verified_by_accountant_id' => $users['accountant']->id,
'accountant_verified_at' => $accountantVerifiedAt,
'accountant_notes' => '帳務核對完成',
'verified_by_chair_id' => $users['chair']->id,
'chair_verified_at' => $chairVerifiedAt,
'chair_notes' => '最終批准',
'notes' => '已完成三階段審核',
]);
$payments[] = MembershipPayment::firstOrCreate(
['reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT)],
[
'member_id' => $members[$i % count($members)]->id,
'amount' => 1000,
'paid_at' => now()->subDays(rand(25, 35)),
'payment_method' => $paymentMethods[array_rand($paymentMethods)],
'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf',
'status' => MembershipPayment::STATUS_APPROVED_CHAIR,
'verified_by_cashier_id' => $users['cashier']->id,
'cashier_verified_at' => $cashierVerifiedAt,
'cashier_notes' => '收據已核對,金額無誤',
'verified_by_accountant_id' => $users['accountant']->id,
'accountant_verified_at' => $accountantVerifiedAt,
'accountant_notes' => '帳務核對完成',
'verified_by_chair_id' => $users['chair']->id,
'chair_verified_at' => $chairVerifiedAt,
'chair_notes' => '最終批准',
'notes' => '已完成三階段審核',
]
);
}
// 2 Rejected Payments
for ($i = 28; $i < 30; $i++) {
$payments[] = MembershipPayment::create([
'member_id' => $members[$i % count($members)]->id,
'amount' => 1000,
'paid_at' => now()->subDays(rand(5, 10)),
'payment_method' => $paymentMethods[array_rand($paymentMethods)],
'reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT),
'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf',
'status' => MembershipPayment::STATUS_REJECTED,
'rejected_by_user_id' => $users['cashier']->id,
'rejected_at' => now()->subDays(rand(3, 8)),
'rejection_reason' => $i === 28 ? '收據影像不清晰,無法辨識' : '金額與收據不符',
'notes' => '已退回',
]);
$payments[] = MembershipPayment::firstOrCreate(
['reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT)],
[
'member_id' => $members[$i % count($members)]->id,
'amount' => 1000,
'paid_at' => now()->subDays(rand(5, 10)),
'payment_method' => $paymentMethods[array_rand($paymentMethods)],
'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf',
'status' => MembershipPayment::STATUS_REJECTED,
'rejected_by_user_id' => $users['cashier']->id,
'rejected_at' => now()->subDays(rand(3, 8)),
'rejection_reason' => $i === 28 ? '收據影像不清晰,無法辨識' : '金額與收據不符',
'notes' => '已退回',
]
);
}
return $payments;
@@ -747,12 +771,12 @@ class TestDataSeeder extends Seeder
$this->command->table(
['Role', 'Email', 'Password', 'Permissions'],
[
['Admin', 'admin@test.com', 'password', 'All permissions'],
['Cashier', 'cashier@test.com', 'password', 'Tier 1 payment verification'],
['Accountant', 'accountant@test.com', 'password', 'Tier 2 payment verification'],
['Chair', 'chair@test.com', 'password', 'Tier 3 payment verification'],
['Manager', 'manager@test.com', 'password', 'Membership activation'],
['Member', 'member@test.com', 'password', 'Member dashboard access'],
['Admin (admin)', 'admin@test.com', 'password', 'All permissions'],
['Finance Cashier (finance_cashier)', 'cashier@test.com', 'password', 'Payment + Finance cashier'],
['Finance Accountant (finance_accountant)', 'accountant@test.com', 'password', 'Payment + Finance accountant'],
['Finance Chair (finance_chair)', 'chair@test.com', 'password', 'Payment + Finance chair'],
['Membership Manager (membership_manager)', 'manager@test.com', 'password', 'Membership activation'],
['Member (no role)', 'member@test.com', 'password', 'Member dashboard access'],
]
);
$this->command->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');