$order->id, 'status_code' => $order->status_code, 'tenant_id' => $order->tenant_id, ]); // 확정 상태이고 최근 7일 이내인 경우만 이슈 생성 $isConfirmed = $order->status_code === Order::STATUS_CONFIRMED; $isRecent = $order->created_at?->gte(Carbon::now()->subDays(7)); if ($isConfirmed && $isRecent) { $clientName = $order->client?->name ?? __('message.today_issue.unknown_client'); $amount = number_format($order->total_amount ?? 0); Log::info('TodayIssue: Creating order success issue', [ 'order_id' => $order->id, 'client' => $clientName, 'amount' => $amount, ]); $this->createIssueWithFcm( tenantId: $order->tenant_id, sourceType: TodayIssue::SOURCE_ORDER, sourceId: $order->id, badge: TodayIssue::BADGE_ORDER_REGISTER, content: __('message.today_issue.order_register', [ 'client' => $clientName, 'amount' => $amount, ]), path: "/sales/order-management-sales/{$order->id}", needsApproval: false, expiresAt: Carbon::now()->addDays(7) ); } else { Log::info('TodayIssue: Order does not meet criteria, removing issue if exists', [ 'order_id' => $order->id, 'is_confirmed' => $isConfirmed, 'is_recent' => $isRecent, ]); // 조건에 맞지 않으면 이슈 삭제 TodayIssue::removeBySource($order->tenant_id, TodayIssue::SOURCE_ORDER, $order->id); } } /** * 수주 삭제 시 이슈 삭제 */ public function handleOrderDeleted(Order $order): void { TodayIssue::removeBySource($order->tenant_id, TodayIssue::SOURCE_ORDER, $order->id); } /** * 미수금(추심 이슈) 생성/삭제 * * is_active가 true로 설정(토글 ON)되면 오늘의 이슈에 추가 * is_active가 false로 설정(토글 OFF)되면 오늘의 이슈에서 삭제 */ public function handleBadDebtChange(BadDebt $badDebt): void { // is_active(설정 토글)이 켜져 있고, 추심중 또는 법적조치 상태인 경우 이슈 생성 $isActiveStatus = in_array($badDebt->status, [BadDebt::STATUS_COLLECTING, BadDebt::STATUS_LEGAL_ACTION]); if ($badDebt->is_active && $isActiveStatus) { $clientName = $badDebt->client?->name ?? __('message.today_issue.unknown_client'); $amount = number_format($badDebt->debt_amount ?? 0); $days = $badDebt->overdue_days ?? 0; $this->createIssueWithFcm( tenantId: $badDebt->tenant_id, sourceType: TodayIssue::SOURCE_BAD_DEBT, sourceId: $badDebt->id, badge: TodayIssue::BADGE_COLLECTION_ISSUE, content: __('message.today_issue.collection_issue', [ 'client' => $clientName, 'amount' => $amount, 'days' => $days, ]), path: "/accounting/bad-debt-collection/{$badDebt->id}", needsApproval: false, expiresAt: null // 해결될 때까지 유지 ); } else { // is_active가 false이거나 상태가 회수완료/대손처리인 경우 이슈 삭제 TodayIssue::removeBySource($badDebt->tenant_id, TodayIssue::SOURCE_BAD_DEBT, $badDebt->id); } } /** * 미수금 삭제 시 이슈 삭제 */ public function handleBadDebtDeleted(BadDebt $badDebt): void { TodayIssue::removeBySource($badDebt->tenant_id, TodayIssue::SOURCE_BAD_DEBT, $badDebt->id); } /** * 재고 이슈(직정 제고) 생성/삭제 */ public function handleStockChange(Stock $stock): void { // 안전재고 미달인 경우만 이슈 생성 if ($stock->safety_stock > 0 && $stock->stock_qty < $stock->safety_stock) { $itemName = $stock->item?->name ?? $stock->item?->code ?? __('message.today_issue.unknown_item'); $this->createIssueWithFcm( tenantId: $stock->tenant_id, sourceType: TodayIssue::SOURCE_STOCK, sourceId: $stock->id, badge: TodayIssue::BADGE_SAFETY_STOCK, content: __('message.today_issue.safety_stock_alert', [ 'item' => $itemName, ]), path: "/material/stock-status/{$stock->id}", needsApproval: false, expiresAt: null // 재고 보충 시까지 유지 ); } else { TodayIssue::removeBySource($stock->tenant_id, TodayIssue::SOURCE_STOCK, $stock->id); } } /** * 재고 삭제 시 이슈 삭제 */ public function handleStockDeleted(Stock $stock): void { TodayIssue::removeBySource($stock->tenant_id, TodayIssue::SOURCE_STOCK, $stock->id); } /** * 지출예상내역서 이슈 생성/삭제 */ public function handleExpectedExpenseChange(ExpectedExpense $expense): void { // 매입(purchases)에서 동기화된 예상 지출은 알림 제외 // 매입 알림은 결재 상신 시에만 발송 if ($expense->source_type === 'purchases') { return; } // 승인 대기 상태인 경우만 이슈 생성 if ($expense->payment_status === 'pending') { $title = $expense->description ?? __('message.today_issue.expense_item'); $amount = number_format($expense->amount ?? 0); $this->createIssueWithFcm( tenantId: $expense->tenant_id, sourceType: TodayIssue::SOURCE_EXPENSE, sourceId: $expense->id, badge: TodayIssue::BADGE_EXPENSE_PENDING, content: __('message.today_issue.expense_pending', [ 'title' => $title, 'amount' => $amount, ]), path: '/approval/inbox', needsApproval: true, expiresAt: null // 처리 완료 시까지 유지 ); } else { TodayIssue::removeBySource($expense->tenant_id, TodayIssue::SOURCE_EXPENSE, $expense->id); } } /** * 지출예상내역서 삭제 시 이슈 삭제 */ public function handleExpectedExpenseDeleted(ExpectedExpense $expense): void { TodayIssue::removeBySource($expense->tenant_id, TodayIssue::SOURCE_EXPENSE, $expense->id); } /** * 결재 요청 이슈 생성/삭제 * * 결재자(ApprovalStep.user_id)에게만 알림 발송 */ public function handleApprovalStepChange(ApprovalStep $step): void { // 승인 대기 상태이고, 결재 문서도 pending 상태인 경우만 이슈 생성 $approval = $step->approval; if ($step->status === 'pending' && $approval && $approval->status === 'pending') { $drafterName = $approval->drafter?->name ?? __('message.today_issue.unknown_user'); $title = $approval->title ?? __('message.today_issue.approval_request'); $this->createIssueWithFcm( tenantId: $approval->tenant_id, sourceType: TodayIssue::SOURCE_APPROVAL, sourceId: $step->id, badge: TodayIssue::BADGE_APPROVAL_REQUEST, content: __('message.today_issue.approval_pending', [ 'title' => $title, 'drafter' => $drafterName, ]), path: '/approval/inbox', needsApproval: true, expiresAt: null, // 결재 완료 시까지 유지 targetUserId: $step->user_id // 결재자에게만 발송 ); } else { if ($approval) { TodayIssue::removeBySource($approval->tenant_id, TodayIssue::SOURCE_APPROVAL, $step->id); } } } /** * 결재 단계 삭제 시 이슈 삭제 */ public function handleApprovalStepDeleted(ApprovalStep $step): void { if ($step->approval) { TodayIssue::removeBySource($step->approval->tenant_id, TodayIssue::SOURCE_APPROVAL, $step->id); } } /** * 결재 상태 변경 시 기안자에게 알림 발송 * * 승인(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 // 기안자에게만 발송 ); } /** * 신규 거래처 이슈 생성 */ public function handleClientCreated(Client $client): void { $this->createIssueWithFcm( tenantId: $client->tenant_id, sourceType: TodayIssue::SOURCE_CLIENT, sourceId: $client->id, badge: TodayIssue::BADGE_NEW_CLIENT, content: __('message.today_issue.new_client', [ 'name' => $client->name, ]), path: "/accounting/vendors/{$client->id}?modal=credit", needsApproval: false, expiresAt: Carbon::now()->addDays(7) ); } /** * 거래처 삭제 시 이슈 삭제 */ 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/deposits/{$deposit->id}", needsApproval: false, expiresAt: Carbon::now()->addDays(7) ); } /** * 입금 삭제 시 이슈 삭제 */ public function handleDepositDeleted(Deposit $deposit): void { TodayIssue::removeBySource($deposit->tenant_id, TodayIssue::SOURCE_DEPOSIT, $deposit->id); } /** * 출금 등록 이슈 생성 (일일 누계 방식) * * - 첫 번째 출금: "거래처명 출금 금액원" * - 두 번째 이후: "첫번째거래처명 외 N건 출금 합계 금액원" * - source_id=null로 일일 1건만 유지 (upsert) * - FCM 푸시 미발송 (알림 설정 미구현) */ public function handleWithdrawalCreated(Withdrawal $withdrawal): void { Log::info('TodayIssue: Withdrawal created detected', [ 'withdrawal_id' => $withdrawal->id, 'tenant_id' => $withdrawal->tenant_id, 'amount' => $withdrawal->amount, ]); $this->upsertWithdrawalDailyIssue($withdrawal->tenant_id); } /** * 출금 삭제 시 일일 누계 이슈 갱신 */ public function handleWithdrawalDeleted(Withdrawal $withdrawal): void { $this->upsertWithdrawalDailyIssue($withdrawal->tenant_id); } /** * 출금 일일 누계 이슈 upsert * * 오늘 날짜의 출금 건수/합계를 집계하여 이슈 1건으로 관리 */ private function upsertWithdrawalDailyIssue(int $tenantId): void { // 기존 개별 출금 이슈 정리 (source_id가 있는 레거시 데이터) TodayIssue::where('tenant_id', $tenantId) ->where('source_type', TodayIssue::SOURCE_WITHDRAWAL) ->whereNotNull('source_id') ->delete(); // 오늘 날짜 출금 조회 (출금일 기준, soft delete 제외) $todayWithdrawals = Withdrawal::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->whereDate('withdrawal_date', Carbon::today()) ->whereNull('deleted_at') ->orderBy('created_at', 'asc') ->get(); $count = $todayWithdrawals->count(); $totalAmount = $todayWithdrawals->sum('amount'); Log::info('TodayIssue: Withdrawal daily summary', [ 'tenant_id' => $tenantId, 'count' => $count, 'total_amount' => $totalAmount, ]); // 출금이 없으면 이슈 삭제 if ($count === 0) { TodayIssue::where('tenant_id', $tenantId) ->where('source_type', TodayIssue::SOURCE_WITHDRAWAL) ->whereNull('source_id') ->delete(); return; } // 첫 번째 출금의 거래처명 $first = $todayWithdrawals->first(); $firstClientName = $first->display_client_name ?: $first->merchant_name ?: __('message.today_issue.unknown_client'); // 1건이면 단건 메시지+상세경로, 2건 이상이면 누계 메시지+목록경로 if ($count === 1) { $content = __('message.today_issue.withdrawal_registered', [ 'client' => $firstClientName, 'amount' => number_format($totalAmount), ]); $path = "/accounting/withdrawals/{$first->id}"; } else { $content = __('message.today_issue.withdrawal_daily_summary', [ 'client' => $firstClientName, 'count' => $count - 1, 'amount' => number_format($totalAmount), ]); $path = '/accounting/withdrawals'; } // source_id=null 로 일일 1건 upsert (FCM 미발송) TodayIssue::createIssue( tenantId: $tenantId, sourceType: TodayIssue::SOURCE_WITHDRAWAL, sourceId: null, badge: TodayIssue::BADGE_WITHDRAWAL, content: $content, path: $path, needsApproval: false, expiresAt: Carbon::now()->addDays(7) ); } /** * 세금 신고 이슈 업데이트 (스케줄러에서 호출) * 이 메서드는 일일 스케줄러에서 호출하여 세금 신고 이슈를 업데이트합니다. */ public function updateTaxIssues(int $tenantId): void { $today = Carbon::today(); // 부가세 신고 마감일 계산 (분기별: 1/25, 4/25, 7/25, 10/25) $quarter = $today->quarter; $deadlineMonth = match ($quarter) { 1 => 1, 2 => 4, 3 => 7, 4 => 10, }; $deadlineYear = $today->year; if ($today->month > $deadlineMonth || ($today->month == $deadlineMonth && $today->day > 25)) { $deadlineMonth = match ($quarter) { 1 => 4, 2 => 7, 3 => 10, 4 => 1, }; if ($deadlineMonth == 1) { $deadlineYear++; } } $deadline = Carbon::create($deadlineYear, $deadlineMonth, 25); $daysUntil = $today->diffInDays($deadline, false); // 기존 세금 신고 이슈 삭제 TodayIssue::where('tenant_id', $tenantId) ->where('source_type', TodayIssue::SOURCE_TAX) ->delete(); // D-30 이내인 경우에만 표시 if ($daysUntil <= 30 && $daysUntil >= 0) { $quarterName = match ($deadlineMonth) { 1 => '4', 4 => '1', 7 => '2', 10 => '3', }; $this->createIssueWithFcm( tenantId: $tenantId, sourceType: TodayIssue::SOURCE_TAX, sourceId: null, // 세금 신고는 특정 소스 ID가 없음 badge: TodayIssue::BADGE_TAX_REPORT, content: __('message.today_issue.tax_vat_deadline', [ 'quarter' => $quarterName, 'days' => $daysUntil, ]), path: '/accounting/tax', needsApproval: false, expiresAt: $deadline ); } } // ============================================================ // FCM 푸시 알림 발송 헬퍼 // ============================================================ /** * TodayIssue 생성 후 FCM 푸시 알림 발송 * * target_user_id가 있으면 해당 사용자에게만, 없으면 테넌트 전체에게 발송 * 알림 설정이 활성화된 사용자에게만 발송 * 중요 알림(수주등록, 신규거래처, 결재요청)은 알림음 다르게 발송 */ public function sendFcmNotification(TodayIssue $issue): void { if (! $issue->notification_type) { return; } try { // 해당 테넌트의 활성 토큰 조회 (알림 설정 활성화된 사용자만) // 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; } // 알림 타입에 따른 채널 결정 $channelId = $this->getChannelForNotificationType($issue->notification_type); $result = $this->fcmSender->sendToMany( $tokens, $issue->badge, $issue->content, $channelId, [ 'type' => 'today_issue', 'issue_id' => $issue->id, 'notification_type' => $issue->notification_type, 'path' => $issue->path, ] ); 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(), 'failure_count' => $result->getFailureCount(), ]); } catch (\Exception $e) { 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(), ]); } } /** * 알림 설정이 활성화된 사용자들의 FCM 토큰 조회 * * @param int $tenantId 테넌트 ID * @param string $notificationType 알림 타입 * @param int|null $targetUserId 특정 대상 사용자 ID (null이면 테넌트 전체) */ private function getEnabledUserTokens(int $tenantId, string $notificationType, ?int $targetUserId = null): array { // 해당 테넌트의 활성 토큰 조회 $query = PushDeviceToken::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('is_active', true) ->whereNull('deleted_at'); // 특정 대상자가 지정된 경우 해당 사용자만 조회 if ($targetUserId !== null) { $query->where('user_id', $targetUserId); } $tokens = $query->get(); if ($tokens->isEmpty()) { return []; } // 각 사용자의 알림 설정 확인 $enabledTokens = []; foreach ($tokens as $token) { if ($this->isNotificationEnabledForUser($tenantId, $token->user_id, $notificationType)) { $enabledTokens[] = $token->token; } } return $enabledTokens; } /** * 특정 사용자의 특정 알림 타입 활성화 여부 확인 * * 설정이 없으면 기본값 true (알림 활성화) */ private function isNotificationEnabledForUser(int $tenantId, int $userId, string $notificationType): bool { $setting = NotificationSetting::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('user_id', $userId) ->where('notification_type', $notificationType) ->first(); // 설정이 없으면 기본값 true if (! $setting) { return true; } // push_enabled 또는 in_app_enabled 중 하나라도 활성화되어 있으면 true return $setting->push_enabled || $setting->in_app_enabled; } /** * 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, string $sourceType, ?int $sourceId, string $badge, string $content, ?string $path = null, bool $needsApproval = false, ?\DateTime $expiresAt = null, ?int $targetUserId = null ): TodayIssue { $issue = TodayIssue::createIssue( tenantId: $tenantId, sourceType: $sourceType, sourceId: $sourceId, badge: $badge, content: $content, path: $path, needsApproval: $needsApproval, expiresAt: $expiresAt, targetUserId: $targetUserId ); // FCM 발송 $this->sendFcmNotification($issue); return $issue; } /** * 알림 타입에 따른 FCM 채널 ID 반환 * * 채널별 알림음: * - push_urgent: 긴급(신규업체) * - push_payment: 결재 * - push_sales_order: 수주 * - push_purchase_order: 발주 * - push_contract: 계약 * - push_default: 일반 (입금, 출금, 카드 등) */ private function getChannelForNotificationType(?string $notificationType): string { return match ($notificationType) { 'new_vendor' => 'push_urgent', // 긴급(신규업체) 'approval_request' => 'push_payment', // 결재 'sales_order' => 'push_sales_order', // 수주 'purchase_order' => 'push_purchase_order', // 발주 'contract' => 'push_contract', // 계약 default => 'push_default', // 일반 (입금, 출금, 카드 등) }; } }