Files
usher-manage-stack/app/Http/Controllers/FinanceDocumentController.php
Gbanyan 642b879dd4 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>
2025-12-01 09:56:01 +08:00

412 lines
16 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Mail\FinanceDocumentApprovedByAccountant;
use App\Mail\FinanceDocumentApprovedByCashier;
use App\Mail\FinanceDocumentFullyApproved;
use App\Mail\FinanceDocumentRejected;
use App\Mail\FinanceDocumentSubmitted;
use App\Models\FinanceDocument;
use App\Models\Member;
use App\Models\User;
use App\Support\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
class FinanceDocumentController extends Controller
{
public function index(Request $request)
{
$query = FinanceDocument::query()
->with(['member', 'submittedBy', 'paymentOrder']);
// Filter by status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Filter by amount tier
if ($request->filled('amount_tier')) {
$query->where('amount_tier', $request->amount_tier);
}
// Filter by workflow stage
if ($request->filled('workflow_stage')) {
$stage = $request->workflow_stage;
if ($stage === 'approval') {
$query->whereNull('payment_order_created_at');
} elseif ($stage === 'payment') {
$query->whereNotNull('payment_order_created_at')
->whereNull('payment_executed_at');
} elseif ($stage === 'recording') {
$query->whereNotNull('payment_executed_at')
->where(function($q) {
$q->whereNull('cashier_ledger_entry_id')
->orWhereNull('accounting_transaction_id');
});
} elseif ($stage === 'completed') {
$query->whereNotNull('cashier_ledger_entry_id')
->whereNotNull('accounting_transaction_id');
}
}
$documents = $query->orderByDesc('created_at')->paginate(15);
return view('admin.finance.index', [
'documents' => $documents,
]);
}
public function create(Request $request)
{
$members = Member::orderBy('full_name')->get();
return view('admin.finance.create', [
'members' => $members,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'member_id' => ['nullable', 'exists:members,id'],
'title' => ['required', 'string', 'max:255'],
'amount' => ['required', 'numeric', 'min:0'],
'description' => ['nullable', 'string'],
'attachment' => ['nullable', 'file', 'max:10240'], // 10MB max
]);
$attachmentPath = null;
if ($request->hasFile('attachment')) {
$attachmentPath = $request->file('attachment')->store('finance-documents', 'local');
}
// Create document first to use its determineAmountTier method
$document = new FinanceDocument([
'member_id' => $validated['member_id'] ?? null,
'submitted_by_user_id' => $request->user()->id,
'title' => $validated['title'],
'amount' => $validated['amount'],
'description' => $validated['description'] ?? null,
'attachment_path' => $attachmentPath,
'status' => FinanceDocument::STATUS_PENDING,
'submitted_at' => now(),
]);
// Determine amount tier
$document->amount_tier = $document->determineAmountTier();
// Set if requires board meeting
$document->requires_board_meeting = $document->needsBoardMeetingApproval();
// Save the document
$document->save();
AuditLogger::log('finance_document.created', $document, $validated);
// Send email notification to finance cashiers
$cashiers = User::role('finance_cashier')->get();
foreach ($cashiers as $cashier) {
Mail::to($cashier->email)->queue(new FinanceDocumentSubmitted($document));
}
return redirect()
->route('admin.finance.index')
->with('status', '報銷申請單已提交。金額級別:' . $document->getAmountTierText());
}
public function show(FinanceDocument $financeDocument)
{
$financeDocument->load([
'member',
'submittedBy',
// 新工作流程 relationships
'approvedBySecretary',
'approvedByChair',
'approvedByBoardMeeting',
'requesterConfirmedBy',
'cashierConfirmedBy',
'accountantRecordedBy',
// Legacy relationships
'approvedByCashier',
'approvedByAccountant',
'rejectedBy',
'chartOfAccount',
'budgetItem',
'paymentOrderCreatedByAccountant',
'paymentVerifiedByCashier',
'paymentExecutedByCashier',
'paymentOrder.createdByAccountant',
'paymentOrder.verifiedByCashier',
'paymentOrder.executedByCashier',
'cashierLedgerEntry.recordedByCashier',
'accountingTransaction',
]);
return view('admin.finance.show', [
'document' => $financeDocument,
]);
}
public function approve(Request $request, FinanceDocument $financeDocument)
{
$user = $request->user();
// 新工作流程:秘書長 → 理事長 → 董理事會
$isSecretary = $user->hasRole('secretary_general');
$isChair = $user->hasRole('finance_chair');
$isBoardMember = $user->hasRole('finance_board_member');
$isAdmin = $user->hasRole('admin');
// 秘書長審核(第一階段)
if ($financeDocument->canBeApprovedBySecretary($user) && ($isSecretary || $isAdmin)) {
$financeDocument->update([
'approved_by_secretary_id' => $user->id,
'secretary_approved_at' => now(),
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
]);
AuditLogger::log('finance_document.approved_by_secretary', $financeDocument, [
'approved_by' => $user->name,
'amount_tier' => $financeDocument->amount_tier,
]);
// 小額:審核完成
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', '秘書長已核准。小額申請審核完成,申請人可向出納領款。');
}
// 中額/大額:送交理事長
$chairs = User::role('finance_chair')->get();
foreach ($chairs as $chair) {
Mail::to($chair->email)->queue(new FinanceDocumentApprovedByAccountant($financeDocument));
}
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '秘書長已核准。已送交理事長審核。');
}
// 理事長審核(第二階段:中額或大額)
if ($financeDocument->canBeApprovedByChair($user) && ($isChair || $isAdmin)) {
$financeDocument->update([
'approved_by_chair_id' => $user->id,
'chair_approved_at' => now(),
'status' => FinanceDocument::STATUS_APPROVED_CHAIR,
]);
AuditLogger::log('finance_document.approved_by_chair', $financeDocument, [
'approved_by' => $user->name,
'amount_tier' => $financeDocument->amount_tier,
]);
// 中額:審核完成
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', '理事長已核准。中額申請審核完成,申請人可向出納領款。');
}
// 大額:送交董理事會
$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', '董理事會已核准。審核流程完成,申請人可向出納領款。');
}
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)
{
$validated = $request->validate([
'rejection_reason' => ['required', 'string', 'max:1000'],
]);
$user = $request->user();
// Can be rejected by cashier, accountant, or chair at any stage (except if already rejected or fully approved)
if ($financeDocument->isRejected() || $financeDocument->isFullyApproved()) {
abort(403, '此文件無法駁回。');
}
// Check if user has permission to reject
$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, '您無權駁回此文件。');
}
$financeDocument->update([
'rejected_by_user_id' => $user->id,
'rejected_at' => now(),
'rejection_reason' => $validated['rejection_reason'],
'status' => FinanceDocument::STATUS_REJECTED,
]);
AuditLogger::log('finance_document.rejected', $financeDocument, [
'rejected_by' => $user->name,
'reason' => $validated['rejection_reason'],
'amount_tier' => $financeDocument->amount_tier,
]);
// Send email notification to submitter (rejected)
Mail::to($financeDocument->submittedBy->email)->queue(new FinanceDocumentRejected($financeDocument));
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '報銷申請單已駁回。');
}
public function download(FinanceDocument $financeDocument)
{
if (!$financeDocument->attachment_path) {
abort(404, 'No attachment found.');
}
$path = storage_path('app/' . $financeDocument->attachment_path);
if (!file_exists($path)) {
abort(404, 'Attachment file not found.');
}
return response()->download($path);
}
}