diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index d65c40a..c95d053 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -1,3 +1,42 @@ +## 2026-01-21 (화) - TodayIssue 헤더 알림 API (Phase 3 완료) + +### 작업 목표 +- TodayIssue + 알림 시스템 통합 Phase 3: 헤더 알림 API 구현 +- 읽지 않은 이슈 목록/개수 조회, 읽음 처리 API 구현 + +### 수정된 파일 +| 파일명 | 설명 | +|--------|------| +| `app/Services/TodayIssueService.php` | getUnreadList(), getUnreadCount(), markAllAsRead() 추가 | +| `app/Http/Controllers/Api/V1/TodayIssueController.php` | unread(), unreadCount(), markAsRead(), markAllAsRead() 추가 | +| `routes/api.php` | 4개 엔드포인트 추가 | +| `lang/ko/message.php` | today_issue.marked_as_read, all_marked_as_read 메시지 추가 | +| `app/Swagger/v1/TodayIssueApi.php` | 4개 엔드포인트 + 스키마 문서화 | +| `app/Swagger/v1/ComprehensiveAnalysisApi.php` | 스키마 이름 충돌 해결 (TodayIssueItem → ComprehensiveTodayIssueItem) | + +### API 엔드포인트 +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/api/v1/today-issues/unread` | 읽지 않은 이슈 목록 (헤더 알림 드롭다운용) | +| GET | `/api/v1/today-issues/unread/count` | 읽지 않은 이슈 개수 (헤더 뱃지용) | +| POST | `/api/v1/today-issues/{id}/read` | 단일 이슈 읽음 처리 | +| POST | `/api/v1/today-issues/read-all` | 모든 이슈 읽음 처리 | + +### 검증 완료 +- [x] Pint 코드 스타일 통과 +- [x] Swagger 문서 생성 완료 +- [x] PHP 문법 검증 통과 +- [x] Service-First 아키텍처 준수 +- [x] Multi-tenancy (tenant_id 필터링) 적용 +- [x] i18n 메시지 키 사용 + +### 계획 문서 +- `docs/plans/today-issue-notification-integration-plan.md` +- 백엔드 작업 완료 (Phase 1-3: 100%) +- Phase 4 (React 헤더 연동)는 프론트엔드 담당 + +--- + ## 2026-01-11 (토) - Labor(노임관리) API 구현 ### 작업 목표 diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 4ebd731..e85a746 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-01-21 10:38:32 +> **자동 생성**: 2026-01-21 20:45:47 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -730,6 +730,11 @@ ### expected_expenses - **bankAccount()**: belongsTo → `bank_accounts` - **creator()**: belongsTo → `users` +### expense_accounts +**모델**: `App\Models\Tenants\ExpenseAccount` + +- **vendor()**: belongsTo → `clients` + ### leaves **모델**: `App\Models\Tenants\Leave` @@ -818,6 +823,12 @@ ### sales - **deposit()**: belongsTo → `deposits` - **creator()**: belongsTo → `users` +### schedules +**모델**: `App\Models\Tenants\Schedule` + +- **creator()**: belongsTo → `users` +- **updater()**: belongsTo → `users` + ### setting_field_defs **모델**: `App\Models\Tenants\SettingFieldDef` @@ -923,6 +934,10 @@ ### tenant_user_profiles - **rankPosition()**: belongsTo → `positions` - **titlePosition()**: belongsTo → `positions` +### today_issues +**모델**: `App\Models\Tenants\TodayIssue` + + ### withdrawals **모델**: `App\Models\Tenants\Withdrawal` diff --git a/app/Http/Controllers/Api/V1/TodayIssueController.php b/app/Http/Controllers/Api/V1/TodayIssueController.php index a4330d3..e25eff5 100644 --- a/app/Http/Controllers/Api/V1/TodayIssueController.php +++ b/app/Http/Controllers/Api/V1/TodayIssueController.php @@ -2,8 +2,8 @@ namespace App\Http\Controllers\Api\V1; -use App\Http\Controllers\Controller; use App\Helpers\ApiResponse; +use App\Http\Controllers\Controller; use App\Services\TodayIssueService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -25,4 +25,53 @@ public function summary(Request $request): JsonResponse return $this->todayIssueService->summary($limit); }, __('message.fetched')); } -} \ No newline at end of file + + /** + * 읽지 않은 이슈 목록 조회 (헤더 알림용) + */ + public function unread(Request $request): JsonResponse + { + $limit = (int) $request->input('limit', 10); + + return ApiResponse::handle(function () use ($limit) { + return $this->todayIssueService->getUnreadList($limit); + }, __('message.fetched')); + } + + /** + * 읽지 않은 이슈 개수 조회 (헤더 알림 뱃지용) + */ + public function unreadCount(): JsonResponse + { + return ApiResponse::handle(function () { + return ['count' => $this->todayIssueService->getUnreadCount()]; + }, __('message.fetched')); + } + + /** + * 이슈 읽음 처리 + */ + public function markAsRead(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $result = $this->todayIssueService->markAsRead($id); + if (! $result) { + throw new \App\Exceptions\ApiException(__('error.not_found'), 404); + } + + return null; + }, __('message.today_issue.marked_as_read')); + } + + /** + * 모든 이슈 읽음 처리 + */ + public function markAllAsRead(): JsonResponse + { + return ApiResponse::handle(function () { + $count = $this->todayIssueService->markAllAsRead(); + + return ['count' => $count]; + }, __('message.today_issue.all_marked_as_read')); + } +} diff --git a/app/Models/NotificationSettingGroup.php b/app/Models/NotificationSettingGroup.php index 057a44d..a2ae4b0 100644 --- a/app/Models/NotificationSettingGroup.php +++ b/app/Models/NotificationSettingGroup.php @@ -95,6 +95,15 @@ class NotificationSettingGroup extends Model ['notification_type' => 'production_complete', 'label' => '생산완료 알림', 'sort_order' => 2], ], ], + [ + 'code' => 'collection', + 'name' => '채권/지출 알림', + 'sort_order' => 8, + 'items' => [ + ['notification_type' => 'bad_debt', 'label' => '추심이슈 알림', 'sort_order' => 1], + ['notification_type' => 'expected_expense', 'label' => '지출 승인대기 알림', 'sort_order' => 2], + ], + ], ]; /** @@ -115,6 +124,8 @@ class NotificationSettingGroup extends Model 'draft_completed' => 'draftCompleted', 'safety_stock' => 'safetyStock', 'production_complete' => 'productionComplete', + 'bad_debt' => 'badDebt', + 'expected_expense' => 'expectedExpense', ]; /** diff --git a/app/Models/Tenants/TodayIssue.php b/app/Models/Tenants/TodayIssue.php new file mode 100644 index 0000000..175332f --- /dev/null +++ b/app/Models/Tenants/TodayIssue.php @@ -0,0 +1,208 @@ + 'boolean', + 'is_read' => 'boolean', + 'read_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + + // 소스 타입 상수 + public const SOURCE_ORDER = 'order'; + + public const SOURCE_BAD_DEBT = 'bad_debt'; + + public const SOURCE_STOCK = 'stock'; + + public const SOURCE_EXPENSE = 'expense'; + + public const SOURCE_TAX = 'tax'; + + public const SOURCE_APPROVAL = 'approval'; + + public const SOURCE_CLIENT = 'client'; + + // 뱃지 타입 상수 + public const BADGE_ORDER_REGISTER = '수주등록'; + + public const BADGE_COLLECTION_ISSUE = '추심이슈'; + + public const BADGE_SAFETY_STOCK = '안전재고'; + + public const BADGE_EXPENSE_PENDING = '지출 승인대기'; + + public const BADGE_TAX_REPORT = '세금 신고'; + + public const BADGE_APPROVAL_REQUEST = '결재 요청'; + + public const BADGE_NEW_CLIENT = '신규거래처'; + + // 뱃지 → notification_type 매핑 + public const BADGE_TO_NOTIFICATION_TYPE = [ + self::BADGE_ORDER_REGISTER => 'sales_order', + self::BADGE_NEW_CLIENT => 'new_vendor', + self::BADGE_APPROVAL_REQUEST => 'approval_request', + self::BADGE_COLLECTION_ISSUE => 'bad_debt', + self::BADGE_SAFETY_STOCK => 'safety_stock', + self::BADGE_EXPENSE_PENDING => 'expected_expense', + self::BADGE_TAX_REPORT => 'vat_report', + ]; + + // 중요 알림 (푸시 알림음) - 수주등록, 신규거래처, 결재요청 + public const IMPORTANT_NOTIFICATIONS = [ + 'sales_order', + 'new_vendor', + 'approval_request', + ]; + + /** + * 확인한 사용자 + */ + public function reader(): BelongsTo + { + return $this->belongsTo(User::class, 'read_by'); + } + + /** + * 읽지 않은 이슈 스코프 + */ + public function scopeUnread($query) + { + return $query->where('is_read', false); + } + + /** + * 만료되지 않은 이슈 스코프 + */ + public function scopeActive($query) + { + return $query->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } + + /** + * 뱃지별 필터 스코프 + */ + public function scopeByBadge($query, string $badge) + { + return $query->where('badge', $badge); + } + + /** + * 소스별 필터 스코프 + */ + public function scopeBySource($query, string $sourceType, ?int $sourceId = null) + { + $query->where('source_type', $sourceType); + + if ($sourceId !== null) { + $query->where('source_id', $sourceId); + } + + return $query; + } + + /** + * 이슈 확인 처리 + */ + public function markAsRead(int $userId): bool + { + return $this->update([ + 'is_read' => true, + 'read_by' => $userId, + 'read_at' => now(), + ]); + } + + /** + * 이슈 생성 헬퍼 (정적 메서드) + */ + public static function createIssue( + int $tenantId, + string $sourceType, + ?int $sourceId, + string $badge, + string $content, + ?string $path = null, + bool $needsApproval = false, + ?\DateTime $expiresAt = null + ): self { + // badge에서 notification_type 자동 매핑 + $notificationType = self::BADGE_TO_NOTIFICATION_TYPE[$badge] ?? null; + + return self::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'source_type' => $sourceType, + 'source_id' => $sourceId, + ], + [ + 'badge' => $badge, + 'notification_type' => $notificationType, + 'content' => $content, + 'path' => $path, + 'needs_approval' => $needsApproval, + 'expires_at' => $expiresAt, + 'is_read' => false, + 'read_by' => null, + 'read_at' => null, + ] + ); + } + + /** + * 중요 알림 여부 확인 + */ + public function isImportantNotification(): bool + { + return in_array($this->notification_type, self::IMPORTANT_NOTIFICATIONS, true); + } + + /** + * 소스 기준 이슈 삭제 헬퍼 (정적 메서드) + */ + public static function removeBySource(int $tenantId, string $sourceType, int $sourceId): bool + { + return self::where('tenant_id', $tenantId) + ->where('source_type', $sourceType) + ->where('source_id', $sourceId) + ->delete() > 0; + } +} diff --git a/app/Observers/TodayIssue/ApprovalStepIssueObserver.php b/app/Observers/TodayIssue/ApprovalStepIssueObserver.php new file mode 100644 index 0000000..136af6c --- /dev/null +++ b/app/Observers/TodayIssue/ApprovalStepIssueObserver.php @@ -0,0 +1,43 @@ +safeExecute(fn () => $this->service->handleApprovalStepChange($step)); + } + + public function updated(ApprovalStep $step): void + { + $this->safeExecute(fn () => $this->service->handleApprovalStepChange($step)); + } + + public function deleted(ApprovalStep $step): void + { + $this->safeExecute(fn () => $this->service->handleApprovalStepDeleted($step)); + } + + protected function safeExecute(callable $callback): void + { + try { + $callback(); + } catch (\Throwable $e) { + Log::warning('TodayIssue ApprovalStepObserver failed', [ + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Observers/TodayIssue/BadDebtIssueObserver.php b/app/Observers/TodayIssue/BadDebtIssueObserver.php new file mode 100644 index 0000000..a8600ec --- /dev/null +++ b/app/Observers/TodayIssue/BadDebtIssueObserver.php @@ -0,0 +1,43 @@ +safeExecute(fn () => $this->service->handleBadDebtChange($badDebt)); + } + + public function updated(BadDebt $badDebt): void + { + $this->safeExecute(fn () => $this->service->handleBadDebtChange($badDebt)); + } + + public function deleted(BadDebt $badDebt): void + { + $this->safeExecute(fn () => $this->service->handleBadDebtDeleted($badDebt)); + } + + protected function safeExecute(callable $callback): void + { + try { + $callback(); + } catch (\Throwable $e) { + Log::warning('TodayIssue BadDebtObserver failed', [ + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Observers/TodayIssue/ClientIssueObserver.php b/app/Observers/TodayIssue/ClientIssueObserver.php new file mode 100644 index 0000000..b13e26a --- /dev/null +++ b/app/Observers/TodayIssue/ClientIssueObserver.php @@ -0,0 +1,38 @@ +safeExecute(fn () => $this->service->handleClientCreated($client)); + } + + public function deleted(Client $client): void + { + $this->safeExecute(fn () => $this->service->handleClientDeleted($client)); + } + + protected function safeExecute(callable $callback): void + { + try { + $callback(); + } catch (\Throwable $e) { + Log::warning('TodayIssue ClientObserver failed', [ + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Observers/TodayIssue/ExpectedExpenseIssueObserver.php b/app/Observers/TodayIssue/ExpectedExpenseIssueObserver.php new file mode 100644 index 0000000..744d799 --- /dev/null +++ b/app/Observers/TodayIssue/ExpectedExpenseIssueObserver.php @@ -0,0 +1,43 @@ +safeExecute(fn () => $this->service->handleExpectedExpenseChange($expense)); + } + + public function updated(ExpectedExpense $expense): void + { + $this->safeExecute(fn () => $this->service->handleExpectedExpenseChange($expense)); + } + + public function deleted(ExpectedExpense $expense): void + { + $this->safeExecute(fn () => $this->service->handleExpectedExpenseDeleted($expense)); + } + + protected function safeExecute(callable $callback): void + { + try { + $callback(); + } catch (\Throwable $e) { + Log::warning('TodayIssue ExpectedExpenseObserver failed', [ + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Observers/TodayIssue/OrderIssueObserver.php b/app/Observers/TodayIssue/OrderIssueObserver.php new file mode 100644 index 0000000..0c3ef4b --- /dev/null +++ b/app/Observers/TodayIssue/OrderIssueObserver.php @@ -0,0 +1,43 @@ +safeExecute(fn () => $this->service->handleOrderChange($order)); + } + + public function updated(Order $order): void + { + $this->safeExecute(fn () => $this->service->handleOrderChange($order)); + } + + public function deleted(Order $order): void + { + $this->safeExecute(fn () => $this->service->handleOrderDeleted($order)); + } + + protected function safeExecute(callable $callback): void + { + try { + $callback(); + } catch (\Throwable $e) { + Log::warning('TodayIssue OrderObserver failed', [ + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Observers/TodayIssue/StockIssueObserver.php b/app/Observers/TodayIssue/StockIssueObserver.php new file mode 100644 index 0000000..fa4629e --- /dev/null +++ b/app/Observers/TodayIssue/StockIssueObserver.php @@ -0,0 +1,43 @@ +safeExecute(fn () => $this->service->handleStockChange($stock)); + } + + public function updated(Stock $stock): void + { + $this->safeExecute(fn () => $this->service->handleStockChange($stock)); + } + + public function deleted(Stock $stock): void + { + $this->safeExecute(fn () => $this->service->handleStockDeleted($stock)); + } + + protected function safeExecute(callable $callback): void + { + try { + $callback(); + } catch (\Throwable $e) { + Log::warning('TodayIssue StockObserver failed', [ + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 755c030..bfc74a7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,12 +2,24 @@ namespace App\Providers; +use App\Models\BadDebts\BadDebt; use App\Models\Boards\Post; use App\Models\Commons\Menu; use App\Models\Members\User; +use App\Models\Orders\Client; +use App\Models\Orders\Order; +use App\Models\Tenants\ApprovalStep; +use App\Models\Tenants\ExpectedExpense; +use App\Models\Tenants\Stock; use App\Models\Tenants\Tenant; use App\Observers\MenuObserver; use App\Observers\TenantObserver; +use App\Observers\TodayIssue\ApprovalStepIssueObserver; +use App\Observers\TodayIssue\BadDebtIssueObserver; +use App\Observers\TodayIssue\ClientIssueObserver; +use App\Observers\TodayIssue\ExpectedExpenseIssueObserver; +use App\Observers\TodayIssue\OrderIssueObserver; +use App\Observers\TodayIssue\StockIssueObserver; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\DB; @@ -57,5 +69,13 @@ public function boot(): void // 테넌트 생성 시 자동 실행 Tenant::observe(TenantObserver::class); + + // CEO 대시보드 오늘의 이슈 실시간 저장 + Order::observe(OrderIssueObserver::class); + BadDebt::observe(BadDebtIssueObserver::class); + Stock::observe(StockIssueObserver::class); + ExpectedExpense::observe(ExpectedExpenseIssueObserver::class); + ApprovalStep::observe(ApprovalStepIssueObserver::class); + Client::observe(ClientIssueObserver::class); } } diff --git a/app/Services/DailyReportService.php b/app/Services/DailyReportService.php index 1240ea6..fbabb97 100644 --- a/app/Services/DailyReportService.php +++ b/app/Services/DailyReportService.php @@ -147,14 +147,76 @@ public function summary(array $params): array ]; }, ['carryover' => 0, 'income' => 0, 'expense' => 0, 'balance' => 0]); + // 운영자금 안정성 계산 + $cashAssetTotal = (float) $krwTotal['balance']; + $monthlyOperatingExpense = $this->calculateMonthlyOperatingExpense($date); + $operatingMonths = $monthlyOperatingExpense > 0 + ? round($cashAssetTotal / $monthlyOperatingExpense, 1) + : null; + $operatingStability = $this->getOperatingStability($operatingMonths); + return [ 'date' => $date->format('Y-m-d'), 'day_of_week' => $date->locale('ko')->dayName, 'note_receivable_total' => (float) $noteReceivableTotal, 'foreign_currency_total' => (float) $usdTotal['balance'], - 'cash_asset_total' => (float) $krwTotal['balance'], + 'cash_asset_total' => $cashAssetTotal, 'krw_totals' => $krwTotal, 'usd_totals' => $usdTotal, + // 운영자금 안정성 지표 + 'monthly_operating_expense' => $monthlyOperatingExpense, + 'operating_months' => $operatingMonths, + 'operating_stability' => $operatingStability, ]; } + + /** + * 직전 3개월 평균 월 운영비 계산 + * + * @param Carbon $baseDate 기준일 + * @return float 월 평균 운영비 + */ + private function calculateMonthlyOperatingExpense(Carbon $baseDate): float + { + $tenantId = $this->tenantId(); + + // 직전 3개월 범위: 기준일 전월 말일부터 3개월 전 1일까지 + $lastMonthEnd = $baseDate->copy()->subMonth()->endOfMonth(); + $threeMonthsAgo = $baseDate->copy()->subMonths(3)->startOfMonth(); + + $totalExpense = Withdrawal::where('tenant_id', $tenantId) + ->whereBetween('withdrawal_date', [$threeMonthsAgo, $lastMonthEnd]) + ->sum('amount'); + + // 3개월 평균 + return round((float) $totalExpense / 3, 0); + } + + /** + * 운영자금 안정성 판정 + * + * 색상 가이드 기준: + * - warning (빨강): 3개월 미만 - 자금 부족 우려 + * - caution (주황): 3~6개월 - 자금 관리 필요 + * - stable (파랑): 6개월 이상 - 안정적 + * + * @param float|null $months 운영 가능 개월 수 + * @return string 안정성 상태 (stable|caution|warning|unknown) + */ + private function getOperatingStability(?float $months): string + { + if ($months === null) { + return 'unknown'; + } + + if ($months >= 6) { + return 'stable'; // 파랑 - 안정적 + } + + if ($months >= 3) { + return 'caution'; // 주황 - 자금 관리 필요 + } + + return 'warning'; // 빨강 - 자금 부족 우려 + } } diff --git a/app/Services/TodayIssueObserverService.php b/app/Services/TodayIssueObserverService.php new file mode 100644 index 0000000..f71a39b --- /dev/null +++ b/app/Services/TodayIssueObserverService.php @@ -0,0 +1,470 @@ + $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; + } +} diff --git a/app/Services/TodayIssueService.php b/app/Services/TodayIssueService.php index 8e4ce4a..7b61a69 100644 --- a/app/Services/TodayIssueService.php +++ b/app/Services/TodayIssueService.php @@ -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'); } -} \ No newline at end of file +} diff --git a/app/Swagger/v1/ComprehensiveAnalysisApi.php b/app/Swagger/v1/ComprehensiveAnalysisApi.php index ba8cc1f..d886e8c 100644 --- a/app/Swagger/v1/ComprehensiveAnalysisApi.php +++ b/app/Swagger/v1/ComprehensiveAnalysisApi.php @@ -34,8 +34,8 @@ * ) * * @OA\Schema( - * schema="TodayIssueItem", - * description="오늘의 이슈 아이템", + * schema="ComprehensiveTodayIssueItem", + * description="종합분석 오늘의 이슈 아이템", * * @OA\Property(property="id", type="string", description="ID", example="issue-1"), * @OA\Property(property="category", type="string", description="카테고리", example="결재요청"), @@ -61,7 +61,7 @@ * type="array", * description="이슈 목록", * - * @OA\Items(ref="#/components/schemas/TodayIssueItem") + * @OA\Items(ref="#/components/schemas/ComprehensiveTodayIssueItem") * ) * ) * diff --git a/app/Swagger/v1/DailyReportApi.php b/app/Swagger/v1/DailyReportApi.php index 4366ad6..ba66a28 100644 --- a/app/Swagger/v1/DailyReportApi.php +++ b/app/Swagger/v1/DailyReportApi.php @@ -61,7 +61,10 @@ * @OA\Property(property="income", type="number", format="float"), * @OA\Property(property="expense", type="number", format="float"), * @OA\Property(property="balance", type="number", format="float") - * ) + * ), + * @OA\Property(property="monthly_operating_expense", type="number", format="float", description="월 운영비 (직전 3개월 평균)", example=500000000), + * @OA\Property(property="operating_months", type="number", format="float", nullable=true, description="운영 가능 개월 수 (현금자산/월운영비)", example=6.5), + * @OA\Property(property="operating_stability", type="string", enum={"stable", "caution", "warning", "unknown"}, description="운영자금 안정성 (stable: 6개월↑, caution: 3~6개월, warning: 3개월↓)", example="stable") * ) */ class DailyReportApi diff --git a/app/Swagger/v1/TodayIssueApi.php b/app/Swagger/v1/TodayIssueApi.php index 3bc352c..5237a9f 100644 --- a/app/Swagger/v1/TodayIssueApi.php +++ b/app/Swagger/v1/TodayIssueApi.php @@ -40,6 +40,60 @@ * ), * @OA\Property(property="total_count", type="integer", example=25, description="전체 이슈 건수") * ) + * + * @OA\Schema( + * schema="TodayIssueUnreadItem", + * type="object", + * description="읽지 않은 이슈 항목 (헤더 알림용)", + * required={"id", "badge", "content", "time", "created_at"}, + * @OA\Property(property="id", type="integer", example=123, description="이슈 고유 ID"), + * @OA\Property( + * property="badge", + * type="string", + * enum={"수주등록", "추심이슈", "안전재고", "지출 승인대기", "세금 신고", "결재 요청", "신규거래처"}, + * example="수주등록", + * description="이슈 카테고리 뱃지" + * ), + * @OA\Property( + * property="notification_type", + * type="string", + * enum={"sales_order", "new_vendor", "approval_request", "bad_debt", "safety_stock", "expected_expense", "vat_report"}, + * example="sales_order", + * description="알림 설정 타입" + * ), + * @OA\Property(property="content", type="string", example="대한건설 신규 수주 1억 2천만원 등록", description="이슈 내용"), + * @OA\Property(property="path", type="string", example="/sales/order-management-sales", description="클릭 시 이동할 경로", nullable=true), + * @OA\Property(property="needs_approval", type="boolean", example=false, description="승인/반려 버튼 표시 여부"), + * @OA\Property(property="time", type="string", example="10분 전", description="상대 시간"), + * @OA\Property(property="created_at", type="string", format="date-time", example="2026-01-21T10:30:00+09:00", description="생성 일시 (ISO 8601)") + * ) + * + * @OA\Schema( + * schema="TodayIssueUnreadResponse", + * type="object", + * description="읽지 않은 이슈 목록 응답", + * @OA\Property( + * property="items", + * type="array", + * description="읽지 않은 이슈 항목 리스트", + * @OA\Items(ref="#/components/schemas/TodayIssueUnreadItem") + * ), + * @OA\Property(property="total", type="integer", example=5, description="읽지 않은 전체 이슈 건수") + * ) + * + * @OA\Schema( + * schema="TodayIssueUnreadCountResponse", + * type="object", + * description="읽지 않은 이슈 개수 응답", + * @OA\Property(property="count", type="integer", example=5, description="읽지 않은 이슈 건수") + * ) + * + * @OA\Schema( + * schema="TodayIssueMarkAllReadResponse", + * type="object", + * description="모든 이슈 읽음 처리 응답", + * @OA\Property(property="count", type="integer", example=5, description="읽음 처리된 이슈 건수") + * ) */ class TodayIssueApi { @@ -86,4 +140,170 @@ class TodayIssueApi * ) */ public function summary() {} + + /** + * @OA\Get( + * path="/api/v1/today-issues/unread", + * operationId="getUnreadTodayIssues", + * tags={"TodayIssue"}, + * summary="읽지 않은 이슈 목록 조회 (헤더 알림용)", + * description="헤더 알림 드롭다운에 표시할 읽지 않은 이슈 목록을 조회합니다.", + * security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}}, + * + * @OA\Parameter( + * name="limit", + * in="query", + * description="조회할 최대 항목 수 (기본 10)", + * required=false, + * @OA\Schema(type="integer", default=10, minimum=1, maximum=50) + * ), + * + * @OA\Response( + * response=200, + * description="성공", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="데이터를 조회했습니다."), + * @OA\Property( + * property="data", + * ref="#/components/schemas/TodayIssueUnreadResponse" + * ) + * ) + * ), + * + * @OA\Response( + * response=401, + * description="인증 실패", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="success", type="boolean", example=false), + * @OA\Property(property="message", type="string", example="인증에 실패했습니다.") + * ) + * ) + * ) + */ + public function unread() {} + + /** + * @OA\Get( + * path="/api/v1/today-issues/unread/count", + * operationId="getUnreadTodayIssueCount", + * tags={"TodayIssue"}, + * summary="읽지 않은 이슈 개수 조회 (헤더 뱃지용)", + * description="헤더 알림 아이콘 뱃지에 표시할 읽지 않은 이슈 개수를 조회합니다.", + * security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}}, + * + * @OA\Response( + * response=200, + * description="성공", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="데이터를 조회했습니다."), + * @OA\Property( + * property="data", + * ref="#/components/schemas/TodayIssueUnreadCountResponse" + * ) + * ) + * ), + * + * @OA\Response( + * response=401, + * description="인증 실패", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="success", type="boolean", example=false), + * @OA\Property(property="message", type="string", example="인증에 실패했습니다.") + * ) + * ) + * ) + */ + public function unreadCount() {} + + /** + * @OA\Post( + * path="/api/v1/today-issues/{id}/read", + * operationId="markTodayIssueAsRead", + * tags={"TodayIssue"}, + * summary="이슈 읽음 처리", + * description="특정 이슈를 읽음 처리합니다. 헤더 알림에서 항목 클릭 시 호출됩니다.", + * security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}}, + * + * @OA\Parameter( + * name="id", + * in="path", + * description="이슈 ID", + * required=true, + * @OA\Schema(type="integer", example=123) + * ), + * + * @OA\Response( + * response=200, + * description="성공", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="알림을 읽음 처리했습니다.") + * ) + * ), + * + * @OA\Response( + * response=404, + * description="이슈를 찾을 수 없음", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="success", type="boolean", example=false), + * @OA\Property(property="message", type="string", example="데이터를 찾을 수 없습니다.") + * ) + * ), + * + * @OA\Response( + * response=401, + * description="인증 실패", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="success", type="boolean", example=false), + * @OA\Property(property="message", type="string", example="인증에 실패했습니다.") + * ) + * ) + * ) + */ + public function markAsRead() {} + + /** + * @OA\Post( + * path="/api/v1/today-issues/read-all", + * operationId="markAllTodayIssuesAsRead", + * tags={"TodayIssue"}, + * summary="모든 이슈 읽음 처리", + * description="읽지 않은 모든 이슈를 읽음 처리합니다. 헤더 알림에서 '모두 읽음' 버튼 클릭 시 호출됩니다.", + * security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}}, + * + * @OA\Response( + * response=200, + * description="성공", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="모든 알림을 읽음 처리했습니다."), + * @OA\Property( + * property="data", + * ref="#/components/schemas/TodayIssueMarkAllReadResponse" + * ) + * ) + * ), + * + * @OA\Response( + * response=401, + * description="인증 실패", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="success", type="boolean", example=false), + * @OA\Property(property="message", type="string", example="인증에 실패했습니다.") + * ) + * ) + * ) + */ + public function markAllAsRead() {} } \ No newline at end of file diff --git a/database/migrations/2026_01_21_161328_create_today_issues_table.php b/database/migrations/2026_01_21_161328_create_today_issues_table.php new file mode 100644 index 0000000..23b7102 --- /dev/null +++ b/database/migrations/2026_01_21_161328_create_today_issues_table.php @@ -0,0 +1,55 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + + // 소스 정보 + $table->string('source_type', 50)->comment('order, bad_debt, stock, expense, tax, approval, client'); + $table->unsignedBigInteger('source_id')->nullable()->comment('원본 레코드 ID (tax는 null)'); + + // 표시 정보 + $table->string('badge', 50)->comment('수주 성공, 주식 이슈, 직정 제고, 지출예상내역서, 세금 신고, 결재 요청, 기타'); + $table->string('content', 500)->comment('표시 내용'); + $table->string('path', 255)->nullable()->comment('이동 경로'); + $table->boolean('needs_approval')->default(false)->comment('승인 필요 여부'); + + // 읽음 상태 + $table->boolean('is_read')->default(false)->comment('확인 여부'); + $table->foreignId('read_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('read_at')->nullable(); + + // 만료 시간 (오늘의 이슈이므로 당일 자정에 만료) + $table->timestamp('expires_at')->nullable()->comment('만료 시간'); + + $table->timestamps(); + + // 인덱스 + $table->index(['tenant_id', 'is_read', 'created_at']); + $table->index(['tenant_id', 'badge']); + $table->index(['expires_at']); + + // 중복 방지 (같은 소스에서 같은 이슈 중복 생성 방지) + $table->unique(['tenant_id', 'source_type', 'source_id'], 'today_issues_unique_source'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('today_issues'); + } +}; diff --git a/database/migrations/2026_01_21_201937_add_notification_type_to_today_issues_table.php b/database/migrations/2026_01_21_201937_add_notification_type_to_today_issues_table.php new file mode 100644 index 0000000..9659d04 --- /dev/null +++ b/database/migrations/2026_01_21_201937_add_notification_type_to_today_issues_table.php @@ -0,0 +1,30 @@ +string('notification_type', 50)->nullable()->after('badge')->comment('알림 유형 (sales_order, new_vendor, approval_request, bad_debt, safety_stock, expected_expense, vat_report)'); + $table->index('notification_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('today_issues', function (Blueprint $table) { + $table->dropIndex(['notification_type']); + $table->dropColumn('notification_type'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 86bc2d0..f2c3eff 100644 --- a/routes/api.php +++ b/routes/api.php @@ -628,6 +628,10 @@ // Today Issue API (CEO 대시보드 오늘의 이슈 리스트) Route::get('/today-issues/summary', [TodayIssueController::class, 'summary'])->name('v1.today-issues.summary'); + Route::get('/today-issues/unread', [TodayIssueController::class, 'unread'])->name('v1.today-issues.unread'); + Route::get('/today-issues/unread/count', [TodayIssueController::class, 'unreadCount'])->name('v1.today-issues.unread.count'); + Route::post('/today-issues/{id}/read', [TodayIssueController::class, 'markAsRead'])->whereNumber('id')->name('v1.today-issues.read'); + Route::post('/today-issues/read-all', [TodayIssueController::class, 'markAllAsRead'])->name('v1.today-issues.read-all'); // Calendar API (CEO 대시보드 캘린더) Route::get('/calendar/schedules', [CalendarController::class, 'summary'])->name('v1.calendar.schedules');