diff --git a/app/Models/Tenants/ExpectedExpense.php b/app/Models/Tenants/ExpectedExpense.php index 7ad94e1..0600839 100644 --- a/app/Models/Tenants/ExpectedExpense.php +++ b/app/Models/Tenants/ExpectedExpense.php @@ -3,8 +3,10 @@ namespace App\Models\Tenants; use App\Traits\BelongsToTenant; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\SoftDeletes; class ExpectedExpense extends Model @@ -24,6 +26,8 @@ class ExpectedExpense extends Model 'payment_status', 'approval_status', 'description', + 'source_type', + 'source_id', 'created_by', 'updated_by', 'deleted_by', @@ -42,6 +46,8 @@ class ExpectedExpense extends Model */ public const TRANSACTION_TYPES = [ 'purchase' => '매입', + 'card' => '카드결제', + 'bill' => '발행어음', 'advance' => '선급금', 'suspense' => '가지급금', 'rent' => '임대료', @@ -131,4 +137,29 @@ public function getApprovalStatusLabelAttribute(): string { return self::APPROVAL_STATUSES[$this->approval_status] ?? $this->approval_status; } + + /** + * 원본 소스 관계 (Polymorphic) + */ + public function source(): MorphTo + { + return $this->morphTo(); + } + + /** + * 원본 소스로 검색 + */ + public function scopeBySource(Builder $query, string $sourceType, int $sourceId): Builder + { + return $query->where('source_type', $sourceType) + ->where('source_id', $sourceId); + } + + /** + * 동기화된 레코드 여부 + */ + public function isSynced(): bool + { + return ! is_null($this->source_type) && ! is_null($this->source_id); + } } diff --git a/app/Observers/ExpenseSync/BillExpenseSyncObserver.php b/app/Observers/ExpenseSync/BillExpenseSyncObserver.php new file mode 100644 index 0000000..27003ba --- /dev/null +++ b/app/Observers/ExpenseSync/BillExpenseSyncObserver.php @@ -0,0 +1,169 @@ +isIssuedBill($bill)) { + $this->syncToExpectedExpense($bill); + } + } + + /** + * 어음 수정 시 → 발행어음 상태 변경 처리 + */ + public function updated(Bill $bill): void + { + $wasIssued = $bill->getOriginal('bill_type') === 'issued'; + $isIssued = $this->isIssuedBill($bill); + + if ($isIssued) { + // 발행어음 → 동기화 + $this->syncToExpectedExpense($bill); + } elseif ($wasIssued && ! $isIssued) { + // 발행어음에서 수취어음으로 변경 → 예상 지출 삭제 + $this->deleteExpectedExpense($bill); + } + } + + /** + * 어음 삭제 시 → 예상 지출 삭제 + */ + public function deleted(Bill $bill): void + { + if ($this->isIssuedBill($bill)) { + $this->deleteExpectedExpense($bill); + } + } + + /** + * 어음 복원 시 → 발행어음인 경우 예상 지출 복원 + */ + public function restored(Bill $bill): void + { + if (! $this->isIssuedBill($bill)) { + return; + } + + $expense = ExpectedExpense::withTrashed() + ->withoutGlobalScopes() + ->bySource('bills', $bill->id) + ->first(); + + if ($expense) { + $expense->restore(); + $this->updateExpectedExpense($expense, $bill); + } else { + $this->createExpectedExpense($bill); + } + } + + /** + * 어음 강제 삭제 시 → 예상 지출 강제 삭제 + */ + public function forceDeleted(Bill $bill): void + { + ExpectedExpense::withTrashed() + ->withoutGlobalScopes() + ->bySource('bills', $bill->id) + ->forceDelete(); + } + + /** + * 발행어음 여부 확인 + */ + protected function isIssuedBill(Bill $bill): bool + { + return $bill->bill_type === 'issued'; + } + + /** + * 예상 지출에 동기화 + */ + protected function syncToExpectedExpense(Bill $bill): void + { + $expense = ExpectedExpense::withoutGlobalScopes() + ->bySource('bills', $bill->id) + ->first(); + + if ($expense) { + $this->updateExpectedExpense($expense, $bill); + } else { + $this->createExpectedExpense($bill); + } + } + + /** + * 예상 지출 삭제 + */ + protected function deleteExpectedExpense(Bill $bill): void + { + ExpectedExpense::withoutGlobalScopes() + ->bySource('bills', $bill->id) + ->delete(); + } + + /** + * 어음 상태 → 지급상태 매핑 + */ + protected function mapPaymentStatus(string $billStatus): string + { + return match ($billStatus) { + 'paymentComplete', 'collectionComplete' => 'paid', + 'dishonored' => 'overdue', + default => 'pending', + }; + } + + /** + * 예상 지출 생성 + */ + protected function createExpectedExpense(Bill $bill): void + { + ExpectedExpense::create([ + 'tenant_id' => $bill->tenant_id, + 'expected_payment_date' => $bill->maturity_date, // 만기일을 예상 지급일로 + 'transaction_type' => 'bill', + 'amount' => $bill->amount, + 'client_id' => $bill->client_id, + 'client_name' => $bill->client_name, + 'bank_account_id' => $bill->bank_account_id, + 'description' => $bill->note ?? "어음번호: {$bill->bill_number}", + 'payment_status' => $this->mapPaymentStatus($bill->status), + 'approval_status' => 'none', + 'source_type' => 'bills', + 'source_id' => $bill->id, + 'created_by' => $bill->created_by, + 'updated_by' => $bill->updated_by, + ]); + } + + /** + * 예상 지출 업데이트 + */ + protected function updateExpectedExpense(ExpectedExpense $expense, Bill $bill): void + { + $expense->update([ + 'expected_payment_date' => $bill->maturity_date, + 'amount' => $bill->amount, + 'client_id' => $bill->client_id, + 'client_name' => $bill->client_name, + 'bank_account_id' => $bill->bank_account_id, + 'description' => $bill->note ?? "어음번호: {$bill->bill_number}", + 'payment_status' => $this->mapPaymentStatus($bill->status), + 'updated_by' => $bill->updated_by, + ]); + } +} diff --git a/app/Observers/ExpenseSync/PurchaseExpenseSyncObserver.php b/app/Observers/ExpenseSync/PurchaseExpenseSyncObserver.php new file mode 100644 index 0000000..c6bf077 --- /dev/null +++ b/app/Observers/ExpenseSync/PurchaseExpenseSyncObserver.php @@ -0,0 +1,120 @@ +syncToExpectedExpense($purchase); + } + + /** + * 매입 수정 시 → 예상 지출 업데이트 + */ + public function updated(Purchase $purchase): void + { + $this->syncToExpectedExpense($purchase); + } + + /** + * 매입 삭제 시 → 예상 지출 삭제 + */ + public function deleted(Purchase $purchase): void + { + ExpectedExpense::withoutGlobalScopes() + ->bySource('purchases', $purchase->id) + ->delete(); + } + + /** + * 매입 복원 시 → 예상 지출 복원 + */ + public function restored(Purchase $purchase): void + { + // 기존 soft deleted 레코드가 있으면 복원, 없으면 생성 + $expense = ExpectedExpense::withTrashed() + ->withoutGlobalScopes() + ->bySource('purchases', $purchase->id) + ->first(); + + if ($expense) { + $expense->restore(); + $this->updateExpectedExpense($expense, $purchase); + } else { + $this->syncToExpectedExpense($purchase); + } + } + + /** + * 매입 강제 삭제 시 → 예상 지출 강제 삭제 + */ + public function forceDeleted(Purchase $purchase): void + { + ExpectedExpense::withTrashed() + ->withoutGlobalScopes() + ->bySource('purchases', $purchase->id) + ->forceDelete(); + } + + /** + * 예상 지출에 동기화 + */ + protected function syncToExpectedExpense(Purchase $purchase): void + { + $expense = ExpectedExpense::withoutGlobalScopes() + ->bySource('purchases', $purchase->id) + ->first(); + + if ($expense) { + $this->updateExpectedExpense($expense, $purchase); + } else { + $this->createExpectedExpense($purchase); + } + } + + /** + * 예상 지출 생성 + */ + protected function createExpectedExpense(Purchase $purchase): void + { + ExpectedExpense::create([ + 'tenant_id' => $purchase->tenant_id, + 'expected_payment_date' => $purchase->purchase_date, + 'transaction_type' => 'purchase', + 'amount' => $purchase->total_amount, + 'client_id' => $purchase->client_id, + 'description' => $purchase->description ?? "매입번호: {$purchase->purchase_number}", + 'payment_status' => $purchase->withdrawal_id ? 'paid' : 'pending', + 'approval_status' => 'none', + 'source_type' => 'purchases', + 'source_id' => $purchase->id, + 'created_by' => $purchase->created_by, + 'updated_by' => $purchase->updated_by, + ]); + } + + /** + * 예상 지출 업데이트 + */ + protected function updateExpectedExpense(ExpectedExpense $expense, Purchase $purchase): void + { + $expense->update([ + 'expected_payment_date' => $purchase->purchase_date, + 'amount' => $purchase->total_amount, + 'client_id' => $purchase->client_id, + 'description' => $purchase->description ?? "매입번호: {$purchase->purchase_number}", + 'payment_status' => $purchase->withdrawal_id ? 'paid' : 'pending', + 'updated_by' => $purchase->updated_by, + ]); + } +} diff --git a/app/Observers/ExpenseSync/WithdrawalExpenseSyncObserver.php b/app/Observers/ExpenseSync/WithdrawalExpenseSyncObserver.php new file mode 100644 index 0000000..1b1ec9b --- /dev/null +++ b/app/Observers/ExpenseSync/WithdrawalExpenseSyncObserver.php @@ -0,0 +1,158 @@ +isCardPayment($withdrawal)) { + $this->syncToExpectedExpense($withdrawal); + } + } + + /** + * 출금 수정 시 → 카드결제 상태 변경 처리 + */ + public function updated(Withdrawal $withdrawal): void + { + $wasCard = $withdrawal->getOriginal('payment_method') === 'card'; + $isCard = $this->isCardPayment($withdrawal); + + if ($isCard) { + // 카드결제 → 동기화 + $this->syncToExpectedExpense($withdrawal); + } elseif ($wasCard && ! $isCard) { + // 카드결제에서 다른 방법으로 변경 → 예상 지출 삭제 + $this->deleteExpectedExpense($withdrawal); + } + } + + /** + * 출금 삭제 시 → 예상 지출 삭제 + */ + public function deleted(Withdrawal $withdrawal): void + { + if ($this->isCardPayment($withdrawal)) { + $this->deleteExpectedExpense($withdrawal); + } + } + + /** + * 출금 복원 시 → 카드결제인 경우 예상 지출 복원 + */ + public function restored(Withdrawal $withdrawal): void + { + if (! $this->isCardPayment($withdrawal)) { + return; + } + + $expense = ExpectedExpense::withTrashed() + ->withoutGlobalScopes() + ->bySource('withdrawals', $withdrawal->id) + ->first(); + + if ($expense) { + $expense->restore(); + $this->updateExpectedExpense($expense, $withdrawal); + } else { + $this->createExpectedExpense($withdrawal); + } + } + + /** + * 출금 강제 삭제 시 → 예상 지출 강제 삭제 + */ + public function forceDeleted(Withdrawal $withdrawal): void + { + ExpectedExpense::withTrashed() + ->withoutGlobalScopes() + ->bySource('withdrawals', $withdrawal->id) + ->forceDelete(); + } + + /** + * 카드결제 여부 확인 + */ + protected function isCardPayment(Withdrawal $withdrawal): bool + { + return $withdrawal->payment_method === 'card'; + } + + /** + * 예상 지출에 동기화 + */ + protected function syncToExpectedExpense(Withdrawal $withdrawal): void + { + $expense = ExpectedExpense::withoutGlobalScopes() + ->bySource('withdrawals', $withdrawal->id) + ->first(); + + if ($expense) { + $this->updateExpectedExpense($expense, $withdrawal); + } else { + $this->createExpectedExpense($withdrawal); + } + } + + /** + * 예상 지출 삭제 + */ + protected function deleteExpectedExpense(Withdrawal $withdrawal): void + { + ExpectedExpense::withoutGlobalScopes() + ->bySource('withdrawals', $withdrawal->id) + ->delete(); + } + + /** + * 예상 지출 생성 + */ + protected function createExpectedExpense(Withdrawal $withdrawal): void + { + ExpectedExpense::create([ + 'tenant_id' => $withdrawal->tenant_id, + 'expected_payment_date' => $withdrawal->withdrawal_date, + 'transaction_type' => 'card', + 'amount' => $withdrawal->amount, + 'client_id' => $withdrawal->client_id, + 'client_name' => $withdrawal->client_name ?? $withdrawal->merchant_name, + 'bank_account_id' => $withdrawal->bank_account_id, + 'account_code' => $withdrawal->account_code, + 'description' => $withdrawal->description, + 'payment_status' => 'paid', // 카드결제는 이미 결제 완료 상태 + 'approval_status' => 'none', + 'source_type' => 'withdrawals', + 'source_id' => $withdrawal->id, + 'created_by' => $withdrawal->created_by, + 'updated_by' => $withdrawal->updated_by, + ]); + } + + /** + * 예상 지출 업데이트 + */ + protected function updateExpectedExpense(ExpectedExpense $expense, Withdrawal $withdrawal): void + { + $expense->update([ + 'expected_payment_date' => $withdrawal->withdrawal_date, + 'amount' => $withdrawal->amount, + 'client_id' => $withdrawal->client_id, + 'client_name' => $withdrawal->client_name ?? $withdrawal->merchant_name, + 'bank_account_id' => $withdrawal->bank_account_id, + 'account_code' => $withdrawal->account_code, + 'description' => $withdrawal->description, + 'updated_by' => $withdrawal->updated_by, + ]); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3cda80a..528e54f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -12,6 +12,8 @@ use App\Models\Tenants\Deposit; use App\Models\Tenants\ExpectedExpense; use App\Models\Tenants\Stock; +use App\Models\Tenants\Bill; +use App\Models\Tenants\Purchase; use App\Models\Tenants\Tenant; use App\Models\Tenants\Withdrawal; use App\Observers\MenuObserver; @@ -24,6 +26,9 @@ use App\Observers\TodayIssue\OrderIssueObserver; use App\Observers\TodayIssue\StockIssueObserver; use App\Observers\TodayIssue\WithdrawalIssueObserver; +use App\Observers\ExpenseSync\BillExpenseSyncObserver; +use App\Observers\ExpenseSync\PurchaseExpenseSyncObserver; +use App\Observers\ExpenseSync\WithdrawalExpenseSyncObserver; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\DB; @@ -83,5 +88,10 @@ public function boot(): void Client::observe(ClientIssueObserver::class); Deposit::observe(DepositIssueObserver::class); Withdrawal::observe(WithdrawalIssueObserver::class); + + // 예상 지출 자동 동기화 (매입/카드결제/발행어음 → expected_expenses) + Purchase::observe(PurchaseExpenseSyncObserver::class); + Withdrawal::observe(WithdrawalExpenseSyncObserver::class); + Bill::observe(BillExpenseSyncObserver::class); } } diff --git a/database/migrations/2026_01_23_100000_add_source_fields_to_expected_expenses_table.php b/database/migrations/2026_01_23_100000_add_source_fields_to_expected_expenses_table.php new file mode 100644 index 0000000..4f7cb4c --- /dev/null +++ b/database/migrations/2026_01_23_100000_add_source_fields_to_expected_expenses_table.php @@ -0,0 +1,27 @@ +string('source_type', 50)->nullable()->after('description')->comment('원본 테이블: purchases/withdrawals/bills'); + $table->unsignedBigInteger('source_id')->nullable()->after('source_type')->comment('원본 레코드 ID'); + + // 복합 인덱스: 동기화된 레코드 검색용 + $table->index(['source_type', 'source_id'], 'idx_source'); + }); + } + + public function down(): void + { + Schema::table('expected_expenses', function (Blueprint $table) { + $table->dropIndex('idx_source'); + $table->dropColumn(['source_type', 'source_id']); + }); + } +};