오늘 이슈(TodayIssue) 기능 구현

- TodayIssue 모델 및 마이그레이션 추가
- TodayIssueController, TodayIssueService 구현
- TodayIssueObserverService 및 Observer 패턴 적용
- DailyReportService 연동
- Swagger API 문서 업데이트
- 라우트 추가
This commit is contained in:
2026-01-22 09:47:29 +09:00
parent 289fd3744c
commit d186a0c111
21 changed files with 1604 additions and 322 deletions

View File

@@ -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 구현
### 작업 목표

View File

@@ -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`

View File

@@ -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'));
}
}
/**
* 읽지 않은 이슈 목록 조회 (헤더 알림용)
*/
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'));
}
}

View File

@@ -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',
];
/**

View File

@@ -0,0 +1,208 @@
<?php
namespace App\Models\Tenants;
use App\Models\Users\User;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 오늘의 이슈 모델
*
* CEO 대시보드용 실시간 이슈 저장
*/
class TodayIssue extends Model
{
use BelongsToTenant, HasFactory, ModelTrait;
protected $table = 'today_issues';
protected $fillable = [
'tenant_id',
'source_type',
'source_id',
'badge',
'notification_type',
'content',
'path',
'needs_approval',
'is_read',
'read_by',
'read_at',
'expires_at',
];
protected $casts = [
'needs_approval' => '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;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Observers\TodayIssue;
use App\Models\Tenants\ApprovalStep;
use App\Services\TodayIssueObserverService;
use Illuminate\Support\Facades\Log;
/**
* ApprovalStep 모델의 TodayIssue Observer
*/
class ApprovalStepIssueObserver
{
public function __construct(
protected TodayIssueObserverService $service
) {}
public function created(ApprovalStep $step): void
{
$this->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(),
]);
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Observers\TodayIssue;
use App\Models\BadDebts\BadDebt;
use App\Services\TodayIssueObserverService;
use Illuminate\Support\Facades\Log;
/**
* BadDebt 모델의 TodayIssue Observer
*/
class BadDebtIssueObserver
{
public function __construct(
protected TodayIssueObserverService $service
) {}
public function created(BadDebt $badDebt): void
{
$this->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(),
]);
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Observers\TodayIssue;
use App\Models\Orders\Client;
use App\Services\TodayIssueObserverService;
use Illuminate\Support\Facades\Log;
/**
* Client 모델의 TodayIssue Observer
*/
class ClientIssueObserver
{
public function __construct(
protected TodayIssueObserverService $service
) {}
public function created(Client $client): void
{
$this->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(),
]);
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Observers\TodayIssue;
use App\Models\Tenants\ExpectedExpense;
use App\Services\TodayIssueObserverService;
use Illuminate\Support\Facades\Log;
/**
* ExpectedExpense 모델의 TodayIssue Observer
*/
class ExpectedExpenseIssueObserver
{
public function __construct(
protected TodayIssueObserverService $service
) {}
public function created(ExpectedExpense $expense): void
{
$this->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(),
]);
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Observers\TodayIssue;
use App\Models\Orders\Order;
use App\Services\TodayIssueObserverService;
use Illuminate\Support\Facades\Log;
/**
* Order 모델의 TodayIssue Observer
*/
class OrderIssueObserver
{
public function __construct(
protected TodayIssueObserverService $service
) {}
public function created(Order $order): void
{
$this->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(),
]);
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Observers\TodayIssue;
use App\Models\Tenants\Stock;
use App\Services\TodayIssueObserverService;
use Illuminate\Support\Facades\Log;
/**
* Stock 모델의 TodayIssue Observer
*/
class StockIssueObserver
{
public function __construct(
protected TodayIssueObserverService $service
) {}
public function created(Stock $stock): void
{
$this->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(),
]);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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'; // 빨강 - 자금 부족 우려
}
}

View 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;
}
}

View File

@@ -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');
}
}
}

View File

@@ -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")
* )
* )
*

View File

@@ -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

View File

@@ -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() {}
}

View File

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

View File

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

View File

@@ -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');