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

@@ -11,6 +11,7 @@ use Tests\TestCase;
* Finance Document Model Unit Tests
*
* Tests business logic methods in FinanceDocument model
* Using new workflow: Secretary Chair Board
*/
class FinanceDocumentTest extends TestCase
{
@@ -77,15 +78,15 @@ class FinanceDocumentTest extends TestCase
}
/** @test */
public function small_amount_approval_stage_is_complete_after_accountant()
public function small_amount_approval_stage_is_complete_after_secretary()
{
$document = new FinanceDocument([
'amount' => 3000,
'amount_tier' => 'small',
'status' => FinanceDocument::STATUS_APPROVED_ACCOUNTANT,
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
]);
$this->assertTrue($document->isApprovalStageComplete());
$this->assertTrue($document->isApprovalComplete());
}
/** @test */
@@ -94,13 +95,13 @@ class FinanceDocumentTest extends TestCase
$document = new FinanceDocument([
'amount' => 25000,
'amount_tier' => 'medium',
'status' => FinanceDocument::STATUS_APPROVED_ACCOUNTANT,
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
]);
$this->assertFalse($document->isApprovalStageComplete());
$this->assertFalse($document->isApprovalComplete());
$document->status = FinanceDocument::STATUS_APPROVED_CHAIR;
$this->assertTrue($document->isApprovalStageComplete());
$this->assertTrue($document->isApprovalComplete());
}
/** @test */
@@ -110,67 +111,46 @@ class FinanceDocumentTest extends TestCase
'amount' => 75000,
'amount_tier' => 'large',
'status' => FinanceDocument::STATUS_APPROVED_CHAIR,
'board_meeting_approved_at' => null,
]);
$this->assertFalse($document->isApprovalStageComplete());
$this->assertFalse($document->isApprovalComplete());
$document->board_meeting_approved_at = now();
$this->assertTrue($document->isApprovalStageComplete());
$document->status = FinanceDocument::STATUS_APPROVED_BOARD;
$this->assertTrue($document->isApprovalComplete());
}
/** @test */
public function cashier_cannot_approve_own_submission()
public function secretary_cannot_approve_own_submission()
{
$user = User::factory()->create();
$document = new FinanceDocument([
'submitted_by_id' => $user->id,
'status' => 'pending',
'submitted_by_user_id' => $user->id,
'status' => FinanceDocument::STATUS_PENDING,
]);
$this->assertFalse($document->canBeApprovedByCashier($user));
$this->assertFalse($document->canBeApprovedBySecretary($user));
}
/** @test */
public function cashier_can_approve_others_submission()
public function secretary_can_approve_others_submission()
{
$submitter = User::factory()->create();
$cashier = User::factory()->create();
$secretary = User::factory()->create();
$document = new FinanceDocument([
'submitted_by_id' => $submitter->id,
'status' => 'pending',
'submitted_by_user_id' => $submitter->id,
'status' => FinanceDocument::STATUS_PENDING,
]);
$this->assertTrue($document->canBeApprovedByCashier($cashier));
$this->assertTrue($document->canBeApprovedBySecretary($secretary));
}
/** @test */
public function accountant_cannot_approve_before_cashier()
public function chair_cannot_approve_before_secretary()
{
$document = new FinanceDocument([
'status' => 'pending',
]);
$this->assertFalse($document->canBeApprovedByAccountant());
}
/** @test */
public function accountant_can_approve_after_cashier()
{
$document = new FinanceDocument([
'status' => FinanceDocument::STATUS_APPROVED_CASHIER,
]);
$this->assertTrue($document->canBeApprovedByAccountant());
}
/** @test */
public function chair_cannot_approve_before_accountant()
{
$document = new FinanceDocument([
'status' => FinanceDocument::STATUS_APPROVED_CASHIER,
'status' => FinanceDocument::STATUS_PENDING,
'amount_tier' => 'medium',
]);
@@ -178,10 +158,21 @@ class FinanceDocumentTest extends TestCase
}
/** @test */
public function chair_can_approve_after_accountant_for_medium_amounts()
public function chair_can_approve_after_secretary()
{
$document = new FinanceDocument([
'status' => FinanceDocument::STATUS_APPROVED_ACCOUNTANT,
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
'amount_tier' => 'medium',
]);
$this->assertTrue($document->canBeApprovedByChair());
}
/** @test */
public function chair_can_approve_after_secretary_for_medium_amounts()
{
$document = new FinanceDocument([
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
'amount_tier' => 'medium',
]);
@@ -194,7 +185,7 @@ class FinanceDocumentTest extends TestCase
$document = new FinanceDocument([
'amount' => 3000,
'amount_tier' => 'small',
'status' => FinanceDocument::STATUS_APPROVED_ACCOUNTANT,
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
]);
$this->assertTrue($document->canCreatePaymentOrder());
@@ -204,40 +195,31 @@ class FinanceDocumentTest extends TestCase
public function payment_order_cannot_be_created_before_approval_complete()
{
$document = new FinanceDocument([
'status' => FinanceDocument::STATUS_APPROVED_CASHIER,
'status' => FinanceDocument::STATUS_PENDING,
'amount_tier' => 'small',
]);
$this->assertFalse($document->canCreatePaymentOrder());
}
/** @test */
public function workflow_stages_are_correctly_identified()
public function disbursement_requires_dual_confirmation()
{
$document = new FinanceDocument([
'status' => 'pending',
'amount' => 3000,
'amount_tier' => 'small',
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
]);
// Stage 1: Approval
$this->assertEquals('approval', $document->getCurrentWorkflowStage());
$this->assertFalse($document->isDisbursementComplete());
// Stage 2: Payment
$document->status = FinanceDocument::STATUS_APPROVED_ACCOUNTANT;
$document->cashier_approved_at = now();
$document->accountant_approved_at = now();
$document->payment_order_created_at = now();
$this->assertEquals('payment', $document->getCurrentWorkflowStage());
// Only requester confirmed
$document->requester_confirmed_at = now();
$this->assertFalse($document->isDisbursementComplete());
// Stage 3: Recording
$document->payment_executed_at = now();
$this->assertEquals('payment', $document->getCurrentWorkflowStage());
$document->cashier_recorded_at = now();
$this->assertEquals('recording', $document->getCurrentWorkflowStage());
// Stage 4: Reconciliation
$document->bank_reconciliation_id = 1;
$this->assertEquals('completed', $document->getCurrentWorkflowStage());
// Both confirmed
$document->cashier_confirmed_at = now();
$this->assertTrue($document->isDisbursementComplete());
}
/** @test */
@@ -259,12 +241,12 @@ class FinanceDocumentTest extends TestCase
public function recording_complete_check_works()
{
$document = new FinanceDocument([
'cashier_recorded_at' => null,
'accountant_recorded_at' => null,
]);
$this->assertFalse($document->isRecordingComplete());
$document->cashier_recorded_at = now();
$document->accountant_recorded_at = now();
$this->assertTrue($document->isRecordingComplete());
}
@@ -281,22 +263,6 @@ class FinanceDocumentTest extends TestCase
$this->assertTrue($document->isReconciled());
}
/** @test */
public function request_type_text_is_correct()
{
$doc1 = new FinanceDocument(['request_type' => 'expense_reimbursement']);
$this->assertEquals('費用報銷', $doc1->getRequestTypeText());
$doc2 = new FinanceDocument(['request_type' => 'advance_payment']);
$this->assertEquals('預支款項', $doc2->getRequestTypeText());
$doc3 = new FinanceDocument(['request_type' => 'purchase_request']);
$this->assertEquals('採購申請', $doc3->getRequestTypeText());
$doc4 = new FinanceDocument(['request_type' => 'petty_cash']);
$this->assertEquals('零用金', $doc4->getRequestTypeText());
}
/** @test */
public function amount_tier_text_is_correct()
{
@@ -309,4 +275,25 @@ class FinanceDocumentTest extends TestCase
$large = new FinanceDocument(['amount_tier' => 'large']);
$this->assertEquals('大額(> 50000', $large->getAmountTierText());
}
/** @test */
public function fully_processed_check_works()
{
$document = new FinanceDocument([
'amount' => 3000,
'amount_tier' => 'small',
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
]);
$this->assertFalse($document->isFullyProcessed());
// Complete disbursement
$document->requester_confirmed_at = now();
$document->cashier_confirmed_at = now();
$this->assertFalse($document->isFullyProcessed());
// Complete recording
$document->accountant_recorded_at = now();
$this->assertTrue($document->isFullyProcessed());
}
}