오늘 이슈(TodayIssue) 기능 구현
- TodayIssue 모델 및 마이그레이션 추가 - TodayIssueController, TodayIssueService 구현 - TodayIssueObserverService 및 Observer 패턴 적용 - DailyReportService 연동 - Swagger API 문서 업데이트 - 라우트 추가
This commit is contained in:
@@ -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 구현
|
||||
|
||||
### 작업 목표
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
208
app/Models/Tenants/TodayIssue.php
Normal file
208
app/Models/Tenants/TodayIssue.php
Normal 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;
|
||||
}
|
||||
}
|
||||
43
app/Observers/TodayIssue/ApprovalStepIssueObserver.php
Normal file
43
app/Observers/TodayIssue/ApprovalStepIssueObserver.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/Observers/TodayIssue/BadDebtIssueObserver.php
Normal file
43
app/Observers/TodayIssue/BadDebtIssueObserver.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
38
app/Observers/TodayIssue/ClientIssueObserver.php
Normal file
38
app/Observers/TodayIssue/ClientIssueObserver.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/Observers/TodayIssue/ExpectedExpenseIssueObserver.php
Normal file
43
app/Observers/TodayIssue/ExpectedExpenseIssueObserver.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/Observers/TodayIssue/OrderIssueObserver.php
Normal file
43
app/Observers/TodayIssue/OrderIssueObserver.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/Observers/TodayIssue/StockIssueObserver.php
Normal file
43
app/Observers/TodayIssue/StockIssueObserver.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'; // 빨강 - 자금 부족 우려
|
||||
}
|
||||
}
|
||||
|
||||
470
app/Services/TodayIssueObserverService.php
Normal file
470
app/Services/TodayIssueObserverService.php
Normal file
@@ -0,0 +1,470 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\BadDebts\BadDebt;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Models\Orders\Client;
|
||||
use App\Models\Orders\Order;
|
||||
use App\Models\PushDeviceToken;
|
||||
use App\Models\Tenants\ApprovalStep;
|
||||
use App\Models\Tenants\ExpectedExpense;
|
||||
use App\Models\Tenants\Stock;
|
||||
use App\Models\Tenants\TodayIssue;
|
||||
use App\Services\Fcm\FcmSender;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 오늘의 이슈 Observer 서비스
|
||||
*
|
||||
* 모델 이벤트 발생 시 TodayIssue 테이블에 실시간 저장/삭제
|
||||
* FCM 푸시 알림 연동
|
||||
*/
|
||||
class TodayIssueObserverService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FcmSender $fcmSender
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 수주 성공 이슈 생성/삭제
|
||||
*/
|
||||
public function handleOrderChange(Order $order): void
|
||||
{
|
||||
Log::info('TodayIssue: Order change detected', [
|
||||
'order_id' => $order->id,
|
||||
'status_code' => $order->status_code,
|
||||
'tenant_id' => $order->tenant_id,
|
||||
]);
|
||||
|
||||
// 확정 상태이고 최근 7일 이내인 경우만 이슈 생성
|
||||
$isConfirmed = $order->status_code === Order::STATUS_CONFIRMED;
|
||||
$isRecent = $order->created_at?->gte(Carbon::now()->subDays(7));
|
||||
|
||||
if ($isConfirmed && $isRecent) {
|
||||
$clientName = $order->client?->name ?? __('message.today_issue.unknown_client');
|
||||
$amount = number_format($order->total_amount ?? 0);
|
||||
|
||||
Log::info('TodayIssue: Creating order success issue', [
|
||||
'order_id' => $order->id,
|
||||
'client' => $clientName,
|
||||
'amount' => $amount,
|
||||
]);
|
||||
|
||||
$this->createIssueWithFcm(
|
||||
tenantId: $order->tenant_id,
|
||||
sourceType: TodayIssue::SOURCE_ORDER,
|
||||
sourceId: $order->id,
|
||||
badge: TodayIssue::BADGE_ORDER_REGISTER,
|
||||
content: __('message.today_issue.order_register', [
|
||||
'client' => $clientName,
|
||||
'amount' => $amount,
|
||||
]),
|
||||
path: '/sales/order-management-sales',
|
||||
needsApproval: false,
|
||||
expiresAt: Carbon::now()->addDays(7)
|
||||
);
|
||||
} else {
|
||||
Log::info('TodayIssue: Order does not meet criteria, removing issue if exists', [
|
||||
'order_id' => $order->id,
|
||||
'is_confirmed' => $isConfirmed,
|
||||
'is_recent' => $isRecent,
|
||||
]);
|
||||
// 조건에 맞지 않으면 이슈 삭제
|
||||
TodayIssue::removeBySource($order->tenant_id, TodayIssue::SOURCE_ORDER, $order->id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 삭제 시 이슈 삭제
|
||||
*/
|
||||
public function handleOrderDeleted(Order $order): void
|
||||
{
|
||||
TodayIssue::removeBySource($order->tenant_id, TodayIssue::SOURCE_ORDER, $order->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미수금(주식 이슈) 생성/삭제
|
||||
*/
|
||||
public function handleBadDebtChange(BadDebt $badDebt): void
|
||||
{
|
||||
// 추심 진행 중인 건만 이슈 생성
|
||||
if (in_array($badDebt->status, ['in_progress', 'legal_action'])) {
|
||||
$clientName = $badDebt->client?->name ?? __('message.today_issue.unknown_client');
|
||||
$amount = number_format($badDebt->total_amount ?? 0);
|
||||
$days = $badDebt->overdue_days ?? 0;
|
||||
|
||||
$this->createIssueWithFcm(
|
||||
tenantId: $badDebt->tenant_id,
|
||||
sourceType: TodayIssue::SOURCE_BAD_DEBT,
|
||||
sourceId: $badDebt->id,
|
||||
badge: TodayIssue::BADGE_COLLECTION_ISSUE,
|
||||
content: __('message.today_issue.collection_issue', [
|
||||
'client' => $clientName,
|
||||
'amount' => $amount,
|
||||
'days' => $days,
|
||||
]),
|
||||
path: '/accounting/receivables-status',
|
||||
needsApproval: false,
|
||||
expiresAt: null // 해결될 때까지 유지
|
||||
);
|
||||
} else {
|
||||
TodayIssue::removeBySource($badDebt->tenant_id, TodayIssue::SOURCE_BAD_DEBT, $badDebt->id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미수금 삭제 시 이슈 삭제
|
||||
*/
|
||||
public function handleBadDebtDeleted(BadDebt $badDebt): void
|
||||
{
|
||||
TodayIssue::removeBySource($badDebt->tenant_id, TodayIssue::SOURCE_BAD_DEBT, $badDebt->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고 이슈(직정 제고) 생성/삭제
|
||||
*/
|
||||
public function handleStockChange(Stock $stock): void
|
||||
{
|
||||
// 안전재고 미달인 경우만 이슈 생성
|
||||
if ($stock->safety_stock > 0 && $stock->stock_qty < $stock->safety_stock) {
|
||||
$itemName = $stock->item?->name ?? $stock->item?->code ?? __('message.today_issue.unknown_item');
|
||||
|
||||
$this->createIssueWithFcm(
|
||||
tenantId: $stock->tenant_id,
|
||||
sourceType: TodayIssue::SOURCE_STOCK,
|
||||
sourceId: $stock->id,
|
||||
badge: TodayIssue::BADGE_SAFETY_STOCK,
|
||||
content: __('message.today_issue.safety_stock_alert', [
|
||||
'item' => $itemName,
|
||||
]),
|
||||
path: '/material/stock-status',
|
||||
needsApproval: false,
|
||||
expiresAt: null // 재고 보충 시까지 유지
|
||||
);
|
||||
} else {
|
||||
TodayIssue::removeBySource($stock->tenant_id, TodayIssue::SOURCE_STOCK, $stock->id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고 삭제 시 이슈 삭제
|
||||
*/
|
||||
public function handleStockDeleted(Stock $stock): void
|
||||
{
|
||||
TodayIssue::removeBySource($stock->tenant_id, TodayIssue::SOURCE_STOCK, $stock->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지출예상내역서 이슈 생성/삭제
|
||||
*/
|
||||
public function handleExpectedExpenseChange(ExpectedExpense $expense): void
|
||||
{
|
||||
// 승인 대기 상태인 경우만 이슈 생성
|
||||
if ($expense->payment_status === 'pending') {
|
||||
$title = $expense->description ?? __('message.today_issue.expense_item');
|
||||
$amount = number_format($expense->amount ?? 0);
|
||||
|
||||
$this->createIssueWithFcm(
|
||||
tenantId: $expense->tenant_id,
|
||||
sourceType: TodayIssue::SOURCE_EXPENSE,
|
||||
sourceId: $expense->id,
|
||||
badge: TodayIssue::BADGE_EXPENSE_PENDING,
|
||||
content: __('message.today_issue.expense_pending', [
|
||||
'title' => $title,
|
||||
'amount' => $amount,
|
||||
]),
|
||||
path: '/approval/inbox',
|
||||
needsApproval: true,
|
||||
expiresAt: null // 처리 완료 시까지 유지
|
||||
);
|
||||
} else {
|
||||
TodayIssue::removeBySource($expense->tenant_id, TodayIssue::SOURCE_EXPENSE, $expense->id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지출예상내역서 삭제 시 이슈 삭제
|
||||
*/
|
||||
public function handleExpectedExpenseDeleted(ExpectedExpense $expense): void
|
||||
{
|
||||
TodayIssue::removeBySource($expense->tenant_id, TodayIssue::SOURCE_EXPENSE, $expense->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 요청 이슈 생성/삭제
|
||||
*/
|
||||
public function handleApprovalStepChange(ApprovalStep $step): void
|
||||
{
|
||||
// 승인 대기 상태이고, 결재 문서도 pending 상태인 경우만 이슈 생성
|
||||
$approval = $step->approval;
|
||||
|
||||
if ($step->status === 'pending' && $approval && $approval->status === 'pending') {
|
||||
$drafterName = $approval->drafter?->name ?? __('message.today_issue.unknown_user');
|
||||
$title = $approval->title ?? __('message.today_issue.approval_request');
|
||||
|
||||
$this->createIssueWithFcm(
|
||||
tenantId: $approval->tenant_id,
|
||||
sourceType: TodayIssue::SOURCE_APPROVAL,
|
||||
sourceId: $step->id,
|
||||
badge: TodayIssue::BADGE_APPROVAL_REQUEST,
|
||||
content: __('message.today_issue.approval_pending', [
|
||||
'title' => $title,
|
||||
'drafter' => $drafterName,
|
||||
]),
|
||||
path: '/approval/inbox',
|
||||
needsApproval: true,
|
||||
expiresAt: null // 결재 완료 시까지 유지
|
||||
);
|
||||
} else {
|
||||
if ($approval) {
|
||||
TodayIssue::removeBySource($approval->tenant_id, TodayIssue::SOURCE_APPROVAL, $step->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 단계 삭제 시 이슈 삭제
|
||||
*/
|
||||
public function handleApprovalStepDeleted(ApprovalStep $step): void
|
||||
{
|
||||
if ($step->approval) {
|
||||
TodayIssue::removeBySource($step->approval->tenant_id, TodayIssue::SOURCE_APPROVAL, $step->id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 신규 거래처 이슈 생성
|
||||
*/
|
||||
public function handleClientCreated(Client $client): void
|
||||
{
|
||||
$this->createIssueWithFcm(
|
||||
tenantId: $client->tenant_id,
|
||||
sourceType: TodayIssue::SOURCE_CLIENT,
|
||||
sourceId: $client->id,
|
||||
badge: TodayIssue::BADGE_NEW_CLIENT,
|
||||
content: __('message.today_issue.new_client', [
|
||||
'name' => $client->name,
|
||||
]),
|
||||
path: '/accounting/vendors',
|
||||
needsApproval: false,
|
||||
expiresAt: Carbon::now()->addDays(7)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처 삭제 시 이슈 삭제
|
||||
*/
|
||||
public function handleClientDeleted(Client $client): void
|
||||
{
|
||||
TodayIssue::removeBySource($client->tenant_id, TodayIssue::SOURCE_CLIENT, $client->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금 신고 이슈 업데이트 (스케줄러에서 호출)
|
||||
* 이 메서드는 일일 스케줄러에서 호출하여 세금 신고 이슈를 업데이트합니다.
|
||||
*/
|
||||
public function updateTaxIssues(int $tenantId): void
|
||||
{
|
||||
$today = Carbon::today();
|
||||
|
||||
// 부가세 신고 마감일 계산 (분기별: 1/25, 4/25, 7/25, 10/25)
|
||||
$quarter = $today->quarter;
|
||||
$deadlineMonth = match ($quarter) {
|
||||
1 => 1,
|
||||
2 => 4,
|
||||
3 => 7,
|
||||
4 => 10,
|
||||
};
|
||||
|
||||
$deadlineYear = $today->year;
|
||||
if ($today->month > $deadlineMonth || ($today->month == $deadlineMonth && $today->day > 25)) {
|
||||
$deadlineMonth = match ($quarter) {
|
||||
1 => 4,
|
||||
2 => 7,
|
||||
3 => 10,
|
||||
4 => 1,
|
||||
};
|
||||
if ($deadlineMonth == 1) {
|
||||
$deadlineYear++;
|
||||
}
|
||||
}
|
||||
|
||||
$deadline = Carbon::create($deadlineYear, $deadlineMonth, 25);
|
||||
$daysUntil = $today->diffInDays($deadline, false);
|
||||
|
||||
// 기존 세금 신고 이슈 삭제
|
||||
TodayIssue::where('tenant_id', $tenantId)
|
||||
->where('source_type', TodayIssue::SOURCE_TAX)
|
||||
->delete();
|
||||
|
||||
// D-30 이내인 경우에만 표시
|
||||
if ($daysUntil <= 30 && $daysUntil >= 0) {
|
||||
$quarterName = match ($deadlineMonth) {
|
||||
1 => '4',
|
||||
4 => '1',
|
||||
7 => '2',
|
||||
10 => '3',
|
||||
};
|
||||
|
||||
$this->createIssueWithFcm(
|
||||
tenantId: $tenantId,
|
||||
sourceType: TodayIssue::SOURCE_TAX,
|
||||
sourceId: null, // 세금 신고는 특정 소스 ID가 없음
|
||||
badge: TodayIssue::BADGE_TAX_REPORT,
|
||||
content: __('message.today_issue.tax_vat_deadline', [
|
||||
'quarter' => $quarterName,
|
||||
'days' => $daysUntil,
|
||||
]),
|
||||
path: '/accounting/tax',
|
||||
needsApproval: false,
|
||||
expiresAt: $deadline
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FCM 푸시 알림 발송 헬퍼
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* TodayIssue 생성 후 FCM 푸시 알림 발송
|
||||
*
|
||||
* 알림 설정이 활성화된 사용자에게만 발송
|
||||
* 중요 알림(수주등록, 신규거래처, 결재요청)은 알림음 다르게 발송
|
||||
*/
|
||||
public function sendFcmNotification(TodayIssue $issue): void
|
||||
{
|
||||
if (! $issue->notification_type) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 해당 테넌트의 활성 토큰 조회 (알림 설정 활성화된 사용자만)
|
||||
$tokens = $this->getEnabledUserTokens($issue->tenant_id, $issue->notification_type);
|
||||
|
||||
if (empty($tokens)) {
|
||||
Log::info('[TodayIssue] No enabled tokens found for FCM', [
|
||||
'tenant_id' => $issue->tenant_id,
|
||||
'notification_type' => $issue->notification_type,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 중요 알림 여부에 따른 채널 결정
|
||||
$channelId = $issue->isImportantNotification() ? 'push_urgent' : 'push_default';
|
||||
|
||||
$result = $this->fcmSender->sendToMany(
|
||||
$tokens,
|
||||
$issue->badge,
|
||||
$issue->content,
|
||||
$channelId,
|
||||
[
|
||||
'type' => 'today_issue',
|
||||
'issue_id' => $issue->id,
|
||||
'notification_type' => $issue->notification_type,
|
||||
'path' => $issue->path,
|
||||
]
|
||||
);
|
||||
|
||||
Log::info('[TodayIssue] FCM notification sent', [
|
||||
'issue_id' => $issue->id,
|
||||
'notification_type' => $issue->notification_type,
|
||||
'channel_id' => $channelId,
|
||||
'token_count' => count($tokens),
|
||||
'success_count' => $result->getSuccessCount(),
|
||||
'failure_count' => $result->getFailureCount(),
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('[TodayIssue] FCM notification failed', [
|
||||
'issue_id' => $issue->id,
|
||||
'notification_type' => $issue->notification_type,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 설정이 활성화된 사용자들의 FCM 토큰 조회
|
||||
*/
|
||||
private function getEnabledUserTokens(int $tenantId, string $notificationType): array
|
||||
{
|
||||
// 해당 테넌트의 활성 토큰 조회
|
||||
$tokens = PushDeviceToken::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
|
||||
if ($tokens->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 각 사용자의 알림 설정 확인
|
||||
$enabledTokens = [];
|
||||
foreach ($tokens as $token) {
|
||||
if ($this->isNotificationEnabledForUser($tenantId, $token->user_id, $notificationType)) {
|
||||
$enabledTokens[] = $token->token;
|
||||
}
|
||||
}
|
||||
|
||||
return $enabledTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 사용자의 특정 알림 타입 활성화 여부 확인
|
||||
*
|
||||
* 설정이 없으면 기본값 true (알림 활성화)
|
||||
*/
|
||||
private function isNotificationEnabledForUser(int $tenantId, int $userId, string $notificationType): bool
|
||||
{
|
||||
$setting = NotificationSetting::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('user_id', $userId)
|
||||
->where('notification_type', $notificationType)
|
||||
->first();
|
||||
|
||||
// 설정이 없으면 기본값 true
|
||||
if (! $setting) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// push_enabled 또는 in_app_enabled 중 하나라도 활성화되어 있으면 true
|
||||
return $setting->push_enabled || $setting->in_app_enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* TodayIssue 생성 시 FCM 발송 포함 (래퍼 메서드)
|
||||
*
|
||||
* createIssue 호출 후 자동으로 FCM 발송
|
||||
*/
|
||||
public function createIssueWithFcm(
|
||||
int $tenantId,
|
||||
string $sourceType,
|
||||
?int $sourceId,
|
||||
string $badge,
|
||||
string $content,
|
||||
?string $path = null,
|
||||
bool $needsApproval = false,
|
||||
?\DateTime $expiresAt = null
|
||||
): TodayIssue {
|
||||
$issue = TodayIssue::createIssue(
|
||||
tenantId: $tenantId,
|
||||
sourceType: $sourceType,
|
||||
sourceId: $sourceId,
|
||||
badge: $badge,
|
||||
content: $content,
|
||||
path: $path,
|
||||
needsApproval: $needsApproval,
|
||||
expiresAt: $expiresAt
|
||||
);
|
||||
|
||||
// FCM 발송
|
||||
$this->sendFcmNotification($issue);
|
||||
|
||||
return $issue;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user