feat: FCM 사용자별 타겟 알림 발송 기능 추가

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 13:52:43 +09:00
parent 518ae4657e
commit f74767563f
7 changed files with 244 additions and 9 deletions

View File

@@ -24,6 +24,7 @@ class TodayIssue extends Model
'tenant_id', 'tenant_id',
'source_type', 'source_type',
'source_id', 'source_id',
'target_user_id',
'badge', 'badge',
'notification_type', 'notification_type',
'content', 'content',
@@ -80,6 +81,13 @@ class TodayIssue extends Model
public const BADGE_WITHDRAWAL = '출금'; public const BADGE_WITHDRAWAL = '출금';
// 기안 상태 변경 알림 뱃지
public const BADGE_DRAFT_APPROVED = '기안승인';
public const BADGE_DRAFT_REJECTED = '기안반려';
public const BADGE_DRAFT_COMPLETED = '기안완료';
// source_type → badge 매핑 // source_type → badge 매핑
public const SOURCE_TO_BADGE = [ public const SOURCE_TO_BADGE = [
self::SOURCE_ORDER => self::BADGE_ORDER_REGISTER, self::SOURCE_ORDER => self::BADGE_ORDER_REGISTER,
@@ -98,6 +106,9 @@ class TodayIssue extends Model
self::BADGE_ORDER_REGISTER => 'sales_order', self::BADGE_ORDER_REGISTER => 'sales_order',
self::BADGE_NEW_CLIENT => 'new_vendor', self::BADGE_NEW_CLIENT => 'new_vendor',
self::BADGE_APPROVAL_REQUEST => 'approval_request', 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_COLLECTION_ISSUE => 'bad_debt',
self::BADGE_SAFETY_STOCK => 'safety_stock', self::BADGE_SAFETY_STOCK => 'safety_stock',
self::BADGE_EXPENSE_PENDING => 'expected_expense', self::BADGE_EXPENSE_PENDING => 'expected_expense',
@@ -122,6 +133,14 @@ public function reader(): BelongsTo
return $this->belongsTo(User::class, 'read_by'); 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; 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( public static function createIssue(
int $tenantId, int $tenantId,
@@ -194,7 +243,8 @@ public static function createIssue(
string $content, string $content,
?string $path = null, ?string $path = null,
bool $needsApproval = false, bool $needsApproval = false,
?\DateTime $expiresAt = null ?\DateTime $expiresAt = null,
?int $targetUserId = null
): self { ): self {
// badge에서 notification_type 자동 매핑 // badge에서 notification_type 자동 매핑
$notificationType = self::BADGE_TO_NOTIFICATION_TYPE[$badge] ?? null; $notificationType = self::BADGE_TO_NOTIFICATION_TYPE[$badge] ?? null;
@@ -204,6 +254,7 @@ public static function createIssue(
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
'source_type' => $sourceType, 'source_type' => $sourceType,
'source_id' => $sourceId, 'source_id' => $sourceId,
'target_user_id' => $targetUserId,
], ],
[ [
'badge' => $badge, 'badge' => $badge,

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Observers\TodayIssue;
use App\Models\Tenants\Approval;
use App\Services\TodayIssueObserverService;
use Illuminate\Support\Facades\Log;
/**
* Approval 모델의 TodayIssue Observer
*
* 기안 상태 변경(승인/반려) 시 기안자에게 알림 발송
*/
class ApprovalIssueObserver
{
public function __construct(
protected TodayIssueObserverService $service
) {}
public function updated(Approval $approval): void
{
// 상태가 변경된 경우에만 처리
if ($approval->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(),
]);
}
}
}

View File

@@ -8,6 +8,7 @@
use App\Models\Members\User; use App\Models\Members\User;
use App\Models\Orders\Client; use App\Models\Orders\Client;
use App\Models\Orders\Order; use App\Models\Orders\Order;
use App\Models\Tenants\Approval;
use App\Models\Tenants\ApprovalStep; use App\Models\Tenants\ApprovalStep;
use App\Models\Tenants\Bill; use App\Models\Tenants\Bill;
use App\Models\Tenants\Deposit; use App\Models\Tenants\Deposit;
@@ -21,6 +22,7 @@
use App\Observers\ExpenseSync\WithdrawalExpenseSyncObserver; use App\Observers\ExpenseSync\WithdrawalExpenseSyncObserver;
use App\Observers\MenuObserver; use App\Observers\MenuObserver;
use App\Observers\TenantObserver; use App\Observers\TenantObserver;
use App\Observers\TodayIssue\ApprovalIssueObserver;
use App\Observers\TodayIssue\ApprovalStepIssueObserver; use App\Observers\TodayIssue\ApprovalStepIssueObserver;
use App\Observers\TodayIssue\BadDebtIssueObserver; use App\Observers\TodayIssue\BadDebtIssueObserver;
use App\Observers\TodayIssue\ClientIssueObserver; use App\Observers\TodayIssue\ClientIssueObserver;
@@ -85,6 +87,7 @@ public function boot(): void
Stock::observe(StockIssueObserver::class); Stock::observe(StockIssueObserver::class);
ExpectedExpense::observe(ExpectedExpenseIssueObserver::class); ExpectedExpense::observe(ExpectedExpenseIssueObserver::class);
ApprovalStep::observe(ApprovalStepIssueObserver::class); ApprovalStep::observe(ApprovalStepIssueObserver::class);
Approval::observe(ApprovalIssueObserver::class);
Client::observe(ClientIssueObserver::class); Client::observe(ClientIssueObserver::class);
Deposit::observe(DepositIssueObserver::class); Deposit::observe(DepositIssueObserver::class);
Withdrawal::observe(WithdrawalIssueObserver::class); Withdrawal::observe(WithdrawalIssueObserver::class);

View File

@@ -7,6 +7,7 @@
use App\Models\Orders\Client; use App\Models\Orders\Client;
use App\Models\Orders\Order; use App\Models\Orders\Order;
use App\Models\PushDeviceToken; use App\Models\PushDeviceToken;
use App\Models\Tenants\Approval;
use App\Models\Tenants\ApprovalStep; use App\Models\Tenants\ApprovalStep;
use App\Models\Tenants\Deposit; use App\Models\Tenants\Deposit;
use App\Models\Tenants\ExpectedExpense; use App\Models\Tenants\ExpectedExpense;
@@ -202,6 +203,8 @@ public function handleExpectedExpenseDeleted(ExpectedExpense $expense): void
/** /**
* 결재 요청 이슈 생성/삭제 * 결재 요청 이슈 생성/삭제
*
* 결재자(ApprovalStep.user_id)에게만 알림 발송
*/ */
public function handleApprovalStepChange(ApprovalStep $step): void public function handleApprovalStepChange(ApprovalStep $step): void
{ {
@@ -223,7 +226,8 @@ public function handleApprovalStepChange(ApprovalStep $step): void
]), ]),
path: '/approval/inbox', path: '/approval/inbox',
needsApproval: true, needsApproval: true,
expiresAt: null // 결재 완료 시까지 유지 expiresAt: null, // 결재 완료 시까지 유지
targetUserId: $step->user_id // 결재자에게만 발송
); );
} else { } else {
if ($approval) { 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 푸시 알림 발송 * TodayIssue 생성 후 FCM 푸시 알림 발송
* *
* target_user_id가 있으면 해당 사용자에게만, 없으면 테넌트 전체에게 발송
* 알림 설정이 활성화된 사용자에게만 발송 * 알림 설정이 활성화된 사용자에게만 발송
* 중요 알림(수주등록, 신규거래처, 결재요청)은 알림음 다르게 발송 * 중요 알림(수주등록, 신규거래처, 결재요청)은 알림음 다르게 발송
*/ */
@@ -436,12 +487,18 @@ public function sendFcmNotification(TodayIssue $issue): void
try { 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)) { if (empty($tokens)) {
Log::info('[TodayIssue] No enabled tokens found for FCM', [ Log::info('[TodayIssue] No enabled tokens found for FCM', [
'tenant_id' => $issue->tenant_id, 'tenant_id' => $issue->tenant_id,
'notification_type' => $issue->notification_type, 'notification_type' => $issue->notification_type,
'target_user_id' => $issue->target_user_id,
]); ]);
return; return;
@@ -466,6 +523,7 @@ public function sendFcmNotification(TodayIssue $issue): void
Log::info('[TodayIssue] FCM notification sent', [ Log::info('[TodayIssue] FCM notification sent', [
'issue_id' => $issue->id, 'issue_id' => $issue->id,
'notification_type' => $issue->notification_type, 'notification_type' => $issue->notification_type,
'target_user_id' => $issue->target_user_id,
'channel_id' => $channelId, 'channel_id' => $channelId,
'token_count' => count($tokens), 'token_count' => count($tokens),
'success_count' => $result->getSuccessCount(), 'success_count' => $result->getSuccessCount(),
@@ -476,6 +534,7 @@ public function sendFcmNotification(TodayIssue $issue): void
Log::error('[TodayIssue] FCM notification failed', [ Log::error('[TodayIssue] FCM notification failed', [
'issue_id' => $issue->id, 'issue_id' => $issue->id,
'notification_type' => $issue->notification_type, 'notification_type' => $issue->notification_type,
'target_user_id' => $issue->target_user_id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
} }
@@ -483,15 +542,25 @@ public function sendFcmNotification(TodayIssue $issue): void
/** /**
* 알림 설정이 활성화된 사용자들의 FCM 토큰 조회 * 알림 설정이 활성화된 사용자들의 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('tenant_id', $tenantId)
->where('is_active', true) ->where('is_active', true)
->whereNull('deleted_at') ->whereNull('deleted_at');
->get();
// 특정 대상자가 지정된 경우 해당 사용자만 조회
if ($targetUserId !== null) {
$query->where('user_id', $targetUserId);
}
$tokens = $query->get();
if ($tokens->isEmpty()) { if ($tokens->isEmpty()) {
return []; return [];
@@ -534,6 +603,16 @@ private function isNotificationEnabledForUser(int $tenantId, int $userId, string
* TodayIssue 생성 시 FCM 발송 포함 (래퍼 메서드) * TodayIssue 생성 시 FCM 발송 포함 (래퍼 메서드)
* *
* createIssue 호출 후 자동으로 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( public function createIssueWithFcm(
int $tenantId, int $tenantId,
@@ -543,7 +622,8 @@ public function createIssueWithFcm(
string $content, string $content,
?string $path = null, ?string $path = null,
bool $needsApproval = false, bool $needsApproval = false,
?\DateTime $expiresAt = null ?\DateTime $expiresAt = null,
?int $targetUserId = null
): TodayIssue { ): TodayIssue {
$issue = TodayIssue::createIssue( $issue = TodayIssue::createIssue(
tenantId: $tenantId, tenantId: $tenantId,
@@ -553,7 +633,8 @@ public function createIssueWithFcm(
content: $content, content: $content,
path: $path, path: $path,
needsApproval: $needsApproval, needsApproval: $needsApproval,
expiresAt: $expiresAt expiresAt: $expiresAt,
targetUserId: $targetUserId
); );
// FCM 발송 // FCM 발송

View File

@@ -21,9 +21,11 @@ class TodayIssueService extends Service
public function summary(int $limit = 30, ?string $badge = null): array public function summary(int $limit = 30, ?string $badge = null): array
{ {
$tenantId = $this->tenantId(); $tenantId = $this->tenantId();
$userId = $this->apiUserId();
$query = TodayIssue::query() $query = TodayIssue::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->forUser($userId) // 본인 대상 또는 전체 브로드캐스트
->active() // 만료되지 않은 이슈만 ->active() // 만료되지 않은 이슈만
->today() // 오늘 날짜 이슈만 ->today() // 오늘 날짜 이슈만
->orderByDesc('created_at'); ->orderByDesc('created_at');
@@ -36,6 +38,7 @@ public function summary(int $limit = 30, ?string $badge = null): array
// 전체 개수 (필터 적용 전, 오늘 날짜만) // 전체 개수 (필터 적용 전, 오늘 날짜만)
$totalQuery = TodayIssue::query() $totalQuery = TodayIssue::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->forUser($userId)
->active() ->active()
->today(); ->today();
$totalCount = $totalQuery->count(); $totalCount = $totalQuery->count();
@@ -72,9 +75,11 @@ public function summary(int $limit = 30, ?string $badge = null): array
public function getUnreadList(int $limit = 10): array public function getUnreadList(int $limit = 10): array
{ {
$tenantId = $this->tenantId(); $tenantId = $this->tenantId();
$userId = $this->apiUserId();
$issues = TodayIssue::query() $issues = TodayIssue::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->forUser($userId) // 본인 대상 또는 전체 브로드캐스트
->unread() ->unread()
->active() ->active()
->orderByDesc('created_at') ->orderByDesc('created_at')
@@ -83,6 +88,7 @@ public function getUnreadList(int $limit = 10): array
$totalCount = TodayIssue::query() $totalCount = TodayIssue::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->forUser($userId)
->unread() ->unread()
->active() ->active()
->count(); ->count();
@@ -115,9 +121,11 @@ public function getUnreadList(int $limit = 10): array
public function getUnreadCount(): int public function getUnreadCount(): int
{ {
$tenantId = $this->tenantId(); $tenantId = $this->tenantId();
$userId = $this->apiUserId();
return TodayIssue::query() return TodayIssue::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->forUser($userId) // 본인 대상 또는 전체 브로드캐스트
->unread() ->unread()
->active() ->active()
->count(); ->count();
@@ -132,6 +140,7 @@ public function markAsRead(int $issueId): bool
$userId = $this->apiUserId(); $userId = $this->apiUserId();
$issue = TodayIssue::where('tenant_id', $tenantId) $issue = TodayIssue::where('tenant_id', $tenantId)
->forUser($userId) // 본인 대상 또는 전체 브로드캐스트
->where('id', $issueId) ->where('id', $issueId)
->first(); ->first();
@@ -152,6 +161,7 @@ public function markAllAsRead(): int
return TodayIssue::query() return TodayIssue::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->forUser($userId) // 본인 대상 또는 전체 브로드캐스트
->unread() ->unread()
->active() ->active()
->update([ ->update([
@@ -177,9 +187,11 @@ public function dismiss(string $sourceType, int $sourceId): bool
public function countByBadge(): array public function countByBadge(): array
{ {
$tenantId = $this->tenantId(); $tenantId = $this->tenantId();
$userId = $this->apiUserId();
$counts = TodayIssue::query() $counts = TodayIssue::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->forUser($userId) // 본인 대상 또는 전체 브로드캐스트
->active() ->active()
->selectRaw('badge, COUNT(*) as count') ->selectRaw('badge, COUNT(*) as count')
->groupBy('badge') ->groupBy('badge')

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('today_issues', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -516,6 +516,11 @@
'deposit_registered' => ':client 입금 :amount원', 'deposit_registered' => ':client 입금 :amount원',
'withdrawal_registered' => ':client 출금 :amount원', 'withdrawal_registered' => ':client 출금 :amount원',
// 기안 상태 변경 알림
'draft_approved' => ':title 결재가 승인되었습니다',
'draft_rejected' => ':title 결재가 반려되었습니다',
'draft_completed' => ':title 결재가 완료되었습니다',
// 하위 호환성 (deprecated) // 하위 호환성 (deprecated)
'order_success' => ':client 신규 수주 :amount원 확정', 'order_success' => ':client 신규 수주 :amount원 확정',
'receivable_overdue' => ':client 미수금 :amount원 연체 :days일', 'receivable_overdue' => ':client 미수금 :amount원 연체 :days일',