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,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));
}
}
}