From f74767563f35935ceb4626a6a11fa66586aaf013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 13:52:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20FCM=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=B3=84=20=ED=83=80=EA=B2=9F=20=EC=95=8C=EB=A6=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - today_issues 테이블에 target_user_id 컬럼 추가 (마이그레이션) - TodayIssue 모델: target_user_id 필드, targetUser 관계, forUser/targetedTo 스코프 추가 - TodayIssue 모델: 기안 상태 뱃지 상수 추가 (BADGE_DRAFT_APPROVED/REJECTED/COMPLETED) - TodayIssueObserverService: createIssueWithFcm, sendFcmNotification, getEnabledUserTokens에 targetUserId 파라미터 추가 - TodayIssueObserverService: handleApprovalStepChange - 결재자에게만 발송 - TodayIssueObserverService: handleApprovalStatusChange 추가 - 기안자에게만 발송 - ApprovalIssueObserver 신규 생성 및 AppServiceProvider에 등록 - i18n: 기안 승인/반려/완료 알림 메시지 추가 결재요청은 결재자(ApprovalStep.user_id)에게만, 기안 승인/반려는 기안자(Approval.drafter_id)에게만 FCM 발송 Co-Authored-By: Claude Opus 4.5 --- app/Models/Tenants/TodayIssue.php | 53 +++++++++- .../TodayIssue/ApprovalIssueObserver.php | 43 ++++++++ app/Providers/AppServiceProvider.php | 3 + app/Services/TodayIssueObserverService.php | 97 +++++++++++++++++-- app/Services/TodayIssueService.php | 12 +++ ...d_target_user_id_to_today_issues_table.php | 40 ++++++++ lang/ko/message.php | 5 + 7 files changed, 244 insertions(+), 9 deletions(-) create mode 100644 app/Observers/TodayIssue/ApprovalIssueObserver.php create mode 100644 database/migrations/2026_01_28_132426_add_target_user_id_to_today_issues_table.php diff --git a/app/Models/Tenants/TodayIssue.php b/app/Models/Tenants/TodayIssue.php index 31f7f94..9a208f2 100644 --- a/app/Models/Tenants/TodayIssue.php +++ b/app/Models/Tenants/TodayIssue.php @@ -24,6 +24,7 @@ class TodayIssue extends Model 'tenant_id', 'source_type', 'source_id', + 'target_user_id', 'badge', 'notification_type', 'content', @@ -80,6 +81,13 @@ class TodayIssue extends Model public const BADGE_WITHDRAWAL = '출금'; + // 기안 상태 변경 알림 뱃지 + public const BADGE_DRAFT_APPROVED = '기안승인'; + + public const BADGE_DRAFT_REJECTED = '기안반려'; + + public const BADGE_DRAFT_COMPLETED = '기안완료'; + // source_type → badge 매핑 public const SOURCE_TO_BADGE = [ self::SOURCE_ORDER => self::BADGE_ORDER_REGISTER, @@ -98,6 +106,9 @@ class TodayIssue extends Model self::BADGE_ORDER_REGISTER => 'sales_order', self::BADGE_NEW_CLIENT => 'new_vendor', self::BADGE_APPROVAL_REQUEST => 'approval_request', + self::BADGE_DRAFT_APPROVED => 'approval_request', + self::BADGE_DRAFT_REJECTED => 'approval_request', + self::BADGE_DRAFT_COMPLETED => 'approval_request', self::BADGE_COLLECTION_ISSUE => 'bad_debt', self::BADGE_SAFETY_STOCK => 'safety_stock', self::BADGE_EXPENSE_PENDING => 'expected_expense', @@ -122,6 +133,14 @@ public function reader(): BelongsTo return $this->belongsTo(User::class, 'read_by'); } + /** + * 대상 사용자 (특정 사용자에게만 발송할 경우) + */ + public function targetUser(): BelongsTo + { + return $this->belongsTo(User::class, 'target_user_id'); + } + /** * 읽지 않은 이슈 스코프 */ @@ -171,6 +190,26 @@ public function scopeBySource($query, string $sourceType, ?int $sourceId = null) return $query; } + /** + * 특정 사용자 대상 이슈 스코프 + * target_user_id가 null이거나 지정된 사용자인 경우 + */ + public function scopeForUser($query, int $userId) + { + return $query->where(function ($q) use ($userId) { + $q->whereNull('target_user_id') + ->orWhere('target_user_id', $userId); + }); + } + + /** + * 특정 사용자만 대상인 이슈 스코프 + */ + public function scopeTargetedTo($query, int $userId) + { + return $query->where('target_user_id', $userId); + } + /** * 이슈 확인 처리 */ @@ -185,6 +224,16 @@ public function markAsRead(int $userId): bool /** * 이슈 생성 헬퍼 (정적 메서드) + * + * @param int $tenantId 테넌트 ID + * @param string $sourceType 소스 타입 (order, approval, etc.) + * @param int|null $sourceId 소스 ID + * @param string $badge 뱃지 타입 + * @param string $content 알림 내용 + * @param string|null $path 이동 경로 + * @param bool $needsApproval 승인 필요 여부 + * @param \DateTime|null $expiresAt 만료 시간 + * @param int|null $targetUserId 특정 대상 사용자 ID (null이면 테넌트 전체) */ public static function createIssue( int $tenantId, @@ -194,7 +243,8 @@ public static function createIssue( string $content, ?string $path = null, bool $needsApproval = false, - ?\DateTime $expiresAt = null + ?\DateTime $expiresAt = null, + ?int $targetUserId = null ): self { // badge에서 notification_type 자동 매핑 $notificationType = self::BADGE_TO_NOTIFICATION_TYPE[$badge] ?? null; @@ -204,6 +254,7 @@ public static function createIssue( 'tenant_id' => $tenantId, 'source_type' => $sourceType, 'source_id' => $sourceId, + 'target_user_id' => $targetUserId, ], [ 'badge' => $badge, diff --git a/app/Observers/TodayIssue/ApprovalIssueObserver.php b/app/Observers/TodayIssue/ApprovalIssueObserver.php new file mode 100644 index 0000000..242e869 --- /dev/null +++ b/app/Observers/TodayIssue/ApprovalIssueObserver.php @@ -0,0 +1,43 @@ +isDirty('status')) { + $newStatus = $approval->status; + + // 승인 또는 반려 상태로 변경된 경우에만 알림 + if (in_array($newStatus, [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED])) { + $this->safeExecute(fn () => $this->service->handleApprovalStatusChange($approval)); + } + } + } + + protected function safeExecute(callable $callback): void + { + try { + $callback(); + } catch (\Throwable $e) { + Log::warning('TodayIssue ApprovalObserver failed', [ + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f5f97d9..d083f5e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,7 @@ use App\Models\Members\User; use App\Models\Orders\Client; use App\Models\Orders\Order; +use App\Models\Tenants\Approval; use App\Models\Tenants\ApprovalStep; use App\Models\Tenants\Bill; use App\Models\Tenants\Deposit; @@ -21,6 +22,7 @@ use App\Observers\ExpenseSync\WithdrawalExpenseSyncObserver; use App\Observers\MenuObserver; use App\Observers\TenantObserver; +use App\Observers\TodayIssue\ApprovalIssueObserver; use App\Observers\TodayIssue\ApprovalStepIssueObserver; use App\Observers\TodayIssue\BadDebtIssueObserver; use App\Observers\TodayIssue\ClientIssueObserver; @@ -85,6 +87,7 @@ public function boot(): void Stock::observe(StockIssueObserver::class); ExpectedExpense::observe(ExpectedExpenseIssueObserver::class); ApprovalStep::observe(ApprovalStepIssueObserver::class); + Approval::observe(ApprovalIssueObserver::class); Client::observe(ClientIssueObserver::class); Deposit::observe(DepositIssueObserver::class); Withdrawal::observe(WithdrawalIssueObserver::class); diff --git a/app/Services/TodayIssueObserverService.php b/app/Services/TodayIssueObserverService.php index 715430f..71e7cd3 100644 --- a/app/Services/TodayIssueObserverService.php +++ b/app/Services/TodayIssueObserverService.php @@ -7,6 +7,7 @@ use App\Models\Orders\Client; use App\Models\Orders\Order; use App\Models\PushDeviceToken; +use App\Models\Tenants\Approval; use App\Models\Tenants\ApprovalStep; use App\Models\Tenants\Deposit; use App\Models\Tenants\ExpectedExpense; @@ -202,6 +203,8 @@ public function handleExpectedExpenseDeleted(ExpectedExpense $expense): void /** * 결재 요청 이슈 생성/삭제 + * + * 결재자(ApprovalStep.user_id)에게만 알림 발송 */ public function handleApprovalStepChange(ApprovalStep $step): void { @@ -223,7 +226,8 @@ public function handleApprovalStepChange(ApprovalStep $step): void ]), path: '/approval/inbox', needsApproval: true, - expiresAt: null // 결재 완료 시까지 유지 + expiresAt: null, // 결재 완료 시까지 유지 + targetUserId: $step->user_id // 결재자에게만 발송 ); } else { if ($approval) { @@ -242,6 +246,52 @@ public function handleApprovalStepDeleted(ApprovalStep $step): void } } + /** + * 결재 상태 변경 시 기안자에게 알림 발송 + * + * 승인(approved), 반려(rejected), 완료(approved 최종) 시 기안자에게만 발송 + */ + public function handleApprovalStatusChange(Approval $approval): void + { + // 상태에 따른 뱃지 결정 + $badge = match ($approval->status) { + Approval::STATUS_APPROVED => TodayIssue::BADGE_DRAFT_APPROVED, + Approval::STATUS_REJECTED => TodayIssue::BADGE_DRAFT_REJECTED, + default => null, + }; + + if (! $badge) { + return; + } + + // 메시지 키 결정 + $messageKey = match ($approval->status) { + Approval::STATUS_APPROVED => 'message.today_issue.draft_approved', + Approval::STATUS_REJECTED => 'message.today_issue.draft_rejected', + default => null, + }; + + if (! $messageKey) { + return; + } + + $title = $approval->title ?? __('message.today_issue.approval_request'); + + $this->createIssueWithFcm( + tenantId: $approval->tenant_id, + sourceType: TodayIssue::SOURCE_APPROVAL, + sourceId: $approval->id, + badge: $badge, + content: __($messageKey, [ + 'title' => $title, + ]), + path: '/approval/draft', + needsApproval: false, + expiresAt: Carbon::now()->addDays(7), + targetUserId: $approval->drafter_id // 기안자에게만 발송 + ); + } + /** * 신규 거래처 이슈 생성 */ @@ -425,6 +475,7 @@ public function updateTaxIssues(int $tenantId): void /** * TodayIssue 생성 후 FCM 푸시 알림 발송 * + * target_user_id가 있으면 해당 사용자에게만, 없으면 테넌트 전체에게 발송 * 알림 설정이 활성화된 사용자에게만 발송 * 중요 알림(수주등록, 신규거래처, 결재요청)은 알림음 다르게 발송 */ @@ -436,12 +487,18 @@ public function sendFcmNotification(TodayIssue $issue): void try { // 해당 테넌트의 활성 토큰 조회 (알림 설정 활성화된 사용자만) - $tokens = $this->getEnabledUserTokens($issue->tenant_id, $issue->notification_type); + // target_user_id가 있으면 해당 사용자만, 없으면 테넌트 전체 + $tokens = $this->getEnabledUserTokens( + $issue->tenant_id, + $issue->notification_type, + $issue->target_user_id + ); if (empty($tokens)) { Log::info('[TodayIssue] No enabled tokens found for FCM', [ 'tenant_id' => $issue->tenant_id, 'notification_type' => $issue->notification_type, + 'target_user_id' => $issue->target_user_id, ]); return; @@ -466,6 +523,7 @@ public function sendFcmNotification(TodayIssue $issue): void Log::info('[TodayIssue] FCM notification sent', [ 'issue_id' => $issue->id, 'notification_type' => $issue->notification_type, + 'target_user_id' => $issue->target_user_id, 'channel_id' => $channelId, 'token_count' => count($tokens), 'success_count' => $result->getSuccessCount(), @@ -476,6 +534,7 @@ public function sendFcmNotification(TodayIssue $issue): void Log::error('[TodayIssue] FCM notification failed', [ 'issue_id' => $issue->id, 'notification_type' => $issue->notification_type, + 'target_user_id' => $issue->target_user_id, 'error' => $e->getMessage(), ]); } @@ -483,15 +542,25 @@ public function sendFcmNotification(TodayIssue $issue): void /** * 알림 설정이 활성화된 사용자들의 FCM 토큰 조회 + * + * @param int $tenantId 테넌트 ID + * @param string $notificationType 알림 타입 + * @param int|null $targetUserId 특정 대상 사용자 ID (null이면 테넌트 전체) */ - private function getEnabledUserTokens(int $tenantId, string $notificationType): array + private function getEnabledUserTokens(int $tenantId, string $notificationType, ?int $targetUserId = null): array { // 해당 테넌트의 활성 토큰 조회 - $tokens = PushDeviceToken::withoutGlobalScopes() + $query = PushDeviceToken::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('is_active', true) - ->whereNull('deleted_at') - ->get(); + ->whereNull('deleted_at'); + + // 특정 대상자가 지정된 경우 해당 사용자만 조회 + if ($targetUserId !== null) { + $query->where('user_id', $targetUserId); + } + + $tokens = $query->get(); if ($tokens->isEmpty()) { return []; @@ -534,6 +603,16 @@ private function isNotificationEnabledForUser(int $tenantId, int $userId, string * TodayIssue 생성 시 FCM 발송 포함 (래퍼 메서드) * * createIssue 호출 후 자동으로 FCM 발송 + * + * @param int $tenantId 테넌트 ID + * @param string $sourceType 소스 타입 + * @param int|null $sourceId 소스 ID + * @param string $badge 뱃지 타입 + * @param string $content 알림 내용 + * @param string|null $path 이동 경로 + * @param bool $needsApproval 승인 필요 여부 + * @param \DateTime|null $expiresAt 만료 시간 + * @param int|null $targetUserId 특정 대상 사용자 ID (null이면 테넌트 전체) */ public function createIssueWithFcm( int $tenantId, @@ -543,7 +622,8 @@ public function createIssueWithFcm( string $content, ?string $path = null, bool $needsApproval = false, - ?\DateTime $expiresAt = null + ?\DateTime $expiresAt = null, + ?int $targetUserId = null ): TodayIssue { $issue = TodayIssue::createIssue( tenantId: $tenantId, @@ -553,7 +633,8 @@ public function createIssueWithFcm( content: $content, path: $path, needsApproval: $needsApproval, - expiresAt: $expiresAt + expiresAt: $expiresAt, + targetUserId: $targetUserId ); // FCM 발송 diff --git a/app/Services/TodayIssueService.php b/app/Services/TodayIssueService.php index a094546..6a801fd 100644 --- a/app/Services/TodayIssueService.php +++ b/app/Services/TodayIssueService.php @@ -21,9 +21,11 @@ class TodayIssueService extends Service public function summary(int $limit = 30, ?string $badge = null): array { $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); $query = TodayIssue::query() ->where('tenant_id', $tenantId) + ->forUser($userId) // 본인 대상 또는 전체 브로드캐스트 ->active() // 만료되지 않은 이슈만 ->today() // 오늘 날짜 이슈만 ->orderByDesc('created_at'); @@ -36,6 +38,7 @@ public function summary(int $limit = 30, ?string $badge = null): array // 전체 개수 (필터 적용 전, 오늘 날짜만) $totalQuery = TodayIssue::query() ->where('tenant_id', $tenantId) + ->forUser($userId) ->active() ->today(); $totalCount = $totalQuery->count(); @@ -72,9 +75,11 @@ public function summary(int $limit = 30, ?string $badge = null): array public function getUnreadList(int $limit = 10): array { $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); $issues = TodayIssue::query() ->where('tenant_id', $tenantId) + ->forUser($userId) // 본인 대상 또는 전체 브로드캐스트 ->unread() ->active() ->orderByDesc('created_at') @@ -83,6 +88,7 @@ public function getUnreadList(int $limit = 10): array $totalCount = TodayIssue::query() ->where('tenant_id', $tenantId) + ->forUser($userId) ->unread() ->active() ->count(); @@ -115,9 +121,11 @@ public function getUnreadList(int $limit = 10): array public function getUnreadCount(): int { $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); return TodayIssue::query() ->where('tenant_id', $tenantId) + ->forUser($userId) // 본인 대상 또는 전체 브로드캐스트 ->unread() ->active() ->count(); @@ -132,6 +140,7 @@ public function markAsRead(int $issueId): bool $userId = $this->apiUserId(); $issue = TodayIssue::where('tenant_id', $tenantId) + ->forUser($userId) // 본인 대상 또는 전체 브로드캐스트 ->where('id', $issueId) ->first(); @@ -152,6 +161,7 @@ public function markAllAsRead(): int return TodayIssue::query() ->where('tenant_id', $tenantId) + ->forUser($userId) // 본인 대상 또는 전체 브로드캐스트 ->unread() ->active() ->update([ @@ -177,9 +187,11 @@ public function dismiss(string $sourceType, int $sourceId): bool public function countByBadge(): array { $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); $counts = TodayIssue::query() ->where('tenant_id', $tenantId) + ->forUser($userId) // 본인 대상 또는 전체 브로드캐스트 ->active() ->selectRaw('badge, COUNT(*) as count') ->groupBy('badge') diff --git a/database/migrations/2026_01_28_132426_add_target_user_id_to_today_issues_table.php b/database/migrations/2026_01_28_132426_add_target_user_id_to_today_issues_table.php new file mode 100644 index 0000000..c92653f --- /dev/null +++ b/database/migrations/2026_01_28_132426_add_target_user_id_to_today_issues_table.php @@ -0,0 +1,40 @@ +unsignedBigInteger('target_user_id') + ->nullable() + ->after('source_id') + ->comment('특정 대상 사용자 ID (null이면 테넌트 전체)'); + + $table->foreign('target_user_id') + ->references('id') + ->on('users') + ->onDelete('cascade'); + + $table->index(['tenant_id', 'target_user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('today_issues', function (Blueprint $table) { + $table->dropForeign(['target_user_id']); + $table->dropIndex(['tenant_id', 'target_user_id']); + $table->dropColumn('target_user_id'); + }); + } +}; diff --git a/lang/ko/message.php b/lang/ko/message.php index df98c74..5a31536 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -516,6 +516,11 @@ 'deposit_registered' => ':client 입금 :amount원', 'withdrawal_registered' => ':client 출금 :amount원', + // 기안 상태 변경 알림 + 'draft_approved' => ':title 결재가 승인되었습니다', + 'draft_rejected' => ':title 결재가 반려되었습니다', + 'draft_completed' => ':title 결재가 완료되었습니다', + // 하위 호환성 (deprecated) 'order_success' => ':client 신규 수주 :amount원 확정', 'receivable_overdue' => ':client 미수금 :amount원 연체 :days일',