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