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:
295
app/Console/Commands/ImportMembersCommand.php
Normal file
295
app/Console/Commands/ImportMembersCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
49
app/Http/Requests/StoreFinanceDocumentRequest.php
Normal file
49
app/Http/Requests/StoreFinanceDocumentRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/Http/Requests/StoreIssueRequest.php
Normal file
64
app/Http/Requests/StoreIssueRequest.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
53
app/Http/Requests/StoreMemberRequest.php
Normal file
53
app/Http/Requests/StoreMemberRequest.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
78
app/Http/Requests/UpdateIssueRequest.php
Normal file
78
app/Http/Requests/UpdateIssueRequest.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
52
app/Http/Requests/UpdateMemberRequest.php
Normal file
52
app/Http/Requests/UpdateMemberRequest.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
214
app/Services/FinanceDocumentApprovalService.php
Normal file
214
app/Services/FinanceDocumentApprovalService.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
217
app/Services/PaymentVerificationService.php
Normal file
217
app/Services/PaymentVerificationService.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
214
app/Traits/HasAccountingEntries.php
Normal file
214
app/Traits/HasAccountingEntries.php
Normal 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);
|
||||
}
|
||||
}
|
||||
135
app/Traits/HasApprovalWorkflow.php
Normal file
135
app/Traits/HasApprovalWorkflow.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user