오늘 이슈(TodayIssue) 기능 구현
- TodayIssue 모델 및 마이그레이션 추가 - TodayIssueController, TodayIssueService 구현 - TodayIssueObserverService 및 Observer 패턴 적용 - DailyReportService 연동 - Swagger API 문서 업데이트 - 라우트 추가
This commit is contained in:
@@ -2,20 +2,13 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\BadDebts\BadDebt;
|
||||
use App\Models\Orders\Client;
|
||||
use App\Models\Orders\Order;
|
||||
use App\Models\Tenants\Approval;
|
||||
use App\Models\Tenants\ApprovalStep;
|
||||
use App\Models\Tenants\ExpectedExpense;
|
||||
use App\Models\Tenants\Leave;
|
||||
use App\Models\Tenants\Stock;
|
||||
use App\Models\Tenants\TodayIssue;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* CEO 대시보드 오늘의 이슈 리스트 서비스
|
||||
*
|
||||
* 각 카테고리별 상세 알림 항목을 집계하여 리스트 형태로 제공
|
||||
* today_issues 테이블에서 실시간 저장된 데이터를 조회
|
||||
*/
|
||||
class TodayIssueService extends Service
|
||||
{
|
||||
@@ -23,330 +16,180 @@ class TodayIssueService extends Service
|
||||
* 오늘의 이슈 리스트 조회
|
||||
*
|
||||
* @param int $limit 조회할 최대 항목 수 (기본 30)
|
||||
* @param string|null $badge 뱃지 필터 (null이면 전체)
|
||||
*/
|
||||
public function summary(int $limit = 30): array
|
||||
public function summary(int $limit = 30, ?string $badge = null): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$query = TodayIssue::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->active() // 만료되지 않은 이슈만
|
||||
->orderByDesc('created_at');
|
||||
|
||||
// 뱃지 필터
|
||||
if ($badge !== null && $badge !== 'all') {
|
||||
$query->byBadge($badge);
|
||||
}
|
||||
|
||||
// 전체 개수 (필터 적용 전)
|
||||
$totalQuery = TodayIssue::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->active();
|
||||
$totalCount = $totalQuery->count();
|
||||
|
||||
// 결과 조회
|
||||
$issues = $query->limit($limit)->get();
|
||||
|
||||
$items = $issues->map(function (TodayIssue $issue) {
|
||||
return [
|
||||
'id' => $issue->source_type.'_'.$issue->source_id,
|
||||
'badge' => $issue->badge,
|
||||
'content' => $issue->content,
|
||||
'time' => $this->formatRelativeTime($issue->created_at),
|
||||
'date' => $issue->created_at?->toDateString(),
|
||||
'needsApproval' => $issue->needs_approval,
|
||||
'path' => $issue->path,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total_count' => $totalCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 읽지 않은 이슈 목록 조회 (헤더 알림용)
|
||||
*
|
||||
* @param int $limit 조회할 최대 항목 수 (기본 10)
|
||||
*/
|
||||
public function getUnreadList(int $limit = 10): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$issues = TodayIssue::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->unread()
|
||||
->active()
|
||||
->orderByDesc('created_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
$totalCount = TodayIssue::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->unread()
|
||||
->active()
|
||||
->count();
|
||||
|
||||
$items = $issues->map(function (TodayIssue $issue) {
|
||||
return [
|
||||
'id' => $issue->id,
|
||||
'badge' => $issue->badge,
|
||||
'notification_type' => $issue->notification_type,
|
||||
'content' => $issue->content,
|
||||
'path' => $issue->path,
|
||||
'needs_approval' => $issue->needs_approval,
|
||||
'time' => $this->formatRelativeTime($issue->created_at),
|
||||
'created_at' => $issue->created_at?->toIso8601String(),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $totalCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 읽지 않은 이슈 개수 조회 (헤더 알림 뱃지용)
|
||||
*/
|
||||
public function getUnreadCount(): int
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return TodayIssue::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->unread()
|
||||
->active()
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* 이슈 확인 처리
|
||||
*/
|
||||
public function markAsRead(int $issueId): bool
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
$today = Carbon::today();
|
||||
|
||||
// 각 카테고리별 이슈 수집
|
||||
$issues = collect();
|
||||
$issue = TodayIssue::where('tenant_id', $tenantId)
|
||||
->where('id', $issueId)
|
||||
->first();
|
||||
|
||||
// 1. 수주 성공 (최근 7일)
|
||||
$issues = $issues->merge($this->getOrderSuccessIssues($tenantId, $today));
|
||||
if (! $issue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 미수금 이슈 (주식 이슈 - 연체 미수금)
|
||||
$issues = $issues->merge($this->getReceivableIssues($tenantId));
|
||||
return $issue->markAsRead($userId);
|
||||
}
|
||||
|
||||
// 3. 재고 이슈 (직정 제고 - 안전재고 미달)
|
||||
$issues = $issues->merge($this->getStockIssues($tenantId));
|
||||
/**
|
||||
* 모든 이슈 읽음 처리
|
||||
*/
|
||||
public function markAllAsRead(): int
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
// 4. 지출예상내역서 (승인 대기 건)
|
||||
$issues = $issues->merge($this->getExpectedExpenseIssues($tenantId));
|
||||
return TodayIssue::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->unread()
|
||||
->active()
|
||||
->update([
|
||||
'is_read' => true,
|
||||
'read_by' => $userId,
|
||||
'read_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 5. 세금 신고 (부가세 D-day)
|
||||
$issues = $issues->merge($this->getTaxIssues($tenantId, $today));
|
||||
/**
|
||||
* 이슈 삭제 (확인 완료 처리)
|
||||
*/
|
||||
public function dismiss(string $sourceType, int $sourceId): bool
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
// 6. 결재 요청 (내 결재 대기 건)
|
||||
$issues = $issues->merge($this->getApprovalIssues($tenantId, $userId));
|
||||
return TodayIssue::removeBySource($tenantId, $sourceType, $sourceId);
|
||||
}
|
||||
|
||||
// 7. 기타 (신규 거래처 등록)
|
||||
$issues = $issues->merge($this->getOtherIssues($tenantId, $today));
|
||||
/**
|
||||
* 뱃지별 개수 조회
|
||||
*/
|
||||
public function countByBadge(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
// 날짜 기준 내림차순 정렬 후 limit 적용
|
||||
$sortedIssues = $issues
|
||||
->sortByDesc('created_at')
|
||||
->take($limit)
|
||||
->values()
|
||||
->map(function ($item) {
|
||||
// created_at 필드 제거 (정렬용으로만 사용)
|
||||
unset($item['created_at']);
|
||||
|
||||
return $item;
|
||||
})
|
||||
$counts = TodayIssue::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->active()
|
||||
->selectRaw('badge, COUNT(*) as count')
|
||||
->groupBy('badge')
|
||||
->pluck('count', 'badge')
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'items' => $sortedIssues,
|
||||
'total_count' => $issues->count(),
|
||||
];
|
||||
// 전체 개수 추가
|
||||
$counts['all'] = array_sum($counts);
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 성공 이슈 (최근 7일 확정 수주)
|
||||
* 만료된 이슈 정리 (스케줄러에서 호출)
|
||||
*/
|
||||
private function getOrderSuccessIssues(int $tenantId, Carbon $today): array
|
||||
public function cleanupExpiredIssues(): int
|
||||
{
|
||||
$orders = Order::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status_code', 'confirmed')
|
||||
->where('created_at', '>=', $today->copy()->subDays(7))
|
||||
->with('client:id,name')
|
||||
->orderByDesc('created_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return $orders->map(function ($order) {
|
||||
$clientName = $order->client?->name ?? __('message.today_issue.unknown_client');
|
||||
$amount = number_format($order->total_amount ?? 0);
|
||||
|
||||
return [
|
||||
'id' => 'order_'.$order->id,
|
||||
'badge' => '수주 성공',
|
||||
'content' => __('message.today_issue.order_success', [
|
||||
'client' => $clientName,
|
||||
'amount' => $amount,
|
||||
]),
|
||||
'time' => $this->formatRelativeTime($order->created_at),
|
||||
'date' => $order->created_at?->toDateString(),
|
||||
'needsApproval' => false,
|
||||
'path' => '/sales/order-management-sales',
|
||||
'created_at' => $order->created_at,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 미수금 이슈 (주식 이슈 - 연체 미수금)
|
||||
*/
|
||||
private function getReceivableIssues(int $tenantId): array
|
||||
{
|
||||
// BadDebt 모델에서 추심 진행 중인 건 조회
|
||||
$badDebts = BadDebt::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status', ['in_progress', 'legal_action'])
|
||||
->with('client:id,name')
|
||||
->orderByDesc('created_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return $badDebts->map(function ($debt) {
|
||||
$clientName = $debt->client?->name ?? __('message.today_issue.unknown_client');
|
||||
$amount = number_format($debt->total_amount ?? 0);
|
||||
$days = $debt->overdue_days ?? 0;
|
||||
|
||||
return [
|
||||
'id' => 'receivable_'.$debt->id,
|
||||
'badge' => '주식 이슈',
|
||||
'content' => __('message.today_issue.receivable_overdue', [
|
||||
'client' => $clientName,
|
||||
'amount' => $amount,
|
||||
'days' => $days,
|
||||
]),
|
||||
'time' => $this->formatRelativeTime($debt->created_at),
|
||||
'date' => $debt->created_at?->toDateString(),
|
||||
'needsApproval' => false,
|
||||
'path' => '/accounting/receivables-status',
|
||||
'created_at' => $debt->created_at,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고 이슈 (직정 제고 - 안전재고 미달)
|
||||
*/
|
||||
private function getStockIssues(int $tenantId): array
|
||||
{
|
||||
$stocks = Stock::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('safety_stock', '>', 0)
|
||||
->whereColumn('stock_qty', '<', 'safety_stock')
|
||||
->with('item:id,name,code')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return $stocks->map(function ($stock) {
|
||||
$itemName = $stock->item?->name ?? $stock->item?->code ?? __('message.today_issue.unknown_item');
|
||||
|
||||
return [
|
||||
'id' => 'stock_'.$stock->id,
|
||||
'badge' => '직정 제고',
|
||||
'content' => __('message.today_issue.stock_below_safety', [
|
||||
'item' => $itemName,
|
||||
]),
|
||||
'time' => $this->formatRelativeTime($stock->updated_at),
|
||||
'date' => $stock->updated_at?->toDateString(),
|
||||
'needsApproval' => false,
|
||||
'path' => '/material/stock-status',
|
||||
'created_at' => $stock->updated_at,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 지출예상내역서 이슈 (승인 대기)
|
||||
*/
|
||||
private function getExpectedExpenseIssues(int $tenantId): array
|
||||
{
|
||||
$expenses = ExpectedExpense::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('payment_status', 'pending')
|
||||
->orderByDesc('created_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// 그룹화: 같은 날짜의 품의서들을 묶어서 표시
|
||||
if ($expenses->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$totalCount = $expenses->count();
|
||||
$totalAmount = $expenses->sum('amount');
|
||||
$firstExpense = $expenses->first();
|
||||
$title = $firstExpense->description ?? __('message.today_issue.expense_item');
|
||||
|
||||
$content = $totalCount > 1
|
||||
? __('message.today_issue.expense_pending_multiple', [
|
||||
'title' => $title,
|
||||
'count' => $totalCount - 1,
|
||||
'amount' => number_format($totalAmount),
|
||||
])
|
||||
: __('message.today_issue.expense_pending_single', [
|
||||
'title' => $title,
|
||||
'amount' => number_format($totalAmount),
|
||||
]);
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'expense_summary',
|
||||
'badge' => '지출예상내역서',
|
||||
'content' => $content,
|
||||
'time' => $this->formatRelativeTime($firstExpense->created_at),
|
||||
'date' => $firstExpense->created_at?->toDateString(),
|
||||
'needsApproval' => true,
|
||||
'path' => '/approval/inbox',
|
||||
'created_at' => $firstExpense->created_at,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금 신고 이슈 (부가세 D-day)
|
||||
*/
|
||||
private function getTaxIssues(int $tenantId, Carbon $today): array
|
||||
{
|
||||
// 부가세 신고 마감일 계산 (분기별: 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);
|
||||
|
||||
// D-30 이내인 경우에만 표시
|
||||
if ($daysUntil > 30 || $daysUntil < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$quarterName = match ($deadlineMonth) {
|
||||
1 => '4',
|
||||
4 => '1',
|
||||
7 => '2',
|
||||
10 => '3',
|
||||
};
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'tax_vat_'.$deadlineYear.'_'.$deadlineMonth,
|
||||
'badge' => '세금 신고',
|
||||
'content' => __('message.today_issue.tax_vat_deadline', [
|
||||
'quarter' => $quarterName,
|
||||
'days' => $daysUntil,
|
||||
]),
|
||||
'time' => $this->formatRelativeTime($today),
|
||||
'date' => $today->toDateString(),
|
||||
'needsApproval' => false,
|
||||
'path' => '/accounting/tax',
|
||||
'created_at' => $today,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 요청 이슈 (내 결재 대기 건)
|
||||
*/
|
||||
private function getApprovalIssues(int $tenantId, int $userId): array
|
||||
{
|
||||
$steps = ApprovalStep::query()
|
||||
->whereHas('approval', function ($query) use ($tenantId) {
|
||||
$query->where('tenant_id', $tenantId)
|
||||
->where('status', 'pending');
|
||||
})
|
||||
->where('approver_id', $userId)
|
||||
->where('status', 'pending')
|
||||
->with(['approval' => function ($query) {
|
||||
$query->with('drafter:id,name');
|
||||
}])
|
||||
->orderByDesc('created_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return $steps->map(function ($step) {
|
||||
$drafterName = $step->approval->drafter?->name ?? __('message.today_issue.unknown_user');
|
||||
$title = $step->approval->title ?? __('message.today_issue.approval_request');
|
||||
|
||||
return [
|
||||
'id' => 'approval_'.$step->approval->id,
|
||||
'badge' => '결재 요청',
|
||||
'content' => __('message.today_issue.approval_pending', [
|
||||
'title' => $title,
|
||||
'drafter' => $drafterName,
|
||||
]),
|
||||
'time' => $this->formatRelativeTime($step->approval->created_at),
|
||||
'date' => $step->approval->created_at?->toDateString(),
|
||||
'needsApproval' => true,
|
||||
'path' => '/approval/inbox',
|
||||
'created_at' => $step->approval->created_at,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기타 이슈 (신규 거래처 등록 등)
|
||||
*/
|
||||
private function getOtherIssues(int $tenantId, Carbon $today): array
|
||||
{
|
||||
// 최근 7일 신규 거래처
|
||||
$clients = Client::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('created_at', '>=', $today->copy()->subDays(7))
|
||||
->orderByDesc('created_at')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
return $clients->map(function ($client) {
|
||||
return [
|
||||
'id' => 'client_'.$client->id,
|
||||
'badge' => '기타',
|
||||
'content' => __('message.today_issue.new_client', [
|
||||
'name' => $client->name,
|
||||
]),
|
||||
'time' => $this->formatRelativeTime($client->created_at),
|
||||
'date' => $client->created_at?->toDateString(),
|
||||
'needsApproval' => false,
|
||||
'path' => '/accounting/vendors',
|
||||
'created_at' => $client->created_at,
|
||||
];
|
||||
})->toArray();
|
||||
return TodayIssue::where('expires_at', '<', now())->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,4 +224,4 @@ private function formatRelativeTime(?Carbon $datetime): string
|
||||
|
||||
return $datetime->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user