오늘 이슈(TodayIssue) 기능 구현
- TodayIssue 모델 및 마이그레이션 추가 - TodayIssueController, TodayIssueService 구현 - TodayIssueObserverService 및 Observer 패턴 적용 - DailyReportService 연동 - Swagger API 문서 업데이트 - 라우트 추가
This commit is contained in:
470
app/Services/TodayIssueObserverService.php
Normal file
470
app/Services/TodayIssueObserverService.php
Normal file
@@ -0,0 +1,470 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\BadDebts\BadDebt;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Models\Orders\Client;
|
||||
use App\Models\Orders\Order;
|
||||
use App\Models\PushDeviceToken;
|
||||
use App\Models\Tenants\ApprovalStep;
|
||||
use App\Models\Tenants\ExpectedExpense;
|
||||
use App\Models\Tenants\Stock;
|
||||
use App\Models\Tenants\TodayIssue;
|
||||
use App\Services\Fcm\FcmSender;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 오늘의 이슈 Observer 서비스
|
||||
*
|
||||
* 모델 이벤트 발생 시 TodayIssue 테이블에 실시간 저장/삭제
|
||||
* FCM 푸시 알림 연동
|
||||
*/
|
||||
class TodayIssueObserverService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FcmSender $fcmSender
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 수주 성공 이슈 생성/삭제
|
||||
*/
|
||||
public function handleOrderChange(Order $order): void
|
||||
{
|
||||
Log::info('TodayIssue: Order change detected', [
|
||||
'order_id' => $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',
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미수금(주식 이슈) 생성/삭제
|
||||
*/
|
||||
public function handleBadDebtChange(BadDebt $badDebt): void
|
||||
{
|
||||
// 추심 진행 중인 건만 이슈 생성
|
||||
if (in_array($badDebt->status, ['in_progress', 'legal_action'])) {
|
||||
$clientName = $badDebt->client?->name ?? __('message.today_issue.unknown_client');
|
||||
$amount = number_format($badDebt->total_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/receivables-status',
|
||||
needsApproval: false,
|
||||
expiresAt: null // 해결될 때까지 유지
|
||||
);
|
||||
} else {
|
||||
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',
|
||||
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
|
||||
{
|
||||
// 승인 대기 상태인 경우만 이슈 생성
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 요청 이슈 생성/삭제
|
||||
*/
|
||||
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 // 결재 완료 시까지 유지
|
||||
);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 신규 거래처 이슈 생성
|
||||
*/
|
||||
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',
|
||||
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 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 푸시 알림 발송
|
||||
*
|
||||
* 알림 설정이 활성화된 사용자에게만 발송
|
||||
* 중요 알림(수주등록, 신규거래처, 결재요청)은 알림음 다르게 발송
|
||||
*/
|
||||
public function sendFcmNotification(TodayIssue $issue): void
|
||||
{
|
||||
if (! $issue->notification_type) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 해당 테넌트의 활성 토큰 조회 (알림 설정 활성화된 사용자만)
|
||||
$tokens = $this->getEnabledUserTokens($issue->tenant_id, $issue->notification_type);
|
||||
|
||||
if (empty($tokens)) {
|
||||
Log::info('[TodayIssue] No enabled tokens found for FCM', [
|
||||
'tenant_id' => $issue->tenant_id,
|
||||
'notification_type' => $issue->notification_type,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 중요 알림 여부에 따른 채널 결정
|
||||
$channelId = $issue->isImportantNotification() ? 'push_urgent' : 'push_default';
|
||||
|
||||
$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,
|
||||
'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,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 설정이 활성화된 사용자들의 FCM 토큰 조회
|
||||
*/
|
||||
private function getEnabledUserTokens(int $tenantId, string $notificationType): array
|
||||
{
|
||||
// 해당 테넌트의 활성 토큰 조회
|
||||
$tokens = PushDeviceToken::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->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 발송
|
||||
*/
|
||||
public function createIssueWithFcm(
|
||||
int $tenantId,
|
||||
string $sourceType,
|
||||
?int $sourceId,
|
||||
string $badge,
|
||||
string $content,
|
||||
?string $path = null,
|
||||
bool $needsApproval = false,
|
||||
?\DateTime $expiresAt = null
|
||||
): TodayIssue {
|
||||
$issue = TodayIssue::createIssue(
|
||||
tenantId: $tenantId,
|
||||
sourceType: $sourceType,
|
||||
sourceId: $sourceId,
|
||||
badge: $badge,
|
||||
content: $content,
|
||||
path: $path,
|
||||
needsApproval: $needsApproval,
|
||||
expiresAt: $expiresAt
|
||||
);
|
||||
|
||||
// FCM 발송
|
||||
$this->sendFcmNotification($issue);
|
||||
|
||||
return $issue;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user