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

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

View File

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

View File

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

View File

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