From d75f6f5bd153e6eb11c8192e1f6d400938cf88b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 23 Jan 2026 10:05:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(API):=20=EC=9E=85=EA=B8=88/=EC=B6=9C?= =?UTF-8?q?=EA=B8=88=20=EC=95=8C=EB=A6=BC=20Observer=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20LoanController=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DepositIssueObserver, WithdrawalIssueObserver 신규 추가 - TodayIssueObserverService에 입금/출금 핸들러 및 디버그 로그 추가 - TodayIssue 모델에 입금/출금 상수 추가 - AppServiceProvider에 Observer 등록 - ApprovalService에 기존 결재선 사용 시 수동 알림 트리거 추가 - LoanController ApiResponse::handle() → ApiResponse::success() 수정 Co-Authored-By: Claude --- .../Controllers/Api/V1/LoanController.php | 22 ++--- app/Models/Tenants/TodayIssue.php | 14 ++- .../TodayIssue/DepositIssueObserver.php | 43 +++++++++ .../TodayIssue/WithdrawalIssueObserver.php | 43 +++++++++ app/Providers/AppServiceProvider.php | 6 ++ app/Services/ApprovalService.php | 32 +++++-- app/Services/ClientService.php | 4 +- app/Services/TodayIssueObserverService.php | 88 +++++++++++++++++++ lang/ko/message.php | 2 + 9 files changed, 232 insertions(+), 22 deletions(-) create mode 100644 app/Observers/TodayIssue/DepositIssueObserver.php create mode 100644 app/Observers/TodayIssue/WithdrawalIssueObserver.php diff --git a/app/Http/Controllers/Api/V1/LoanController.php b/app/Http/Controllers/Api/V1/LoanController.php index 34ab0cf..fe27b67 100644 --- a/app/Http/Controllers/Api/V1/LoanController.php +++ b/app/Http/Controllers/Api/V1/LoanController.php @@ -25,7 +25,7 @@ public function index(LoanIndexRequest $request): JsonResponse { $result = $this->loanService->index($request->validated()); - return ApiResponse::handle('message.fetched', $result); + return ApiResponse::success($result, __('message.fetched')); } /** @@ -36,7 +36,7 @@ public function summary(LoanIndexRequest $request): JsonResponse $userId = $request->validated()['user_id'] ?? null; $result = $this->loanService->summary($userId); - return ApiResponse::handle('message.fetched', $result); + return ApiResponse::success($result, __('message.fetched')); } /** @@ -46,7 +46,7 @@ public function dashboard(): JsonResponse { $result = $this->loanService->dashboard(); - return ApiResponse::handle('message.fetched', $result); + return ApiResponse::success($result, __('message.fetched')); } /** @@ -56,7 +56,7 @@ public function store(LoanStoreRequest $request): JsonResponse { $result = $this->loanService->store($request->validated()); - return ApiResponse::handle('message.created', $result, 201); + return ApiResponse::success($result, __('message.created'), [], 201); } /** @@ -66,7 +66,7 @@ public function show(int $id): JsonResponse { $result = $this->loanService->show($id); - return ApiResponse::handle('message.fetched', $result); + return ApiResponse::success($result, __('message.fetched')); } /** @@ -76,7 +76,7 @@ public function update(LoanUpdateRequest $request, int $id): JsonResponse { $result = $this->loanService->update($id, $request->validated()); - return ApiResponse::handle('message.updated', $result); + return ApiResponse::success($result, __('message.updated')); } /** @@ -86,7 +86,7 @@ public function destroy(int $id): JsonResponse { $this->loanService->destroy($id); - return ApiResponse::handle('message.deleted'); + return ApiResponse::success(null, __('message.deleted')); } /** @@ -96,7 +96,7 @@ public function settle(LoanSettleRequest $request, int $id): JsonResponse { $result = $this->loanService->settle($id, $request->validated()); - return ApiResponse::handle('message.loan.settled', $result); + return ApiResponse::success($result, __('message.loan.settled')); } /** @@ -110,7 +110,7 @@ public function calculateInterest(LoanCalculateInterestRequest $request): JsonRe $validated['user_id'] ?? null ); - return ApiResponse::handle('message.fetched', $result); + return ApiResponse::success($result, __('message.fetched')); } /** @@ -120,7 +120,7 @@ public function interestReport(int $year): JsonResponse { $result = $this->loanService->interestReport($year); - return ApiResponse::handle('message.fetched', $result); + return ApiResponse::success($result, __('message.fetched')); } /** @@ -131,6 +131,6 @@ public function taxSimulation(LoanCalculateInterestRequest $request): JsonRespon $validated = $request->validated(); $result = $this->loanService->taxSimulation($validated['year']); - return ApiResponse::handle('message.fetched', $result); + return ApiResponse::success($result, __('message.fetched')); } } diff --git a/app/Models/Tenants/TodayIssue.php b/app/Models/Tenants/TodayIssue.php index 175332f..bf6ee33 100644 --- a/app/Models/Tenants/TodayIssue.php +++ b/app/Models/Tenants/TodayIssue.php @@ -57,6 +57,10 @@ class TodayIssue extends Model public const SOURCE_CLIENT = 'client'; + public const SOURCE_DEPOSIT = 'deposit'; + + public const SOURCE_WITHDRAWAL = 'withdrawal'; + // 뱃지 타입 상수 public const BADGE_ORDER_REGISTER = '수주등록'; @@ -72,6 +76,10 @@ class TodayIssue extends Model public const BADGE_NEW_CLIENT = '신규거래처'; + public const BADGE_DEPOSIT = '입금'; + + public const BADGE_WITHDRAWAL = '출금'; + // 뱃지 → notification_type 매핑 public const BADGE_TO_NOTIFICATION_TYPE = [ self::BADGE_ORDER_REGISTER => 'sales_order', @@ -81,13 +89,17 @@ class TodayIssue extends Model self::BADGE_SAFETY_STOCK => 'safety_stock', self::BADGE_EXPENSE_PENDING => 'expected_expense', self::BADGE_TAX_REPORT => 'vat_report', + self::BADGE_DEPOSIT => 'deposit', + self::BADGE_WITHDRAWAL => 'withdrawal', ]; - // 중요 알림 (푸시 알림음) - 수주등록, 신규거래처, 결재요청 + // 중요 알림 (푸시 알림음) - 수주등록, 신규거래처, 결재요청, 입금, 출금 public const IMPORTANT_NOTIFICATIONS = [ 'sales_order', 'new_vendor', 'approval_request', + 'deposit', + 'withdrawal', ]; /** diff --git a/app/Observers/TodayIssue/DepositIssueObserver.php b/app/Observers/TodayIssue/DepositIssueObserver.php new file mode 100644 index 0000000..79d0646 --- /dev/null +++ b/app/Observers/TodayIssue/DepositIssueObserver.php @@ -0,0 +1,43 @@ + $deposit->id, + 'tenant_id' => $deposit->tenant_id, + ]); + + $this->safeExecute(fn () => $this->service->handleDepositCreated($deposit)); + } + + public function deleted(Deposit $deposit): void + { + $this->safeExecute(fn () => $this->service->handleDepositDeleted($deposit)); + } + + protected function safeExecute(callable $callback): void + { + try { + $callback(); + } catch (\Throwable $e) { + Log::warning('TodayIssue DepositObserver failed', [ + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Observers/TodayIssue/WithdrawalIssueObserver.php b/app/Observers/TodayIssue/WithdrawalIssueObserver.php new file mode 100644 index 0000000..ee4437a --- /dev/null +++ b/app/Observers/TodayIssue/WithdrawalIssueObserver.php @@ -0,0 +1,43 @@ + $withdrawal->id, + 'tenant_id' => $withdrawal->tenant_id, + ]); + + $this->safeExecute(fn () => $this->service->handleWithdrawalCreated($withdrawal)); + } + + public function deleted(Withdrawal $withdrawal): void + { + $this->safeExecute(fn () => $this->service->handleWithdrawalDeleted($withdrawal)); + } + + protected function safeExecute(callable $callback): void + { + try { + $callback(); + } catch (\Throwable $e) { + Log::warning('TodayIssue WithdrawalObserver failed', [ + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index bfc74a7..3cda80a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -9,17 +9,21 @@ use App\Models\Orders\Client; use App\Models\Orders\Order; use App\Models\Tenants\ApprovalStep; +use App\Models\Tenants\Deposit; use App\Models\Tenants\ExpectedExpense; use App\Models\Tenants\Stock; use App\Models\Tenants\Tenant; +use App\Models\Tenants\Withdrawal; use App\Observers\MenuObserver; use App\Observers\TenantObserver; use App\Observers\TodayIssue\ApprovalStepIssueObserver; use App\Observers\TodayIssue\BadDebtIssueObserver; use App\Observers\TodayIssue\ClientIssueObserver; +use App\Observers\TodayIssue\DepositIssueObserver; use App\Observers\TodayIssue\ExpectedExpenseIssueObserver; use App\Observers\TodayIssue\OrderIssueObserver; use App\Observers\TodayIssue\StockIssueObserver; +use App\Observers\TodayIssue\WithdrawalIssueObserver; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\DB; @@ -77,5 +81,7 @@ public function boot(): void ExpectedExpense::observe(ExpectedExpenseIssueObserver::class); ApprovalStep::observe(ApprovalStepIssueObserver::class); Client::observe(ClientIssueObserver::class); + Deposit::observe(DepositIssueObserver::class); + Withdrawal::observe(WithdrawalIssueObserver::class); } } diff --git a/app/Services/ApprovalService.php b/app/Services/ApprovalService.php index 2e08e3f..7516427 100644 --- a/app/Services/ApprovalService.php +++ b/app/Services/ApprovalService.php @@ -14,6 +14,10 @@ class ApprovalService extends Service { + public function __construct( + protected TodayIssueObserverService $todayIssueService + ) {} + // ========================================================================= // 결재 양식 관리 // ========================================================================= @@ -730,13 +734,8 @@ public function submit(int $id, array $data): Approval throw new BadRequestHttpException(__('error.approval.not_submittable')); } - // steps가 있으면 새로 생성, 없으면 기존 결재선 사용 - if (! empty($data['steps'])) { - // 기존 결재선 삭제 후 새로 생성 - $approval->steps()->delete(); - $this->createApprovalSteps($approval, $data['steps']); - } else { - // 기존 결재선이 없으면 에러 + // 기존 결재선 확인 (steps 없이 상신하는 경우) + if (empty($data['steps'])) { $existingSteps = $approval->steps() ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) ->count(); @@ -746,12 +745,31 @@ public function submit(int $id, array $data): Approval } } + // 먼저 approval을 pending으로 변경 (Observer가 올바른 상태로 트리거되도록) $approval->status = Approval::STATUS_PENDING; $approval->drafted_at = now(); $approval->current_step = 1; $approval->updated_by = $userId; $approval->save(); + // steps가 있으면 새로 생성 (approval이 pending 상태일 때 생성해야 알림 발송) + if (! empty($data['steps'])) { + // 기존 결재선 삭제 후 새로 생성 + $approval->steps()->delete(); + $this->createApprovalSteps($approval, $data['steps']); + } else { + // 기존 결재선 사용 시, Observer가 트리거되지 않으므로 수동으로 알림 발송 + $firstPendingStep = $approval->steps() + ->where('status', ApprovalStep::STATUS_PENDING) + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->orderBy('step_order') + ->first(); + + if ($firstPendingStep) { + $this->todayIssueService->handleApprovalStepChange($firstPendingStep); + } + } + return $approval->fresh([ 'form:id,name,code,category', 'drafter:id,name', diff --git a/app/Services/ClientService.php b/app/Services/ClientService.php index 8937118..e3f16da 100644 --- a/app/Services/ClientService.php +++ b/app/Services/ClientService.php @@ -134,9 +134,7 @@ public function store(array $data) $client = Client::create($data); - // 신규 거래처 등록 푸시 발송 - app(PushNotificationService::class) - ->notifyNewClient($client->id, $client->name, $tenantId); + // 신규 거래처 등록 푸시는 ClientIssueObserver가 자동 처리 return $client; } diff --git a/app/Services/TodayIssueObserverService.php b/app/Services/TodayIssueObserverService.php index f71a39b..4ab08f4 100644 --- a/app/Services/TodayIssueObserverService.php +++ b/app/Services/TodayIssueObserverService.php @@ -8,9 +8,11 @@ use App\Models\Orders\Order; use App\Models\PushDeviceToken; use App\Models\Tenants\ApprovalStep; +use App\Models\Tenants\Deposit; use App\Models\Tenants\ExpectedExpense; use App\Models\Tenants\Stock; use App\Models\Tenants\TodayIssue; +use App\Models\Tenants\Withdrawal; use App\Services\Fcm\FcmSender; use Carbon\Carbon; use Illuminate\Support\Facades\Log; @@ -261,6 +263,92 @@ public function handleClientDeleted(Client $client): void TodayIssue::removeBySource($client->tenant_id, TodayIssue::SOURCE_CLIENT, $client->id); } + /** + * 입금 등록 이슈 생성 + */ + public function handleDepositCreated(Deposit $deposit): void + { + Log::info('TodayIssue: Deposit created detected', [ + 'deposit_id' => $deposit->id, + 'tenant_id' => $deposit->tenant_id, + 'amount' => $deposit->amount, + ]); + + $clientName = $deposit->display_client_name ?: __('message.today_issue.unknown_client'); + $amount = number_format($deposit->amount ?? 0); + + Log::info('TodayIssue: Creating deposit issue', [ + 'deposit_id' => $deposit->id, + 'client' => $clientName, + 'amount' => $amount, + ]); + + $this->createIssueWithFcm( + tenantId: $deposit->tenant_id, + sourceType: TodayIssue::SOURCE_DEPOSIT, + sourceId: $deposit->id, + badge: TodayIssue::BADGE_DEPOSIT, + content: __('message.today_issue.deposit_registered', [ + 'client' => $clientName, + 'amount' => $amount, + ]), + path: '/accounting/deposit-management', + needsApproval: false, + expiresAt: Carbon::now()->addDays(7) + ); + } + + /** + * 입금 삭제 시 이슈 삭제 + */ + public function handleDepositDeleted(Deposit $deposit): void + { + TodayIssue::removeBySource($deposit->tenant_id, TodayIssue::SOURCE_DEPOSIT, $deposit->id); + } + + /** + * 출금 등록 이슈 생성 + */ + public function handleWithdrawalCreated(Withdrawal $withdrawal): void + { + Log::info('TodayIssue: Withdrawal created detected', [ + 'withdrawal_id' => $withdrawal->id, + 'tenant_id' => $withdrawal->tenant_id, + 'amount' => $withdrawal->amount, + ]); + + $clientName = $withdrawal->display_client_name ?: $withdrawal->merchant_name ?: __('message.today_issue.unknown_client'); + $amount = number_format($withdrawal->amount ?? 0); + + Log::info('TodayIssue: Creating withdrawal issue', [ + 'withdrawal_id' => $withdrawal->id, + 'client' => $clientName, + 'amount' => $amount, + ]); + + $this->createIssueWithFcm( + tenantId: $withdrawal->tenant_id, + sourceType: TodayIssue::SOURCE_WITHDRAWAL, + sourceId: $withdrawal->id, + badge: TodayIssue::BADGE_WITHDRAWAL, + content: __('message.today_issue.withdrawal_registered', [ + 'client' => $clientName, + 'amount' => $amount, + ]), + path: '/accounting/withdrawal-management', + needsApproval: false, + expiresAt: Carbon::now()->addDays(7) + ); + } + + /** + * 출금 삭제 시 이슈 삭제 + */ + public function handleWithdrawalDeleted(Withdrawal $withdrawal): void + { + TodayIssue::removeBySource($withdrawal->tenant_id, TodayIssue::SOURCE_WITHDRAWAL, $withdrawal->id); + } + /** * 세금 신고 이슈 업데이트 (스케줄러에서 호출) * 이 메서드는 일일 스케줄러에서 호출하여 세금 신고 이슈를 업데이트합니다. diff --git a/lang/ko/message.php b/lang/ko/message.php index d92cc40..caa9a9e 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -513,6 +513,8 @@ 'tax_vat_deadline' => ':quarter분기 부가세 신고 D-:days', 'approval_pending' => ':title 승인 요청 (:drafter)', 'new_client' => '신규 거래처 :name 등록 완료', + 'deposit_registered' => ':client 입금 :amount원', + 'withdrawal_registered' => ':client 출금 :amount원', // 하위 호환성 (deprecated) 'order_success' => ':client 신규 수주 :amount원 확정',