Add phone login support and member import functionality

Features:
- Support login via phone number or email (LoginRequest)
- Add members:import-roster command for Excel roster import
- Merge survey emails with roster data

Code Quality (Phase 1-4):
- Add database locking for balance calculation
- Add self-approval checks for finance workflow
- Create service layer (FinanceDocumentApprovalService, PaymentVerificationService)
- Add HasAccountingEntries and HasApprovalWorkflow traits
- Create FormRequest classes for validation
- Add status-badge component
- Define authorization gates in AuthServiceProvider
- Add accounting config file

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-25 03:08:06 +08:00
parent ed7169b64e
commit 42099759e8
66 changed files with 3492 additions and 3803 deletions

View File

@@ -0,0 +1,295 @@
<?php
namespace App\Console\Commands;
use App\Models\Member;
use App\Models\MembershipPayment;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use PhpOffice\PhpSpreadsheet\IOFactory;
class ImportMembersCommand extends Command
{
protected $signature = 'members:import-roster
{roster : Path to member roster Excel file}
{--survey= : Optional path to survey Excel file with emails}
{--dry-run : Preview import without saving}';
protected $description = 'Import members from association Excel roster file';
protected array $surveyEmails = [];
public function handle(): int
{
$rosterPath = $this->argument('roster');
$surveyPath = $this->option('survey');
$dryRun = $this->option('dry-run');
if (!file_exists($rosterPath)) {
$this->error("Roster file not found: {$rosterPath}");
return 1;
}
// Load survey emails if provided
if ($surveyPath && file_exists($surveyPath)) {
$this->loadSurveyEmails($surveyPath);
$this->info("Loaded {$this->getSurveyEmailCount()} emails from survey.");
}
// Load roster
$spreadsheet = IOFactory::load($rosterPath);
$sheet = $spreadsheet->getActiveSheet();
$rows = $sheet->toArray();
// Skip header row
$header = array_shift($rows);
$this->info("Header: " . implode(', ', array_filter($header)));
// Column mapping (based on 2026-01-23 會員名冊.xlsx structure)
// 序號, 姓名, 民國出生年月日, 性別, 現職, 聯絡地址, 聯絡電話, 身分證字號, 身份別, 申請日期, e-mail
$columnMap = [
0 => 'sequence', // 序號
1 => 'name', // 姓名
2 => 'birth_date', // 民國出生年月日
3 => 'gender', // 性別
4 => 'occupation', // 現職
5 => 'address', // 聯絡地址
6 => 'phone', // 聯絡電話
7 => 'national_id', // 身分證字號
8 => 'member_type', // 身份別
9 => 'apply_date', // 申請日期
10 => 'email', // e-mail
];
$imported = 0;
$skipped = 0;
$errors = [];
DB::beginTransaction();
try {
foreach ($rows as $index => $row) {
$rowNum = $index + 2; // Excel row number (1-indexed + header)
// Skip empty rows
$name = trim($row[1] ?? '');
if (empty($name)) {
continue;
}
$phone = $this->normalizePhone($row[6] ?? '');
$email = $this->resolveEmail($name, $row[10] ?? '');
$nationalId = trim($row[7] ?? '');
$address = trim($row[5] ?? '');
$memberType = $this->mapMemberType($row[8] ?? '');
// Validate phone for password generation
if (strlen($phone) < 4) {
$errors[] = "Row {$rowNum}: {$name} - Invalid phone number for password";
$skipped++;
continue;
}
// Generate password from last 4 digits
$password = substr($phone, -4);
// Check for existing user by email
$existingUser = User::where('email', $email)->first();
if ($existingUser) {
$this->warn("Row {$rowNum}: {$name} - Email already exists: {$email}");
$skipped++;
continue;
}
// Check for existing member by national ID
if ($nationalId) {
$existingMember = Member::where('national_id_hash', hash('sha256', $nationalId))->first();
if ($existingMember) {
$this->warn("Row {$rowNum}: {$name} - National ID already exists");
$skipped++;
continue;
}
}
if ($dryRun) {
$this->line("[DRY RUN] Would import: {$name}, Phone: {$phone}, Email: {$email}, Password: ****{$password}");
$imported++;
continue;
}
// Create User
$user = User::create([
'name' => $name,
'email' => $email,
'password' => Hash::make($password),
]);
// Create Member
$member = Member::create([
'user_id' => $user->id,
'full_name' => $name,
'email' => $email,
'phone' => $phone,
'address_line_1' => $address,
'national_id' => $nationalId ?: null,
'membership_status' => Member::STATUS_ACTIVE,
'membership_type' => $memberType,
'membership_started_at' => now(),
'membership_expires_at' => now()->endOfYear(), // 2026-12-31
]);
// Create fully approved MembershipPayment
MembershipPayment::create([
'member_id' => $member->id,
'fee_type' => MembershipPayment::FEE_TYPE_ENTRANCE,
'amount' => 500, // Standard entrance fee
'base_amount' => 500,
'discount_amount' => 0,
'final_amount' => 500,
'disability_discount' => false,
'payment_method' => MembershipPayment::METHOD_CASH,
'status' => MembershipPayment::STATUS_APPROVED_CHAIR,
'paid_at' => now(),
'notes' => 'Imported from legacy roster - pre-approved',
]);
$this->info("Imported: {$name} ({$email})");
$imported++;
}
if (!$dryRun) {
DB::commit();
}
} catch (\Exception $e) {
DB::rollBack();
$this->error("Import failed: " . $e->getMessage());
return 1;
}
$this->newLine();
$this->info("Import complete!");
$this->info(" Imported: {$imported}");
$this->info(" Skipped: {$skipped}");
if (!empty($errors)) {
$this->newLine();
$this->warn("Errors:");
foreach ($errors as $error) {
$this->line(" - {$error}");
}
}
if ($dryRun) {
$this->newLine();
$this->warn("This was a dry run. No data was saved.");
}
return 0;
}
protected function loadSurveyEmails(string $path): void
{
$spreadsheet = IOFactory::load($path);
$sheet = $spreadsheet->getActiveSheet();
$rows = $sheet->toArray();
// Skip header
array_shift($rows);
foreach ($rows as $row) {
// Survey structure: timestamp, 身份, 姓名, 年齡, 職業, 居住地, 疾病類型, 聯絡用電子郵件, 聯絡電話, 電子郵件地址
// Name is column 2, email is column 9 (電子郵件地址)
$name = trim($row[2] ?? '');
$email = trim($row[9] ?? '');
// Also check 聯絡用電子郵件 (column 7) as fallback
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$email = trim($row[7] ?? '');
}
if ($email && $name && filter_var($email, FILTER_VALIDATE_EMAIL)) {
// Normalize name for matching
$normalizedName = $this->normalizeName($name);
$this->surveyEmails[$normalizedName] = $email;
}
}
}
protected function getSurveyEmailCount(): int
{
return count($this->surveyEmails);
}
protected function resolveEmail(string $name, string $rosterEmail): string
{
// First, use roster email if valid
$rosterEmail = trim($rosterEmail);
if ($rosterEmail && filter_var($rosterEmail, FILTER_VALIDATE_EMAIL)) {
return $rosterEmail;
}
// Second, check survey data
$normalizedName = $this->normalizeName($name);
if (isset($this->surveyEmails[$normalizedName])) {
return $this->surveyEmails[$normalizedName];
}
// Generate placeholder email
$safeName = preg_replace('/[^a-zA-Z0-9]/', '', $this->toPinyin($name));
if (empty($safeName)) {
$safeName = 'member' . time() . rand(100, 999);
}
return strtolower($safeName) . '@member.usher.org.tw';
}
protected function normalizeName(string $name): string
{
// Remove spaces and convert to lowercase for matching
return mb_strtolower(preg_replace('/\s+/', '', $name));
}
protected function normalizePhone(string $phone): string
{
// Remove all non-numeric characters
$phone = preg_replace('/[^0-9]/', '', $phone);
// If phone contains a mobile number (starting with 09), extract it
// This handles cases where home and mobile are concatenated
if (preg_match('/(09\d{8})/', $phone, $matches)) {
return $matches[1];
}
// If no mobile found but has 10 digits starting with 09, use it
if (strlen($phone) === 10 && str_starts_with($phone, '09')) {
return $phone;
}
// Return last 10 digits if longer (might be concatenated numbers)
if (strlen($phone) > 10) {
return substr($phone, -10);
}
return $phone;
}
protected function mapMemberType(string $type): string
{
// Map Chinese member types to constants
$type = trim($type);
return match ($type) {
'榮譽會員' => Member::TYPE_HONORARY,
'終身會員' => Member::TYPE_LIFETIME,
'學生會員' => Member::TYPE_STUDENT,
default => Member::TYPE_REGULAR, // 患者, 家屬, etc.
};
}
protected function toPinyin(string $name): string
{
// Simple romanization for email generation
// Just use a hash-based approach for Chinese names
return 'member' . substr(md5($name), 0, 8);
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Http\Requests\StoreMemberRequest;
use App\Http\Requests\UpdateMemberRequest;
use App\Models\Member;
use App\Support\AuditLogger;
use Spatie\Permission\Models\Role;
@@ -88,22 +90,9 @@ class AdminMemberController extends Controller
return view('admin.members.create');
}
public function store(Request $request)
public function store(StoreMemberRequest $request)
{
$validated = $request->validate([
'full_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'national_id' => ['nullable', 'string', 'max:50'],
'phone' => ['nullable', 'string', 'max:50'],
'address_line_1' => ['nullable', 'string', 'max:255'],
'address_line_2' => ['nullable', 'string', 'max:255'],
'city' => ['nullable', 'string', 'max:120'],
'postal_code' => ['nullable', 'string', 'max:20'],
'emergency_contact_name' => ['nullable', 'string', 'max:255'],
'emergency_contact_phone' => ['nullable', 'string', 'max:50'],
'membership_started_at' => ['nullable', 'date'],
'membership_expires_at' => ['nullable', 'date', 'after_or_equal:membership_started_at'],
]);
$validated = $request->validated();
// Create user account
$user = \App\Models\User::create([
@@ -137,23 +126,9 @@ class AdminMemberController extends Controller
]);
}
public function update(Request $request, Member $member)
public function update(UpdateMemberRequest $request, Member $member)
{
$validated = $request->validate([
'full_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'national_id' => ['nullable', 'string', 'max:50'],
'phone' => ['nullable', 'string', 'max:50'],
'address_line_1' => ['nullable', 'string', 'max:255'],
'address_line_2' => ['nullable', 'string', 'max:255'],
'city' => ['nullable', 'string', 'max:120'],
'postal_code' => ['nullable', 'string', 'max:20'],
'emergency_contact_name' => ['nullable', 'string', 'max:255'],
'emergency_contact_phone' => ['nullable', 'string', 'max:50'],
'membership_started_at' => ['nullable', 'date'],
'membership_expires_at' => ['nullable', 'date', 'after_or_equal:membership_started_at'],
]);
$validated = $request->validated();
$member->update($validated);
AuditLogger::log('member.updated', $member, $validated);

View File

@@ -2,8 +2,8 @@
namespace App\Http\Controllers;
use App\Http\Requests\StoreFinanceDocumentRequest;
use App\Mail\FinanceDocumentApprovedByAccountant;
use App\Mail\FinanceDocumentApprovedByCashier;
use App\Mail\FinanceDocumentFullyApproved;
use App\Mail\FinanceDocumentRejected;
use App\Mail\FinanceDocumentSubmitted;
@@ -12,6 +12,7 @@ use App\Models\Member;
use App\Models\User;
use App\Support\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
class FinanceDocumentController extends Controller
@@ -39,16 +40,16 @@ class FinanceDocumentController extends Controller
$query->whereNull('payment_order_created_at');
} elseif ($stage === 'payment') {
$query->whereNotNull('payment_order_created_at')
->whereNull('payment_executed_at');
->whereNull('payment_executed_at');
} elseif ($stage === 'recording') {
$query->whereNotNull('payment_executed_at')
->where(function($q) {
$q->whereNull('cashier_ledger_entry_id')
->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');
->whereNotNull('accounting_transaction_id');
}
}
@@ -68,15 +69,9 @@ class FinanceDocumentController extends Controller
]);
}
public function store(Request $request)
public function store(StoreFinanceDocumentRequest $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
]);
$validated = $request->validated();
$attachmentPath = null;
if ($request->hasFile('attachment')) {
@@ -114,7 +109,7 @@ class FinanceDocumentController extends Controller
return redirect()
->route('admin.finance.index')
->with('status', '報銷申請單已提交。金額級別:' . $document->getAmountTierText());
->with('status', '報銷申請單已提交。金額級別:'.$document->getAmountTierText());
}
public function show(FinanceDocument $financeDocument)
@@ -275,6 +270,7 @@ class FinanceDocumentController extends Controller
// 檢查是否雙重確認完成
if ($financeDocument->isDisbursementComplete()) {
$financeDocument->update(['disbursement_status' => FinanceDocument::DISBURSEMENT_COMPLETED]);
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '出帳確認完成。等待會計入帳。');
@@ -286,7 +282,7 @@ class FinanceDocumentController extends Controller
}
// 出納確認
if (($isCashier || $isAdmin) && $financeDocument->canCashierConfirmDisbursement()) {
if (($isCashier || $isAdmin) && $financeDocument->canCashierConfirmDisbursement($user)) {
$financeDocument->update([
'cashier_confirmed_at' => now(),
'cashier_confirmed_by_id' => $user->id,
@@ -299,6 +295,7 @@ class FinanceDocumentController extends Controller
// 檢查是否雙重確認完成
if ($financeDocument->isDisbursementComplete()) {
$financeDocument->update(['disbursement_status' => FinanceDocument::DISBURSEMENT_COMPLETED]);
return redirect()
->route('admin.finance.show', $financeDocument)
->with('status', '出帳確認完成。等待會計入帳。');
@@ -322,26 +319,29 @@ class FinanceDocumentController extends Controller
$isAccountant = $user->hasRole('finance_accountant');
$isAdmin = $user->hasRole('admin');
if (!$financeDocument->canAccountantConfirmRecording()) {
if (! $financeDocument->canAccountantConfirmRecording()) {
abort(403, '此文件尚未完成出帳確認,無法入帳。');
}
if (!$isAccountant && !$isAdmin) {
if (! $isAccountant && ! $isAdmin) {
abort(403, '只有會計可以確認入帳。');
}
$financeDocument->update([
'accountant_recorded_at' => now(),
'accountant_recorded_by_id' => $user->id,
'recording_status' => FinanceDocument::RECORDING_COMPLETED,
]);
// 使用交易確保資料完整性:如果會計分錄產生失敗,不應標記為已入帳
DB::transaction(function () use ($financeDocument, $user) {
$financeDocument->update([
'accountant_recorded_at' => now(),
'accountant_recorded_by_id' => $user->id,
'recording_status' => FinanceDocument::RECORDING_COMPLETED,
]);
// 自動產生會計分錄
$financeDocument->autoGenerateAccountingEntries();
// 自動產生會計分錄
$financeDocument->autoGenerateAccountingEntries();
AuditLogger::log('finance_document.accountant_confirmed_recording', $financeDocument, [
'confirmed_by' => $user->name,
]);
AuditLogger::log('finance_document.accountant_confirmed_recording', $financeDocument, [
'confirmed_by' => $user->name,
]);
});
return redirect()
->route('admin.finance.show', $financeDocument)
@@ -369,7 +369,7 @@ class FinanceDocumentController extends Controller
$user->hasRole('finance_chair') ||
$user->hasRole('finance_board_member');
if (!$canReject) {
if (! $canReject) {
abort(403, '您無權駁回此文件。');
}
@@ -396,13 +396,13 @@ class FinanceDocumentController extends Controller
public function download(FinanceDocument $financeDocument)
{
if (!$financeDocument->attachment_path) {
if (! $financeDocument->attachment_path) {
abort(404, 'No attachment found.');
}
$path = storage_path('app/' . $financeDocument->attachment_path);
$path = storage_path('app/'.$financeDocument->attachment_path);
if (!file_exists($path)) {
if (! file_exists($path)) {
abort(404, 'Attachment file not found.');
}

View File

@@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Http\Requests\StoreIssueRequest;
use App\Http\Requests\UpdateIssueRequest;
use App\Models\Issue;
use App\Models\IssueAttachment;
use App\Models\IssueComment;
@@ -108,31 +110,9 @@ class IssueController extends Controller
return view('admin.issues.create', compact('users', 'labels', 'members', 'openIssues'));
}
public function store(Request $request)
public function store(StoreIssueRequest $request)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'issue_type' => ['required', Rule::in([
Issue::TYPE_WORK_ITEM,
Issue::TYPE_PROJECT_TASK,
Issue::TYPE_MAINTENANCE,
Issue::TYPE_MEMBER_REQUEST,
])],
'priority' => ['required', Rule::in([
Issue::PRIORITY_LOW,
Issue::PRIORITY_MEDIUM,
Issue::PRIORITY_HIGH,
Issue::PRIORITY_URGENT,
])],
'assigned_to_user_id' => ['nullable', 'exists:users,id'],
'member_id' => ['nullable', 'exists:members,id'],
'parent_issue_id' => ['nullable', 'exists:issues,id'],
'due_date' => ['nullable', 'date'],
'estimated_hours' => ['nullable', 'numeric', 'min:0'],
'labels' => ['nullable', 'array'],
'labels.*' => ['exists:issue_labels,id'],
]);
$validated = $request->validated();
$issue = DB::transaction(function () use ($validated, $request) {
$issue = Issue::create([
@@ -209,37 +189,10 @@ class IssueController extends Controller
return view('admin.issues.edit', compact('issue', 'users', 'labels', 'members', 'openIssues'));
}
public function update(Request $request, Issue $issue)
public function update(UpdateIssueRequest $request, Issue $issue)
{
if ($issue->isClosed() && !Auth::user()->hasRole('admin')) {
return redirect()->route('admin.issues.show', $issue)
->with('error', __('Cannot edit closed issues.'));
}
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'issue_type' => ['required', Rule::in([
Issue::TYPE_WORK_ITEM,
Issue::TYPE_PROJECT_TASK,
Issue::TYPE_MAINTENANCE,
Issue::TYPE_MEMBER_REQUEST,
])],
'priority' => ['required', Rule::in([
Issue::PRIORITY_LOW,
Issue::PRIORITY_MEDIUM,
Issue::PRIORITY_HIGH,
Issue::PRIORITY_URGENT,
])],
'assigned_to_user_id' => ['nullable', 'exists:users,id'],
'reviewer_id' => ['nullable', 'exists:users,id'],
'member_id' => ['nullable', 'exists:members,id'],
'parent_issue_id' => ['nullable', 'exists:issues,id'],
'due_date' => ['nullable', 'date'],
'estimated_hours' => ['nullable', 'numeric', 'min:0'],
'labels' => ['nullable', 'array'],
'labels.*' => ['exists:issue_labels,id'],
]);
// Authorization is handled by UpdateIssueRequest
$validated = $request->validated();
$issue = DB::transaction(function () use ($issue, $validated) {
$issue->update($validated);

View File

@@ -2,6 +2,7 @@
namespace App\Http\Requests\Auth;
use App\Models\User;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
@@ -27,13 +28,15 @@ class LoginRequest extends FormRequest
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
// Accept email or phone number
'email' => ['required', 'string'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
* Supports login via email or phone number.
*
* @throws \Illuminate\Validation\ValidationException
*/
@@ -41,7 +44,14 @@ class LoginRequest extends FormRequest
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
$loginInput = $this->input('email');
$password = $this->input('password');
$remember = $this->boolean('remember');
// Determine if input is email or phone
$credentials = $this->resolveCredentials($loginInput, $password);
if (! Auth::attempt($credentials, $remember)) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
@@ -52,6 +62,41 @@ class LoginRequest extends FormRequest
RateLimiter::clear($this->throttleKey());
}
/**
* Resolve login credentials based on input type (email or phone).
*/
protected function resolveCredentials(string $loginInput, string $password): array
{
// Check if input looks like an email
if (filter_var($loginInput, FILTER_VALIDATE_EMAIL)) {
return [
'email' => $loginInput,
'password' => $password,
];
}
// Normalize phone number (remove dashes, spaces)
$phone = preg_replace('/[^0-9]/', '', $loginInput);
// Try to find user by phone number in member record
$user = User::whereHas('member', function ($query) use ($phone) {
$query->where('phone', 'like', "%{$phone}%");
})->first();
if ($user) {
return [
'email' => $user->email,
'password' => $password,
];
}
// Fallback: treat as email (will fail auth but proper error message)
return [
'email' => $loginInput,
'password' => $password,
];
}
/**
* Ensure the login request is not rate limited.
*

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreFinanceDocumentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'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
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'title.required' => '請輸入標題',
'title.max' => '標題不得超過 255 字',
'amount.required' => '請輸入金額',
'amount.numeric' => '金額必須為數字',
'amount.min' => '金額不得為負數',
'attachment.max' => '附件大小不得超過 10MB',
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Requests;
use App\Models\Issue;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreIssueRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()->can('create_issues') || $this->user()->hasRole('admin');
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'issue_type' => ['required', Rule::in([
Issue::TYPE_WORK_ITEM,
Issue::TYPE_PROJECT_TASK,
Issue::TYPE_MAINTENANCE,
Issue::TYPE_MEMBER_REQUEST,
])],
'priority' => ['required', Rule::in([
Issue::PRIORITY_LOW,
Issue::PRIORITY_MEDIUM,
Issue::PRIORITY_HIGH,
Issue::PRIORITY_URGENT,
])],
'assigned_to_user_id' => ['nullable', 'exists:users,id'],
'member_id' => ['nullable', 'exists:members,id'],
'parent_issue_id' => ['nullable', 'exists:issues,id'],
'due_date' => ['nullable', 'date'],
'estimated_hours' => ['nullable', 'numeric', 'min:0'],
'labels' => ['nullable', 'array'],
'labels.*' => ['exists:issue_labels,id'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'title.required' => __('Title is required.'),
'issue_type.required' => __('Issue type is required.'),
'issue_type.in' => __('Invalid issue type.'),
'priority.required' => __('Priority is required.'),
'priority.in' => __('Invalid priority level.'),
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreMemberRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()->can('create_members') || $this->user()->hasRole('admin');
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'full_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'national_id' => ['nullable', 'string', 'max:50'],
'phone' => ['nullable', 'string', 'max:50'],
'address_line_1' => ['nullable', 'string', 'max:255'],
'address_line_2' => ['nullable', 'string', 'max:255'],
'city' => ['nullable', 'string', 'max:120'],
'postal_code' => ['nullable', 'string', 'max:20'],
'emergency_contact_name' => ['nullable', 'string', 'max:255'],
'emergency_contact_phone' => ['nullable', 'string', 'max:50'],
'membership_started_at' => ['nullable', 'date'],
'membership_expires_at' => ['nullable', 'date', 'after_or_equal:membership_started_at'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'full_name.required' => __('Full name is required.'),
'email.required' => __('Email is required.'),
'email.email' => __('Please enter a valid email address.'),
'email.unique' => __('This email is already registered.'),
'membership_expires_at.after_or_equal' => __('Expiry date must be after or equal to start date.'),
];
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Http\Requests;
use App\Models\Issue;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class UpdateIssueRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$issue = $this->route('issue');
// Admins can always edit
if ($this->user()->hasRole('admin')) {
return true;
}
// Cannot edit closed issues
if ($issue && $issue->isClosed()) {
return false;
}
return $this->user()->can('edit_issues');
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'issue_type' => ['required', Rule::in([
Issue::TYPE_WORK_ITEM,
Issue::TYPE_PROJECT_TASK,
Issue::TYPE_MAINTENANCE,
Issue::TYPE_MEMBER_REQUEST,
])],
'priority' => ['required', Rule::in([
Issue::PRIORITY_LOW,
Issue::PRIORITY_MEDIUM,
Issue::PRIORITY_HIGH,
Issue::PRIORITY_URGENT,
])],
'assigned_to_user_id' => ['nullable', 'exists:users,id'],
'reviewer_id' => ['nullable', 'exists:users,id'],
'member_id' => ['nullable', 'exists:members,id'],
'parent_issue_id' => ['nullable', 'exists:issues,id'],
'due_date' => ['nullable', 'date'],
'estimated_hours' => ['nullable', 'numeric', 'min:0'],
'labels' => ['nullable', 'array'],
'labels.*' => ['exists:issue_labels,id'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'title.required' => __('Title is required.'),
'issue_type.required' => __('Issue type is required.'),
'issue_type.in' => __('Invalid issue type.'),
'priority.required' => __('Priority is required.'),
'priority.in' => __('Invalid priority level.'),
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateMemberRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()->can('edit_members') || $this->user()->hasRole('admin');
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'full_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'national_id' => ['nullable', 'string', 'max:50'],
'phone' => ['nullable', 'string', 'max:50'],
'address_line_1' => ['nullable', 'string', 'max:255'],
'address_line_2' => ['nullable', 'string', 'max:255'],
'city' => ['nullable', 'string', 'max:120'],
'postal_code' => ['nullable', 'string', 'max:20'],
'emergency_contact_name' => ['nullable', 'string', 'max:255'],
'emergency_contact_phone' => ['nullable', 'string', 'max:50'],
'membership_started_at' => ['nullable', 'date'],
'membership_expires_at' => ['nullable', 'date', 'after_or_equal:membership_started_at'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'full_name.required' => __('Full name is required.'),
'email.required' => __('Email is required.'),
'email.email' => __('Please enter a valid email address.'),
'membership_expires_at.after_or_equal' => __('Expiry date must be after or equal to start date.'),
];
}
}

View File

@@ -38,10 +38,13 @@ class CashierLedgerEntry extends Model
* 類型常數
*/
const ENTRY_TYPE_RECEIPT = 'receipt';
const ENTRY_TYPE_PAYMENT = 'payment';
const PAYMENT_METHOD_BANK_TRANSFER = 'bank_transfer';
const PAYMENT_METHOD_CHECK = 'check';
const PAYMENT_METHOD_CASH = 'cash';
/**
@@ -74,11 +77,13 @@ class CashierLedgerEntry extends Model
/**
* 取得最新餘額(從最後一筆記錄)
* 注意:調用此方法時應在 DB::transaction() 中進行,以確保鎖定生效
*/
public static function getLatestBalance(string $bankAccount = null): float
public static function getLatestBalance(?string $bankAccount = null): float
{
$query = self::orderBy('entry_date', 'desc')
->orderBy('id', 'desc');
->orderBy('id', 'desc')
->lockForUpdate();
if ($bankAccount) {
$query->where('bank_account', $bankAccount);

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\HasAccountingEntries;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -9,43 +10,59 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
class FinanceDocument extends Model
{
use HasFactory;
use HasAccountingEntries, HasFactory;
// 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';
// 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
public const AMOUNT_TIER_MEDIUM = 'medium'; // 5,000 - 50,000
public const AMOUNT_TIER_LARGE = 'large'; // > 50,000
// Reconciliation status constants
public const RECONCILIATION_PENDING = 'pending';
public const RECONCILIATION_MATCHED = 'matched';
public const RECONCILIATION_DISCREPANCY = 'discrepancy';
public const RECONCILIATION_RESOLVED = 'resolved';
// Payment method constants
public const PAYMENT_METHOD_BANK_TRANSFER = 'bank_transfer';
public const PAYMENT_METHOD_CHECK = 'check';
public const PAYMENT_METHOD_CASH = 'cash';
protected $fillable = [
@@ -264,7 +281,7 @@ class FinanceDocument extends Model
$debitTotal = $this->debitEntries()->sum('amount');
$creditTotal = $this->creditEntries()->sum('amount');
return bccomp((string)$debitTotal, (string)$creditTotal, 2) === 0;
return bccomp((string) $debitTotal, (string) $creditTotal, 2) === 0;
}
/**
@@ -295,7 +312,7 @@ class FinanceDocument extends Model
public function autoGenerateAccountingEntries(): void
{
// Only auto-generate if chart_of_account_id is set
if (!$this->chart_of_account_id) {
if (! $this->chart_of_account_id) {
return;
}
@@ -304,7 +321,7 @@ class FinanceDocument extends Model
// Determine if this is income or expense based on request type or account type
$account = $this->chartOfAccount;
if (!$account) {
if (! $account) {
return;
}
@@ -315,7 +332,7 @@ class FinanceDocument extends Model
'entry_type' => AccountingEntry::ENTRY_TYPE_DEBIT,
'amount' => $this->amount,
'entry_date' => $entryDate,
'description' => '收入 - ' . ($this->description ?? $this->title),
'description' => '收入 - '.($this->description ?? $this->title),
];
$entries[] = [
'chart_of_account_id' => $this->chart_of_account_id,
@@ -338,11 +355,11 @@ class FinanceDocument extends Model
'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT,
'amount' => $this->amount,
'entry_date' => $entryDate,
'description' => '支出 - ' . ($this->description ?? $this->title),
'description' => '支出 - '.($this->description ?? $this->title),
];
}
if (!empty($entries)) {
if (! empty($entries)) {
$this->generateAccountingEntries($entries);
}
}
@@ -391,7 +408,7 @@ class FinanceDocument extends Model
return false;
}
if (!in_array($tier, [self::AMOUNT_TIER_MEDIUM, self::AMOUNT_TIER_LARGE])) {
if (! in_array($tier, [self::AMOUNT_TIER_MEDIUM, self::AMOUNT_TIER_LARGE])) {
return false;
}
@@ -404,7 +421,7 @@ class FinanceDocument extends Model
/**
* 新工作流程:董理事會可審核
* 條件:理事長已核准 + 大額
* 條件:理事長已核准 + 大額 + 不能審核自己的申請
*/
public function canBeApprovedByBoard(?User $user = null): bool
{
@@ -418,6 +435,11 @@ class FinanceDocument extends Model
return false;
}
// 防止審核自己的申請(自我核准繞過)
if ($user && $this->submitted_by_user_id && $this->submitted_by_user_id === $user->id) {
return false;
}
return true;
}
@@ -459,7 +481,7 @@ class FinanceDocument extends Model
*/
public function canRequesterConfirmDisbursement(?User $user = null): bool
{
if (!$this->isApprovalComplete()) {
if (! $this->isApprovalComplete()) {
return false;
}
@@ -477,11 +499,11 @@ class FinanceDocument extends Model
/**
* 出納可確認出帳
* 條件:審核完成 + 尚未確認
* 條件:審核完成 + 尚未確認 + 不能確認自己的申請
*/
public function canCashierConfirmDisbursement(): bool
public function canCashierConfirmDisbursement(?User $user = null): bool
{
if (!$this->isApprovalComplete()) {
if (! $this->isApprovalComplete()) {
return false;
}
@@ -489,6 +511,11 @@ class FinanceDocument extends Model
return false;
}
// 防止出納確認自己的申請(自我核准繞過)
if ($user && $this->submitted_by_user_id && $this->submitted_by_user_id === $user->id) {
return false;
}
return true;
}
@@ -553,7 +580,7 @@ class FinanceDocument extends Model
*/
public function getStatusLabelAttribute(): string
{
return match($this->status) {
return match ($this->status) {
self::STATUS_PENDING => '待審核',
self::STATUS_APPROVED_SECRETARY => '秘書長已核准',
self::STATUS_APPROVED_CHAIR => '理事長已核准',
@@ -571,7 +598,7 @@ class FinanceDocument extends Model
*/
public function getDisbursementStatusLabelAttribute(): string
{
if (!$this->isApprovalComplete()) {
if (! $this->isApprovalComplete()) {
return '審核中';
}
@@ -595,7 +622,7 @@ class FinanceDocument extends Model
*/
public function getRecordingStatusLabelAttribute(): string
{
if (!$this->isDisbursementComplete()) {
if (! $this->isDisbursementComplete()) {
return '尚未出帳';
}
@@ -615,15 +642,15 @@ class FinanceDocument extends Model
return '已駁回';
}
if (!$this->isApprovalComplete()) {
if (! $this->isApprovalComplete()) {
return '審核階段';
}
if (!$this->isDisbursementComplete()) {
if (! $this->isDisbursementComplete()) {
return '出帳階段';
}
if (!$this->isRecordingComplete()) {
if (! $this->isRecordingComplete()) {
return '入帳階段';
}
@@ -639,9 +666,12 @@ class FinanceDocument extends Model
*/
public function determineAmountTier(): string
{
if ($this->amount < 5000) {
$smallThreshold = config('accounting.amount_tiers.small_threshold', 5000);
$largeThreshold = config('accounting.amount_tiers.large_threshold', 50000);
if ($this->amount < $smallThreshold) {
return self::AMOUNT_TIER_SMALL;
} elseif ($this->amount <= 50000) {
} elseif ($this->amount <= $largeThreshold) {
return self::AMOUNT_TIER_MEDIUM;
} else {
return self::AMOUNT_TIER_LARGE;
@@ -654,6 +684,7 @@ class FinanceDocument extends Model
public function needsBoardMeetingApproval(): bool
{
$tier = $this->amount_tier ?? $this->determineAmountTier();
return $tier === self::AMOUNT_TIER_LARGE;
}
@@ -826,7 +857,7 @@ class FinanceDocument extends Model
*/
public function getCurrentWorkflowStage(): string
{
if (!$this->isApprovalStageComplete()) {
if (! $this->isApprovalStageComplete()) {
return 'approval';
}

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\HasAccountingEntries;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -10,7 +11,7 @@ use Illuminate\Support\Facades\DB;
class Income extends Model
{
use HasFactory;
use HasAccountingEntries, HasFactory;
// 收入類型常數
const TYPE_MEMBERSHIP_FEE = 'membership_fee'; // 會費收入
@@ -144,11 +145,27 @@ class Income extends Model
}
/**
* 會計分錄
* Override trait's foreign key for accounting entries
*/
public function accountingEntries(): HasMany
protected function getAccountingForeignKey(): string
{
return $this->hasMany(AccountingEntry::class);
return 'income_id';
}
/**
* Override trait's accounting date
*/
protected function getAccountingDate()
{
return $this->income_date ?? $this->created_at ?? now();
}
/**
* Override trait's accounting description
*/
protected function getAccountingDescription(): string
{
return "收入:{$this->title} ({$this->income_number})";
}
// ========== 狀態查詢 ==========
@@ -216,7 +233,7 @@ class Income extends Model
$ledgerEntry = $this->createCashierLedgerEntry();
// 3. 產生會計分錄
$this->generateAccountingEntries();
$this->createIncomeAccountingEntries();
});
}
@@ -263,42 +280,46 @@ class Income extends Model
}
/**
* 產生會計分錄
* 產生會計分錄 (使用 trait 的方法)
*/
protected function generateAccountingEntries(): void
protected function createIncomeAccountingEntries(): void
{
// 借方:資產帳戶(現金或銀行存款)
$assetAccountId = $this->getAssetAccountId();
$assetAccountId = $this->getAssetAccountIdForPaymentMethod();
$description = $this->getAccountingDescription();
$entryDate = $this->getAccountingDate();
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})",
]);
$entries = [
// 借方:資產帳戶(現金或銀行存款)
[
'chart_of_account_id' => $assetAccountId,
'entry_type' => AccountingEntry::ENTRY_TYPE_DEBIT,
'amount' => $this->amount,
'entry_date' => $entryDate,
'description' => $description,
],
// 貸方:收入科目
[
'chart_of_account_id' => $this->chart_of_account_id,
'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT,
'amount' => $this->amount,
'entry_date' => $entryDate,
'description' => $description,
],
];
// 貸方:收入科目
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})",
]);
// Use trait's method
$this->generateAccountingEntries($entries);
}
/**
* 根據付款方式取得資產帳戶 ID
* 根據付款方式取得資產帳戶 ID (使用 config)
*/
protected function getAssetAccountId(): int
protected function getAssetAccountIdForPaymentMethod(): int
{
$accountCode = match ($this->payment_method) {
self::PAYMENT_METHOD_BANK_TRANSFER => '1201', // 銀行存款
self::PAYMENT_METHOD_CHECK => '1201', // 銀行存款
default => '1101', // 現金
self::PAYMENT_METHOD_BANK_TRANSFER,
self::PAYMENT_METHOD_CHECK => config('accounting.account_codes.bank', '1201'),
default => config('accounting.account_codes.cash', '1101'),
};
return ChartOfAccount::where('account_code', $accountCode)->value('id') ?? 1;

View File

@@ -2,13 +2,14 @@
namespace App\Models;
use App\Traits\HasApprovalWorkflow;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class MembershipPayment extends Model
{
use HasFactory;
use HasApprovalWorkflow, HasFactory;
// Status constants
const STATUS_PENDING = 'pending';
@@ -97,12 +98,7 @@ class MembershipPayment extends Model
return $this->belongsTo(User::class, 'rejected_by_user_id');
}
// Status check methods
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
// Status check methods (isPending and isRejected provided by HasApprovalWorkflow trait)
public function isApprovedByCashier(): bool
{
return $this->status === self::STATUS_APPROVED_CASHIER;
@@ -118,10 +114,7 @@ class MembershipPayment extends Model
return $this->status === self::STATUS_APPROVED_CHAIR;
}
public function isRejected(): bool
{
return $this->status === self::STATUS_REJECTED;
}
// isRejected() provided by HasApprovalWorkflow trait
// Workflow validation methods
public function canBeApprovedByCashier(): bool

View File

@@ -2,8 +2,8 @@
namespace App\Providers;
// use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
@@ -21,6 +21,58 @@ class AuthServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
// Define gates that map to Spatie permissions
// These gates are used in controllers with $this->authorize()
// Payment Order gates
Gate::define('create_payment_order', function ($user) {
return $user->can('create_payment_order');
});
Gate::define('verify_payment_order', function ($user) {
return $user->can('verify_payment_order');
});
Gate::define('execute_payment', function ($user) {
return $user->can('execute_payment');
});
// Finance document gates
Gate::define('approve_finance_secretary', function ($user) {
return $user->can('approve_finance_secretary') || $user->hasRole('secretary_general');
});
Gate::define('approve_finance_chair', function ($user) {
return $user->can('approve_finance_chair') || $user->hasRole('finance_chair');
});
Gate::define('approve_finance_board', function ($user) {
return $user->can('approve_finance_board') || $user->hasRole('finance_board_member');
});
// Member management gates
Gate::define('create_members', function ($user) {
return $user->can('create_members') || $user->hasRole(['admin', 'super_admin']);
});
Gate::define('edit_members', function ($user) {
return $user->can('edit_members') || $user->hasRole(['admin', 'super_admin']);
});
// Issue management gates
Gate::define('create_issues', function ($user) {
return $user->can('create_issues') || $user->hasRole(['admin', 'super_admin']);
});
Gate::define('edit_issues', function ($user) {
return $user->can('edit_issues') || $user->hasRole(['admin', 'super_admin']);
});
// Super admin bypass - can do anything
Gate::before(function ($user, $ability) {
if ($user->hasRole('super_admin')) {
return true;
}
});
}
}

View File

@@ -0,0 +1,214 @@
<?php
namespace App\Services;
use App\Mail\FinanceDocumentApprovedByAccountant;
use App\Mail\FinanceDocumentFullyApproved;
use App\Mail\FinanceDocumentRejected;
use App\Models\FinanceDocument;
use App\Models\User;
use App\Support\AuditLogger;
use Illuminate\Support\Facades\Mail;
/**
* Service for handling FinanceDocument approval workflow.
*
* Workflow: Secretary Chair Board (based on amount tier)
* - Small (<5,000): Secretary only
* - Medium (5,000-50,000): Secretary Chair
* - Large (>50,000): Secretary Chair Board
*/
class FinanceDocumentApprovalService
{
/**
* Approve by Secretary (first stage)
*/
public function approveBySecretary(FinanceDocument $document, User $user): array
{
if (! $document->canBeApprovedBySecretary($user)) {
return ['success' => false, 'message' => '無法在此階段進行秘書長審核。'];
}
$document->update([
'approved_by_secretary_id' => $user->id,
'secretary_approved_at' => now(),
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
]);
AuditLogger::log('finance_document.approved_by_secretary', $document, [
'approved_by' => $user->name,
'amount_tier' => $document->amount_tier,
]);
// Small amount: approval complete
if ($document->amount_tier === FinanceDocument::AMOUNT_TIER_SMALL) {
$this->notifySubmitter($document);
return [
'success' => true,
'message' => '秘書長已核准。小額申請審核完成,申請人可向出納領款。',
'complete' => true,
];
}
// Medium/Large: notify chairs
$this->notifyNextApprovers($document, 'finance_chair');
return [
'success' => true,
'message' => '秘書長已核准。已送交理事長審核。',
'complete' => false,
];
}
/**
* Approve by Chair (second stage)
*/
public function approveByChair(FinanceDocument $document, User $user): array
{
if (! $document->canBeApprovedByChair($user)) {
return ['success' => false, 'message' => '無法在此階段進行理事長審核。'];
}
$document->update([
'approved_by_chair_id' => $user->id,
'chair_approved_at' => now(),
'status' => FinanceDocument::STATUS_APPROVED_CHAIR,
]);
AuditLogger::log('finance_document.approved_by_chair', $document, [
'approved_by' => $user->name,
'amount_tier' => $document->amount_tier,
]);
// Medium amount: approval complete
if ($document->amount_tier === FinanceDocument::AMOUNT_TIER_MEDIUM) {
$this->notifySubmitter($document);
return [
'success' => true,
'message' => '理事長已核准。中額申請審核完成,申請人可向出納領款。',
'complete' => true,
];
}
// Large: notify board members
$this->notifyNextApprovers($document, 'finance_board_member');
return [
'success' => true,
'message' => '理事長已核准。大額申請需送交董理事會審核。',
'complete' => false,
];
}
/**
* Approve by Board (third stage)
*/
public function approveByBoard(FinanceDocument $document, User $user): array
{
if (! $document->canBeApprovedByBoard($user)) {
return ['success' => false, 'message' => '無法在此階段進行董理事會審核。'];
}
$document->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', $document, [
'approved_by' => $user->name,
'amount_tier' => $document->amount_tier,
]);
$this->notifySubmitter($document);
return [
'success' => true,
'message' => '董理事會已核准。審核流程完成,申請人可向出納領款。',
'complete' => true,
];
}
/**
* Reject document at any stage
*/
public function reject(FinanceDocument $document, User $user, string $reason): array
{
if ($document->isRejected()) {
return ['success' => false, 'message' => '此申請已被駁回。'];
}
$document->update([
'status' => FinanceDocument::STATUS_REJECTED,
'rejected_by_user_id' => $user->id,
'rejected_at' => now(),
'rejection_reason' => $reason,
]);
AuditLogger::log('finance_document.rejected', $document, [
'rejected_by' => $user->name,
'reason' => $reason,
]);
// Notify submitter
if ($document->submittedBy) {
Mail::to($document->submittedBy->email)->queue(new FinanceDocumentRejected($document));
}
return [
'success' => true,
'message' => '申請已駁回。',
];
}
/**
* Process approval based on user role
*/
public function processApproval(FinanceDocument $document, User $user): array
{
$isSecretary = $user->hasRole('secretary_general');
$isChair = $user->hasRole('finance_chair');
$isBoardMember = $user->hasRole('finance_board_member');
$isAdmin = $user->hasRole('admin');
// Secretary approval
if ($document->canBeApprovedBySecretary($user) && ($isSecretary || $isAdmin)) {
return $this->approveBySecretary($document, $user);
}
// Chair approval
if ($document->canBeApprovedByChair($user) && ($isChair || $isAdmin)) {
return $this->approveByChair($document, $user);
}
// Board approval
if ($document->canBeApprovedByBoard($user) && ($isBoardMember || $isAdmin)) {
return $this->approveByBoard($document, $user);
}
return ['success' => false, 'message' => '您無權在此階段審核此文件。'];
}
/**
* Notify submitter that approval is complete
*/
protected function notifySubmitter(FinanceDocument $document): void
{
if ($document->submittedBy) {
Mail::to($document->submittedBy->email)->queue(new FinanceDocumentFullyApproved($document));
}
}
/**
* Notify next approvers in workflow
*/
protected function notifyNextApprovers(FinanceDocument $document, string $roleName): void
{
$approvers = User::role($roleName)->get();
foreach ($approvers as $approver) {
Mail::to($approver->email)->queue(new FinanceDocumentApprovedByAccountant($document));
}
}
}

View File

@@ -0,0 +1,217 @@
<?php
namespace App\Services;
use App\Mail\MembershipActivatedMail;
use App\Mail\PaymentApprovedByAccountantMail;
use App\Mail\PaymentApprovedByCashierMail;
use App\Mail\PaymentFullyApprovedMail;
use App\Mail\PaymentRejectedMail;
use App\Models\Member;
use App\Models\MembershipPayment;
use App\Models\User;
use App\Support\AuditLogger;
use Illuminate\Support\Facades\Mail;
/**
* Service for handling MembershipPayment verification workflow.
*
* Workflow: Cashier Accountant Chair
*/
class PaymentVerificationService
{
/**
* Approve by Cashier (first tier)
*/
public function approveByCashier(MembershipPayment $payment, User $user, ?string $notes = null): array
{
if (! $payment->canBeApprovedByCashier()) {
return ['success' => false, 'message' => '此付款無法在此階段由出納審核。'];
}
$payment->update([
'status' => MembershipPayment::STATUS_APPROVED_CASHIER,
'verified_by_cashier_id' => $user->id,
'cashier_verified_at' => now(),
'notes' => $notes ?? $payment->notes,
]);
AuditLogger::log('payment.approved_by_cashier', $payment, [
'member_id' => $payment->member_id,
'amount' => $payment->amount,
'verified_by' => $user->id,
]);
// Notify member
$this->notifyMember($payment, PaymentApprovedByCashierMail::class);
// Notify accountants
$this->notifyRole($payment, 'verify_payments_accountant', PaymentApprovedByCashierMail::class);
return [
'success' => true,
'message' => '出納已審核。已送交會計審核。',
];
}
/**
* Approve by Accountant (second tier)
*/
public function approveByAccountant(MembershipPayment $payment, User $user, ?string $notes = null): array
{
if (! $payment->canBeApprovedByAccountant()) {
return ['success' => false, 'message' => '此付款無法在此階段由會計審核。'];
}
$payment->update([
'status' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT,
'verified_by_accountant_id' => $user->id,
'accountant_verified_at' => now(),
'notes' => $notes ?? $payment->notes,
]);
AuditLogger::log('payment.approved_by_accountant', $payment, [
'member_id' => $payment->member_id,
'amount' => $payment->amount,
'verified_by' => $user->id,
]);
// Notify member
$this->notifyMember($payment, PaymentApprovedByAccountantMail::class);
// Notify chairs
$this->notifyRole($payment, 'verify_payments_chair', PaymentApprovedByAccountantMail::class);
return [
'success' => true,
'message' => '會計已審核。已送交主席審核。',
];
}
/**
* Approve by Chair (final tier)
*/
public function approveByChair(MembershipPayment $payment, User $user, ?string $notes = null): array
{
if (! $payment->canBeApprovedByChair()) {
return ['success' => false, 'message' => '此付款無法在此階段由主席審核。'];
}
$payment->update([
'status' => MembershipPayment::STATUS_APPROVED_CHAIR,
'verified_by_chair_id' => $user->id,
'chair_verified_at' => now(),
'notes' => $notes ?? $payment->notes,
]);
AuditLogger::log('payment.approved_by_chair', $payment, [
'member_id' => $payment->member_id,
'amount' => $payment->amount,
'verified_by' => $user->id,
]);
// Activate membership
$activationResult = $this->activateMembership($payment);
// Notify member of full approval
$this->notifyMember($payment, PaymentFullyApprovedMail::class);
if ($activationResult) {
$this->notifyMember($payment, MembershipActivatedMail::class);
}
return [
'success' => true,
'message' => '主席已審核。付款驗證完成,會員資格已啟用。',
'membershipActivated' => $activationResult,
];
}
/**
* Reject payment at any stage
*/
public function reject(MembershipPayment $payment, User $user, string $reason): array
{
if ($payment->isRejected()) {
return ['success' => false, 'message' => '此付款已被拒絕。'];
}
$payment->update([
'status' => MembershipPayment::STATUS_REJECTED,
'rejected_by_user_id' => $user->id,
'rejected_at' => now(),
'rejection_reason' => $reason,
]);
AuditLogger::log('payment.rejected', $payment, [
'member_id' => $payment->member_id,
'amount' => $payment->amount,
'rejected_by' => $user->id,
'reason' => $reason,
]);
// Notify member
$this->notifyMember($payment, PaymentRejectedMail::class);
return [
'success' => true,
'message' => '付款已拒絕。',
];
}
/**
* Activate membership after payment is fully approved
*/
protected function activateMembership(MembershipPayment $payment): bool
{
$member = $payment->member;
if (! $member) {
return false;
}
// Only activate if member is pending
if ($member->membership_status !== Member::STATUS_PENDING) {
return false;
}
// Calculate membership dates
$startDate = now();
$expiryDate = now()->addYear();
$member->update([
'membership_status' => Member::STATUS_ACTIVE,
'membership_started_at' => $startDate,
'membership_expires_at' => $expiryDate,
]);
AuditLogger::log('member.activated_via_payment', $member, [
'payment_id' => $payment->id,
'started_at' => $startDate,
'expires_at' => $expiryDate,
]);
return true;
}
/**
* Notify member with given mail class
*/
protected function notifyMember(MembershipPayment $payment, string $mailClass): void
{
if ($payment->member && $payment->member->email) {
Mail::to($payment->member->email)->queue(new $mailClass($payment));
}
}
/**
* Notify users with given permission
*/
protected function notifyRole(MembershipPayment $payment, string $permission, string $mailClass): void
{
$users = User::permission($permission)->get();
foreach ($users as $user) {
Mail::to($user->email)->queue(new $mailClass($payment));
}
}
}

View File

@@ -0,0 +1,214 @@
<?php
namespace App\Traits;
use App\Models\AccountingEntry;
use App\Models\ChartOfAccount;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Trait for models that can have accounting entries (double-entry bookkeeping).
*
* Usage:
* 1. Add `use HasAccountingEntries;` to your model
* 2. Define the `getAccountingDescription()` method in your model
* 3. Define the `getAccountingDate()` method in your model
* 4. Define the `getAccountingChartOfAccountId()` method if auto-generating entries
*/
trait HasAccountingEntries
{
/**
* Get all accounting entries for this model
*/
public function accountingEntries(): HasMany
{
return $this->hasMany(AccountingEntry::class, $this->getAccountingForeignKey());
}
/**
* Get the foreign key name for accounting entries
*/
protected function getAccountingForeignKey(): string
{
return 'finance_document_id';
}
/**
* Get debit entries for this model
*/
public function debitEntries(): HasMany
{
return $this->accountingEntries()->where('entry_type', AccountingEntry::ENTRY_TYPE_DEBIT);
}
/**
* Get credit entries for this model
*/
public function creditEntries(): HasMany
{
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 model
* 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->getAccountingDate(),
'description' => $entry['description'] ?? $this->getAccountingDescription(),
]);
}
}
/**
* Auto-generate simple accounting entries based on account type
* For basic income/expense transactions
*/
public function autoGenerateAccountingEntries(): void
{
$chartOfAccountId = $this->getAccountingChartOfAccountId();
// Only auto-generate if chart_of_account_id is set
if (! $chartOfAccountId) {
return;
}
$entries = [];
$entryDate = $this->getAccountingDate();
$description = $this->getAccountingDescription();
$amount = $this->getAccountingAmount();
// Get account to determine type
$account = ChartOfAccount::find($chartOfAccountId);
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' => $amount,
'entry_date' => $entryDate,
'description' => '收入 - '.$description,
];
$entries[] = [
'chart_of_account_id' => $chartOfAccountId,
'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT,
'amount' => $amount,
'entry_date' => $entryDate,
'description' => $description,
];
} elseif ($account->account_type === 'expense') {
// Expense: Debit Expense Account, Credit Cash
$entries[] = [
'chart_of_account_id' => $chartOfAccountId,
'entry_type' => AccountingEntry::ENTRY_TYPE_DEBIT,
'amount' => $amount,
'entry_date' => $entryDate,
'description' => $description,
];
$entries[] = [
'chart_of_account_id' => $this->getCashAccountId(),
'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT,
'amount' => $amount,
'entry_date' => $entryDate,
'description' => '支出 - '.$description,
];
}
if (! empty($entries)) {
$this->generateAccountingEntries($entries);
}
}
/**
* Get the cash account ID using config
*/
protected function getCashAccountId(): int
{
static $cashAccountId = null;
if ($cashAccountId === null) {
$cashCode = config('accounting.account_codes.cash', '1101');
$cashAccount = ChartOfAccount::where('account_code', $cashCode)->first();
$cashAccountId = $cashAccount ? $cashAccount->id : 1;
}
return $cashAccountId;
}
/**
* Get the bank account ID using config
*/
protected function getBankAccountId(): int
{
static $bankAccountId = null;
if ($bankAccountId === null) {
$bankCode = config('accounting.account_codes.bank', '1201');
$bankAccount = ChartOfAccount::where('account_code', $bankCode)->first();
$bankAccountId = $bankAccount ? $bankAccount->id : 2;
}
return $bankAccountId;
}
/**
* Get description for accounting entries
* Override in model if needed
*/
protected function getAccountingDescription(): string
{
return $this->description ?? $this->title ?? 'Transaction';
}
/**
* Get date for accounting entries
* Override in model if needed
*/
protected function getAccountingDate()
{
return $this->submitted_at ?? $this->created_at ?? now();
}
/**
* Get chart of account ID for auto-generation
* Override in model if needed
*/
protected function getAccountingChartOfAccountId(): ?int
{
return $this->chart_of_account_id ?? null;
}
/**
* Get amount for accounting entries
* Override in model if needed
*/
protected function getAccountingAmount(): float
{
return (float) ($this->amount ?? 0);
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace App\Traits;
use App\Models\User;
/**
* Trait for models with multi-tier approval workflows.
*
* Usage:
* 1. Add `use HasApprovalWorkflow;` to your model
* 2. Define STATUS_* constants for each approval stage
* 3. Define STATUS_REJECTED constant
* 4. Override methods as needed for custom approval logic
*/
trait HasApprovalWorkflow
{
/**
* Check if document/payment is rejected
*/
public function isRejected(): bool
{
return $this->status === static::STATUS_REJECTED;
}
/**
* Check if document/payment is pending (initial state)
*/
public function isPending(): bool
{
return $this->status === static::STATUS_PENDING;
}
/**
* Check if self-approval is being attempted
* Prevents users from approving their own submissions
*
* @param User|null $user The user attempting to approve
* @param string $submitterField The field containing the submitter's user ID
*/
protected function isSelfApproval(?User $user, string $submitterField = 'submitted_by_user_id'): bool
{
if (! $user) {
return false;
}
$submitterId = $this->{$submitterField};
return $submitterId && $submitterId === $user->id;
}
/**
* Get the human-readable status label
* Override in model for custom labels
*/
public function getStatusLabelAttribute(): string
{
return ucfirst(str_replace('_', ' ', $this->status));
}
/**
* Check if approval can proceed based on current status
*
* @param string $requiredStatus The status required before this approval
* @param User|null $user The user attempting to approve
* @param bool $checkSelfApproval Whether to check for self-approval
*/
protected function canProceedWithApproval(
string $requiredStatus,
?User $user = null,
bool $checkSelfApproval = true
): bool {
if ($this->status !== $requiredStatus) {
return false;
}
if ($checkSelfApproval && $this->isSelfApproval($user)) {
return false;
}
return true;
}
/**
* Get the rejection details
*/
public function getRejectionDetails(): ?array
{
if (! $this->isRejected()) {
return null;
}
return [
'reason' => $this->rejection_reason ?? null,
'rejected_by' => $this->rejectedBy ?? null,
'rejected_at' => $this->rejected_at ?? null,
];
}
/**
* Check if model can be rejected
* Default: can reject if not already rejected
*/
public function canBeRejected(): bool
{
return ! $this->isRejected();
}
/**
* Get the next approver role required
* Override in model to implement specific logic
*/
public function getNextApproverRole(): ?string
{
return null;
}
/**
* Get approval history
* Returns array of approval stages that have been completed
*/
public function getApprovalHistory(): array
{
$history = [];
// This should be overridden in each model to provide specific fields
// Example structure:
// [
// ['stage' => 'cashier', 'user' => User, 'at' => Carbon],
// ['stage' => 'accountant', 'user' => User, 'at' => Carbon],
// ]
return $history;
}
}