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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user