feat: 대시보드 API 및 FCM 푸시 알림 API 구현
Dashboard API: - DashboardController, DashboardService 추가 - /dashboard/summary, /charts, /approvals 엔드포인트 Push Notification API: - FCM 토큰 관리 (등록/해제/목록) - 알림 설정 관리 (유형별 on/off, 알림음 설정) - 알림 유형: deposit, withdrawal, order, approval, attendance, notice, system - 알림음: default, deposit, withdrawal, order, approval, urgent - PushDeviceToken, PushNotificationSetting 모델 - Swagger 문서 추가
This commit is contained in:
47
app/Http/Controllers/Api/V1/DashboardController.php
Normal file
47
app/Http/Controllers/Api/V1/DashboardController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\Dashboard\DashboardApprovalsRequest;
|
||||
use App\Http\Requests\V1\Dashboard\DashboardChartsRequest;
|
||||
use App\Http\Responses\ApiResponse;
|
||||
use App\Services\DashboardService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DashboardService $dashboardService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 대시보드 요약 데이터 조회
|
||||
*/
|
||||
public function summary(): JsonResponse
|
||||
{
|
||||
$data = $this->dashboardService->summary();
|
||||
|
||||
return ApiResponse::handle(['data' => $data], __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 차트 데이터 조회
|
||||
*/
|
||||
public function charts(DashboardChartsRequest $request): JsonResponse
|
||||
{
|
||||
$data = $this->dashboardService->charts($request->validated());
|
||||
|
||||
return ApiResponse::handle(['data' => $data], __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 현황 조회
|
||||
*/
|
||||
public function approvals(DashboardApprovalsRequest $request): JsonResponse
|
||||
{
|
||||
$data = $this->dashboardService->approvals($request->validated());
|
||||
|
||||
return ApiResponse::handle(['data' => $data], __('message.fetched'));
|
||||
}
|
||||
}
|
||||
91
app/Http/Controllers/Api/V1/PushNotificationController.php
Normal file
91
app/Http/Controllers/Api/V1/PushNotificationController.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Push\RegisterTokenRequest;
|
||||
use App\Http\Requests\Push\UpdateSettingsRequest;
|
||||
use App\Services\PushNotificationService;
|
||||
use App\Utils\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PushNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* FCM 토큰 등록
|
||||
*/
|
||||
public function registerToken(RegisterTokenRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$service = new PushNotificationService;
|
||||
|
||||
return $service->registerToken($request->validated());
|
||||
}, __('message.push.token_registered'));
|
||||
}
|
||||
|
||||
/**
|
||||
* FCM 토큰 해제
|
||||
*/
|
||||
public function unregisterToken(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$token = $request->input('token');
|
||||
if (! $token) {
|
||||
throw new \InvalidArgumentException(__('error.push.token_required'));
|
||||
}
|
||||
|
||||
$service = new PushNotificationService;
|
||||
|
||||
return ['unregistered' => $service->unregisterToken($token)];
|
||||
}, __('message.push.token_unregistered'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자의 등록된 디바이스 토큰 목록
|
||||
*/
|
||||
public function getTokens()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$service = new PushNotificationService;
|
||||
|
||||
return $service->getUserTokens();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 설정 조회
|
||||
*/
|
||||
public function getSettings()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$service = new PushNotificationService;
|
||||
|
||||
return $service->getSettings();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 설정 업데이트
|
||||
*/
|
||||
public function updateSettings(UpdateSettingsRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$service = new PushNotificationService;
|
||||
|
||||
return $service->updateSettings($request->validated()['settings']);
|
||||
}, __('message.push.settings_updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 유형 목록 조회
|
||||
*/
|
||||
public function getNotificationTypes()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return [
|
||||
'types' => \App\Models\PushNotificationSetting::getAllTypes(),
|
||||
'sounds' => \App\Models\PushNotificationSetting::getAllSounds(),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
43
app/Http/Requests/Push/RegisterTokenRequest.php
Normal file
43
app/Http/Requests/Push/RegisterTokenRequest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Push;
|
||||
|
||||
use App\Models\PushDeviceToken;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class RegisterTokenRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'token' => ['required', 'string', 'min:10'],
|
||||
'platform' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([
|
||||
PushDeviceToken::PLATFORM_IOS,
|
||||
PushDeviceToken::PLATFORM_ANDROID,
|
||||
PushDeviceToken::PLATFORM_WEB,
|
||||
]),
|
||||
],
|
||||
'device_name' => ['nullable', 'string', 'max:255'],
|
||||
'app_version' => ['nullable', 'string', 'max:50'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'token.required' => __('error.push.token_required'),
|
||||
'token.min' => __('error.push.token_invalid'),
|
||||
'platform.required' => __('error.push.platform_required'),
|
||||
'platform.in' => __('error.push.platform_invalid'),
|
||||
];
|
||||
}
|
||||
}
|
||||
45
app/Http/Requests/Push/UpdateSettingsRequest.php
Normal file
45
app/Http/Requests/Push/UpdateSettingsRequest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Push;
|
||||
|
||||
use App\Models\PushNotificationSetting;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateSettingsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'settings' => ['required', 'array'],
|
||||
'settings.*.notification_type' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in(PushNotificationSetting::getAllTypes()),
|
||||
],
|
||||
'settings.*.is_enabled' => ['required', 'boolean'],
|
||||
'settings.*.sound' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::in(PushNotificationSetting::getAllSounds()),
|
||||
],
|
||||
'settings.*.vibrate' => ['nullable', 'boolean'],
|
||||
'settings.*.show_preview' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'settings.required' => __('error.push.settings_required'),
|
||||
'settings.*.notification_type.required' => __('error.push.type_required'),
|
||||
'settings.*.notification_type.in' => __('error.push.type_invalid'),
|
||||
'settings.*.is_enabled.required' => __('error.push.enabled_required'),
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/V1/Dashboard/DashboardApprovalsRequest.php
Normal file
28
app/Http/Requests/V1/Dashboard/DashboardApprovalsRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\Dashboard;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class DashboardApprovalsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'limit' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'limit.min' => __('error.validation.min', ['min' => 1]),
|
||||
'limit.max' => __('error.validation.max', ['max' => 50]),
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Http/Requests/V1/Dashboard/DashboardChartsRequest.php
Normal file
27
app/Http/Requests/V1/Dashboard/DashboardChartsRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\Dashboard;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class DashboardChartsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'period' => ['nullable', 'string', 'in:week,month,quarter'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'period.in' => __('error.dashboard.invalid_period'),
|
||||
];
|
||||
}
|
||||
}
|
||||
79
app/Models/PushDeviceToken.php
Normal file
79
app/Models/PushDeviceToken.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class PushDeviceToken extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'token',
|
||||
'platform',
|
||||
'device_name',
|
||||
'app_version',
|
||||
'is_active',
|
||||
'last_used_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 플랫폼 상수
|
||||
*/
|
||||
public const PLATFORM_IOS = 'ios';
|
||||
|
||||
public const PLATFORM_ANDROID = 'android';
|
||||
|
||||
public const PLATFORM_WEB = 'web';
|
||||
|
||||
/**
|
||||
* 사용자 관계
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트 관계
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 활성 토큰만
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 플랫폼별 필터
|
||||
*/
|
||||
public function scopePlatform($query, string $platform)
|
||||
{
|
||||
return $query->where('platform', $platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 특정 사용자의 토큰
|
||||
*/
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
}
|
||||
131
app/Models/PushNotificationSetting.php
Normal file
131
app/Models/PushNotificationSetting.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PushNotificationSetting extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'notification_type',
|
||||
'is_enabled',
|
||||
'sound',
|
||||
'vibrate',
|
||||
'show_preview',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_enabled' => 'boolean',
|
||||
'vibrate' => 'boolean',
|
||||
'show_preview' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 알림 유형 상수
|
||||
*/
|
||||
public const TYPE_DEPOSIT = 'deposit'; // 입금
|
||||
|
||||
public const TYPE_WITHDRAWAL = 'withdrawal'; // 출금
|
||||
|
||||
public const TYPE_ORDER = 'order'; // 수주
|
||||
|
||||
public const TYPE_APPROVAL = 'approval'; // 결재
|
||||
|
||||
public const TYPE_ATTENDANCE = 'attendance'; // 근태
|
||||
|
||||
public const TYPE_NOTICE = 'notice'; // 공지사항
|
||||
|
||||
public const TYPE_SYSTEM = 'system'; // 시스템
|
||||
|
||||
/**
|
||||
* 알림음 상수
|
||||
*/
|
||||
public const SOUND_DEFAULT = 'default';
|
||||
|
||||
public const SOUND_DEPOSIT = 'deposit.wav'; // 입금 알림음
|
||||
|
||||
public const SOUND_WITHDRAWAL = 'withdrawal.wav'; // 출금 알림음
|
||||
|
||||
public const SOUND_ORDER = 'order.wav'; // 수주 알림음
|
||||
|
||||
public const SOUND_APPROVAL = 'approval.wav'; // 결재 알림음
|
||||
|
||||
public const SOUND_URGENT = 'urgent.wav'; // 긴급 알림음
|
||||
|
||||
/**
|
||||
* 모든 알림 유형 반환
|
||||
*/
|
||||
public static function getAllTypes(): array
|
||||
{
|
||||
return [
|
||||
self::TYPE_DEPOSIT,
|
||||
self::TYPE_WITHDRAWAL,
|
||||
self::TYPE_ORDER,
|
||||
self::TYPE_APPROVAL,
|
||||
self::TYPE_ATTENDANCE,
|
||||
self::TYPE_NOTICE,
|
||||
self::TYPE_SYSTEM,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 알림음 반환
|
||||
*/
|
||||
public static function getAllSounds(): array
|
||||
{
|
||||
return [
|
||||
self::SOUND_DEFAULT,
|
||||
self::SOUND_DEPOSIT,
|
||||
self::SOUND_WITHDRAWAL,
|
||||
self::SOUND_ORDER,
|
||||
self::SOUND_APPROVAL,
|
||||
self::SOUND_URGENT,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 관계
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트 관계
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 활성화된 알림만
|
||||
*/
|
||||
public function scopeEnabled($query)
|
||||
{
|
||||
return $query->where('is_enabled', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 특정 사용자의 설정
|
||||
*/
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 특정 알림 유형
|
||||
*/
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('notification_type', $type);
|
||||
}
|
||||
}
|
||||
354
app/Services/DashboardService.php
Normal file
354
app/Services/DashboardService.php
Normal file
@@ -0,0 +1,354 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Tenants\Approval;
|
||||
use App\Models\Tenants\ApprovalStep;
|
||||
use App\Models\Tenants\Attendance;
|
||||
use App\Models\Tenants\Deposit;
|
||||
use App\Models\Tenants\Leave;
|
||||
use App\Models\Tenants\Purchase;
|
||||
use App\Models\Tenants\Sale;
|
||||
use App\Models\Tenants\Withdrawal;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardService extends Service
|
||||
{
|
||||
/**
|
||||
* 대시보드 요약 데이터 조회
|
||||
*/
|
||||
public function summary(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
$today = Carbon::today();
|
||||
$startOfMonth = Carbon::now()->startOfMonth();
|
||||
$endOfMonth = Carbon::now()->endOfMonth();
|
||||
|
||||
return [
|
||||
'today' => $this->getTodaySummary($tenantId, $today),
|
||||
'finance' => $this->getFinanceSummary($tenantId, $startOfMonth, $endOfMonth),
|
||||
'sales' => $this->getSalesSummary($tenantId, $startOfMonth, $endOfMonth),
|
||||
'tasks' => $this->getTasksSummary($tenantId, $userId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 차트 데이터 조회
|
||||
*
|
||||
* @param array $params [period: week|month|quarter]
|
||||
*/
|
||||
public function charts(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$period = $params['period'] ?? 'month';
|
||||
|
||||
[$startDate, $endDate] = $this->getPeriodRange($period);
|
||||
|
||||
return [
|
||||
'period' => $period,
|
||||
'start_date' => $startDate->toDateString(),
|
||||
'end_date' => $endDate->toDateString(),
|
||||
'deposit_trend' => $this->getDepositTrend($tenantId, $startDate, $endDate),
|
||||
'withdrawal_trend' => $this->getWithdrawalTrend($tenantId, $startDate, $endDate),
|
||||
'sales_by_client' => $this->getSalesByClient($tenantId, $startDate, $endDate),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 현황 조회
|
||||
*
|
||||
* @param array $params [limit: int]
|
||||
*/
|
||||
public function approvals(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
$limit = $params['limit'] ?? 10;
|
||||
|
||||
// 내가 결재할 문서 (결재함)
|
||||
$pendingApprovals = $this->getPendingApprovals($tenantId, $userId, $limit);
|
||||
|
||||
// 내가 기안한 문서 중 진행중인 것
|
||||
$myDrafts = $this->getMyPendingDrafts($tenantId, $userId, $limit);
|
||||
|
||||
return [
|
||||
'pending_approvals' => $pendingApprovals,
|
||||
'my_drafts' => $myDrafts,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘 요약 데이터
|
||||
*/
|
||||
private function getTodaySummary(int $tenantId, Carbon $today): array
|
||||
{
|
||||
// 오늘 출근자 수
|
||||
$attendancesCount = Attendance::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereDate('work_date', $today)
|
||||
->whereNotNull('check_in')
|
||||
->count();
|
||||
|
||||
// 오늘 휴가자 수
|
||||
$leavesCount = Leave::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', 'approved')
|
||||
->whereDate('start_date', '<=', $today)
|
||||
->whereDate('end_date', '>=', $today)
|
||||
->count();
|
||||
|
||||
// 결재 대기 문서 수 (전체)
|
||||
$approvalsPending = Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', 'pending')
|
||||
->count();
|
||||
|
||||
return [
|
||||
'date' => $today->toDateString(),
|
||||
'attendances_count' => $attendancesCount,
|
||||
'leaves_count' => $leavesCount,
|
||||
'approvals_pending' => $approvalsPending,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 재무 요약 데이터
|
||||
*/
|
||||
private function getFinanceSummary(int $tenantId, Carbon $startOfMonth, Carbon $endOfMonth): array
|
||||
{
|
||||
// 월간 입금 합계
|
||||
$monthlyDeposit = Deposit::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereBetween('deposit_date', [$startOfMonth, $endOfMonth])
|
||||
->sum('amount');
|
||||
|
||||
// 월간 출금 합계
|
||||
$monthlyWithdrawal = Withdrawal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereBetween('withdrawal_date', [$startOfMonth, $endOfMonth])
|
||||
->sum('amount');
|
||||
|
||||
// 현재 잔액 (전체 입금 - 전체 출금)
|
||||
$totalDeposits = Deposit::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->sum('amount');
|
||||
|
||||
$totalWithdrawals = Withdrawal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->sum('amount');
|
||||
|
||||
$balance = $totalDeposits - $totalWithdrawals;
|
||||
|
||||
return [
|
||||
'monthly_deposit' => (float) $monthlyDeposit,
|
||||
'monthly_withdrawal' => (float) $monthlyWithdrawal,
|
||||
'balance' => (float) $balance,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 매출/매입 요약 데이터
|
||||
*/
|
||||
private function getSalesSummary(int $tenantId, Carbon $startOfMonth, Carbon $endOfMonth): array
|
||||
{
|
||||
// 월간 매출 합계
|
||||
$monthlySales = Sale::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereBetween('sale_date', [$startOfMonth, $endOfMonth])
|
||||
->sum('total_amount');
|
||||
|
||||
// 월간 매입 합계
|
||||
$monthlyPurchases = Purchase::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereBetween('purchase_date', [$startOfMonth, $endOfMonth])
|
||||
->sum('total_amount');
|
||||
|
||||
return [
|
||||
'monthly_sales' => (float) $monthlySales,
|
||||
'monthly_purchases' => (float) $monthlyPurchases,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 할 일 요약 데이터
|
||||
*/
|
||||
private function getTasksSummary(int $tenantId, int $userId): array
|
||||
{
|
||||
// 내가 결재해야 할 문서 수
|
||||
$pendingApprovals = ApprovalStep::query()
|
||||
->whereHas('approval', function ($query) use ($tenantId) {
|
||||
$query->where('tenant_id', $tenantId)
|
||||
->where('status', 'pending');
|
||||
})
|
||||
->where('approver_id', $userId)
|
||||
->where('status', 'pending')
|
||||
->count();
|
||||
|
||||
// 승인 대기 휴가 신청 수 (관리자용)
|
||||
$pendingLeaves = Leave::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', 'pending')
|
||||
->count();
|
||||
|
||||
return [
|
||||
'pending_approvals' => $pendingApprovals,
|
||||
'pending_leaves' => $pendingLeaves,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기간 범위 계산
|
||||
*
|
||||
* @return array [Carbon $startDate, Carbon $endDate]
|
||||
*/
|
||||
private function getPeriodRange(string $period): array
|
||||
{
|
||||
$endDate = Carbon::today();
|
||||
|
||||
switch ($period) {
|
||||
case 'week':
|
||||
$startDate = $endDate->copy()->subDays(6);
|
||||
break;
|
||||
case 'quarter':
|
||||
$startDate = $endDate->copy()->subMonths(3)->startOfMonth();
|
||||
break;
|
||||
case 'month':
|
||||
default:
|
||||
$startDate = $endDate->copy()->subDays(29);
|
||||
break;
|
||||
}
|
||||
|
||||
return [$startDate, $endDate];
|
||||
}
|
||||
|
||||
/**
|
||||
* 입금 추이 데이터
|
||||
*/
|
||||
private function getDepositTrend(int $tenantId, Carbon $startDate, Carbon $endDate): array
|
||||
{
|
||||
$deposits = Deposit::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereBetween('deposit_date', [$startDate, $endDate])
|
||||
->select(
|
||||
DB::raw('DATE(deposit_date) as date'),
|
||||
DB::raw('SUM(amount) as amount')
|
||||
)
|
||||
->groupBy(DB::raw('DATE(deposit_date)'))
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
return $deposits->map(function ($item) {
|
||||
return [
|
||||
'date' => $item->date,
|
||||
'amount' => (float) $item->amount,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 출금 추이 데이터
|
||||
*/
|
||||
private function getWithdrawalTrend(int $tenantId, Carbon $startDate, Carbon $endDate): array
|
||||
{
|
||||
$withdrawals = Withdrawal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereBetween('withdrawal_date', [$startDate, $endDate])
|
||||
->select(
|
||||
DB::raw('DATE(withdrawal_date) as date'),
|
||||
DB::raw('SUM(amount) as amount')
|
||||
)
|
||||
->groupBy(DB::raw('DATE(withdrawal_date)'))
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
return $withdrawals->map(function ($item) {
|
||||
return [
|
||||
'date' => $item->date,
|
||||
'amount' => (float) $item->amount,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처별 매출 데이터
|
||||
*/
|
||||
private function getSalesByClient(int $tenantId, Carbon $startDate, Carbon $endDate): array
|
||||
{
|
||||
$sales = Sale::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereBetween('sale_date', [$startDate, $endDate])
|
||||
->with('client:id,name')
|
||||
->select(
|
||||
'client_id',
|
||||
DB::raw('SUM(total_amount) as amount')
|
||||
)
|
||||
->groupBy('client_id')
|
||||
->orderByDesc('amount')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return $sales->map(function ($item) {
|
||||
return [
|
||||
'client_id' => $item->client_id,
|
||||
'client_name' => $item->client?->name ?? __('message.dashboard.unknown_client'),
|
||||
'amount' => (float) $item->amount,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 내가 결재해야 할 문서 목록
|
||||
*/
|
||||
private function getPendingApprovals(int $tenantId, int $userId, int $limit): 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');
|
||||
}])
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
return $steps->map(function ($step) {
|
||||
return [
|
||||
'id' => $step->approval->id,
|
||||
'title' => $step->approval->title,
|
||||
'drafter_name' => $step->approval->drafter?->name ?? '',
|
||||
'status' => $step->approval->status,
|
||||
'created_at' => $step->approval->created_at?->toDateTimeString(),
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 내가 기안한 진행중인 문서 목록
|
||||
*/
|
||||
private function getMyPendingDrafts(int $tenantId, int $userId, int $limit): array
|
||||
{
|
||||
$approvals = Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('drafter_id', $userId)
|
||||
->where('status', 'pending')
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
return $approvals->map(function ($approval) {
|
||||
return [
|
||||
'id' => $approval->id,
|
||||
'title' => $approval->title,
|
||||
'status' => $approval->status,
|
||||
'current_step' => $approval->current_step,
|
||||
'created_at' => $approval->created_at?->toDateTimeString(),
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
}
|
||||
277
app/Services/PushNotificationService.php
Normal file
277
app/Services/PushNotificationService.php
Normal file
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\PushDeviceToken;
|
||||
use App\Models\PushNotificationSetting;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PushNotificationService extends Service
|
||||
{
|
||||
/**
|
||||
* FCM 토큰 등록/갱신
|
||||
*/
|
||||
public function registerToken(array $data): PushDeviceToken
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
// 동일 토큰이 있으면 업데이트, 없으면 생성
|
||||
$token = PushDeviceToken::withoutGlobalScopes()
|
||||
->where('token', $data['token'])
|
||||
->first();
|
||||
|
||||
if ($token) {
|
||||
// 기존 토큰 업데이트 (다른 사용자의 토큰이면 이전 것은 비활성화)
|
||||
if ($token->user_id !== $userId || $token->tenant_id !== $tenantId) {
|
||||
$token->update([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'platform' => $data['platform'],
|
||||
'device_name' => $data['device_name'] ?? null,
|
||||
'app_version' => $data['app_version'] ?? null,
|
||||
'is_active' => true,
|
||||
'last_used_at' => now(),
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
} else {
|
||||
$token->update([
|
||||
'platform' => $data['platform'],
|
||||
'device_name' => $data['device_name'] ?? null,
|
||||
'app_version' => $data['app_version'] ?? null,
|
||||
'is_active' => true,
|
||||
'last_used_at' => now(),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// 새 토큰 생성
|
||||
$token = PushDeviceToken::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'token' => $data['token'],
|
||||
'platform' => $data['platform'],
|
||||
'device_name' => $data['device_name'] ?? null,
|
||||
'app_version' => $data['app_version'] ?? null,
|
||||
'is_active' => true,
|
||||
'last_used_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 사용자 기본 알림 설정 초기화 (없는 경우)
|
||||
$this->initializeDefaultSettings($tenantId, $userId);
|
||||
|
||||
Log::info('FCM token registered', [
|
||||
'user_id' => $userId,
|
||||
'platform' => $data['platform'],
|
||||
'token_id' => $token->id,
|
||||
]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* FCM 토큰 비활성화
|
||||
*/
|
||||
public function unregisterToken(string $tokenValue): bool
|
||||
{
|
||||
$token = PushDeviceToken::withoutGlobalScopes()
|
||||
->where('token', $tokenValue)
|
||||
->first();
|
||||
|
||||
if ($token) {
|
||||
$token->update(['is_active' => false]);
|
||||
|
||||
Log::info('FCM token unregistered', [
|
||||
'token_id' => $token->id,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자의 활성 토큰 목록 조회
|
||||
*/
|
||||
public function getUserTokens(?int $userId = null): array
|
||||
{
|
||||
$userId = $userId ?? $this->apiUserId();
|
||||
|
||||
return PushDeviceToken::forUser($userId)
|
||||
->active()
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 설정 조회
|
||||
*/
|
||||
public function getSettings(?int $userId = null): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $userId ?? $this->apiUserId();
|
||||
|
||||
$settings = PushNotificationSetting::where('tenant_id', $tenantId)
|
||||
->forUser($userId)
|
||||
->get()
|
||||
->keyBy('notification_type');
|
||||
|
||||
// 모든 알림 유형에 대한 설정 반환 (없으면 기본값)
|
||||
$result = [];
|
||||
foreach (PushNotificationSetting::getAllTypes() as $type) {
|
||||
if ($settings->has($type)) {
|
||||
$result[$type] = $settings->get($type)->toArray();
|
||||
} else {
|
||||
$result[$type] = [
|
||||
'notification_type' => $type,
|
||||
'is_enabled' => true,
|
||||
'sound' => $this->getDefaultSound($type),
|
||||
'vibrate' => true,
|
||||
'show_preview' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 설정 업데이트
|
||||
*/
|
||||
public function updateSettings(array $settings): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$updated = [];
|
||||
foreach ($settings as $setting) {
|
||||
$record = PushNotificationSetting::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'notification_type' => $setting['notification_type'],
|
||||
],
|
||||
[
|
||||
'is_enabled' => $setting['is_enabled'],
|
||||
'sound' => $setting['sound'] ?? $this->getDefaultSound($setting['notification_type']),
|
||||
'vibrate' => $setting['vibrate'] ?? true,
|
||||
'show_preview' => $setting['show_preview'] ?? true,
|
||||
]
|
||||
);
|
||||
$updated[] = $record->toArray();
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 사용자에게 푸시 알림 전송 (FCM HTTP v1 API)
|
||||
*/
|
||||
public function sendToUser(int $userId, string $notificationType, array $notification): bool
|
||||
{
|
||||
$tenantId = $this->tenantIdOrNull() ?? 0;
|
||||
|
||||
// 사용자 알림 설정 확인
|
||||
$setting = PushNotificationSetting::where('tenant_id', $tenantId)
|
||||
->forUser($userId)
|
||||
->ofType($notificationType)
|
||||
->first();
|
||||
|
||||
// 알림이 비활성화된 경우 전송 안함
|
||||
if ($setting && ! $setting->is_enabled) {
|
||||
Log::info('Push notification skipped (disabled)', [
|
||||
'user_id' => $userId,
|
||||
'type' => $notificationType,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 사용자의 활성 토큰 조회
|
||||
$tokens = PushDeviceToken::withoutGlobalScopes()
|
||||
->forUser($userId)
|
||||
->active()
|
||||
->get();
|
||||
|
||||
if ($tokens->isEmpty()) {
|
||||
Log::info('Push notification skipped (no tokens)', [
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 알림음 결정
|
||||
$sound = $setting?->sound ?? $this->getDefaultSound($notificationType);
|
||||
|
||||
$successCount = 0;
|
||||
foreach ($tokens as $token) {
|
||||
$result = $this->sendFcmMessage($token, $notification, $sound, $notificationType);
|
||||
if ($result) {
|
||||
$successCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $successCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* FCM 메시지 전송 (실제 구현)
|
||||
*/
|
||||
protected function sendFcmMessage(
|
||||
PushDeviceToken $token,
|
||||
array $notification,
|
||||
string $sound,
|
||||
string $notificationType
|
||||
): bool {
|
||||
// TODO: FCM HTTP v1 API 구현
|
||||
// 현재는 로그만 기록
|
||||
Log::info('FCM message would be sent', [
|
||||
'token_id' => $token->id,
|
||||
'platform' => $token->platform,
|
||||
'title' => $notification['title'] ?? '',
|
||||
'body' => $notification['body'] ?? '',
|
||||
'sound' => $sound,
|
||||
'type' => $notificationType,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 알림 설정 초기화
|
||||
*/
|
||||
protected function initializeDefaultSettings(int $tenantId, int $userId): void
|
||||
{
|
||||
foreach (PushNotificationSetting::getAllTypes() as $type) {
|
||||
PushNotificationSetting::firstOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'notification_type' => $type,
|
||||
],
|
||||
[
|
||||
'is_enabled' => true,
|
||||
'sound' => $this->getDefaultSound($type),
|
||||
'vibrate' => true,
|
||||
'show_preview' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 유형별 기본 알림음
|
||||
*/
|
||||
protected function getDefaultSound(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
PushNotificationSetting::TYPE_DEPOSIT => PushNotificationSetting::SOUND_DEPOSIT,
|
||||
PushNotificationSetting::TYPE_WITHDRAWAL => PushNotificationSetting::SOUND_WITHDRAWAL,
|
||||
PushNotificationSetting::TYPE_ORDER => PushNotificationSetting::SOUND_ORDER,
|
||||
PushNotificationSetting::TYPE_APPROVAL => PushNotificationSetting::SOUND_APPROVAL,
|
||||
default => PushNotificationSetting::SOUND_DEFAULT,
|
||||
};
|
||||
}
|
||||
}
|
||||
184
app/Swagger/v1/DashboardApi.php
Normal file
184
app/Swagger/v1/DashboardApi.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag(name="Dashboard", description="대시보드")
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="DashboardSummary",
|
||||
* type="object",
|
||||
* description="대시보드 요약 데이터",
|
||||
*
|
||||
* @OA\Property(property="today", type="object", description="오늘 요약",
|
||||
* @OA\Property(property="date", type="string", format="date", example="2025-01-15", description="오늘 날짜"),
|
||||
* @OA\Property(property="attendances_count", type="integer", example=25, description="오늘 출근자 수"),
|
||||
* @OA\Property(property="leaves_count", type="integer", example=3, description="오늘 휴가자 수"),
|
||||
* @OA\Property(property="approvals_pending", type="integer", example=5, description="결재 대기 문서 수")
|
||||
* ),
|
||||
* @OA\Property(property="finance", type="object", description="재무 요약",
|
||||
* @OA\Property(property="monthly_deposit", type="number", format="float", example=50000000, description="월간 입금액"),
|
||||
* @OA\Property(property="monthly_withdrawal", type="number", format="float", example=30000000, description="월간 출금액"),
|
||||
* @OA\Property(property="balance", type="number", format="float", example=150000000, description="현재 잔액")
|
||||
* ),
|
||||
* @OA\Property(property="sales", type="object", description="매출/매입 요약",
|
||||
* @OA\Property(property="monthly_sales", type="number", format="float", example=80000000, description="월간 매출"),
|
||||
* @OA\Property(property="monthly_purchases", type="number", format="float", example=45000000, description="월간 매입")
|
||||
* ),
|
||||
* @OA\Property(property="tasks", type="object", description="할 일 요약",
|
||||
* @OA\Property(property="pending_approvals", type="integer", example=3, description="내가 결재할 문서 수"),
|
||||
* @OA\Property(property="pending_leaves", type="integer", example=2, description="승인 대기 휴가 신청 수")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="DashboardCharts",
|
||||
* type="object",
|
||||
* description="대시보드 차트 데이터",
|
||||
*
|
||||
* @OA\Property(property="period", type="string", enum={"week","month","quarter"}, example="month", description="조회 기간"),
|
||||
* @OA\Property(property="start_date", type="string", format="date", example="2024-12-17", description="시작일"),
|
||||
* @OA\Property(property="end_date", type="string", format="date", example="2025-01-15", description="종료일"),
|
||||
* @OA\Property(property="deposit_trend", type="array", description="입금 추이",
|
||||
* @OA\Items(type="object",
|
||||
* @OA\Property(property="date", type="string", format="date", example="2025-01-15"),
|
||||
* @OA\Property(property="amount", type="number", format="float", example=5000000)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Property(property="withdrawal_trend", type="array", description="출금 추이",
|
||||
* @OA\Items(type="object",
|
||||
* @OA\Property(property="date", type="string", format="date", example="2025-01-15"),
|
||||
* @OA\Property(property="amount", type="number", format="float", example=3000000)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Property(property="sales_by_client", type="array", description="거래처별 매출 (상위 10개)",
|
||||
* @OA\Items(type="object",
|
||||
* @OA\Property(property="client_id", type="integer", example=1),
|
||||
* @OA\Property(property="client_name", type="string", example="(주)테스트"),
|
||||
* @OA\Property(property="amount", type="number", format="float", example=15000000)
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="DashboardApprovals",
|
||||
* type="object",
|
||||
* description="대시보드 결재 현황",
|
||||
*
|
||||
* @OA\Property(property="pending_approvals", type="array", description="결재 대기 문서 (내가 결재할 문서)",
|
||||
* @OA\Items(type="object",
|
||||
* @OA\Property(property="id", type="integer", example=1, description="결재문서 ID"),
|
||||
* @OA\Property(property="title", type="string", example="출장 신청서", description="제목"),
|
||||
* @OA\Property(property="drafter_name", type="string", example="홍길동", description="기안자명"),
|
||||
* @OA\Property(property="status", type="string", example="pending", description="상태"),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-01-15 10:30:00", description="생성일시")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Property(property="my_drafts", type="array", description="내가 기안한 진행중인 문서",
|
||||
* @OA\Items(type="object",
|
||||
* @OA\Property(property="id", type="integer", example=2, description="결재문서 ID"),
|
||||
* @OA\Property(property="title", type="string", example="휴가 신청서", description="제목"),
|
||||
* @OA\Property(property="status", type="string", example="pending", description="상태"),
|
||||
* @OA\Property(property="current_step", type="integer", example=2, description="현재 결재 단계"),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-01-14 09:00:00", description="생성일시")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
class DashboardApi
|
||||
{
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/dashboard/summary",
|
||||
* tags={"Dashboard"},
|
||||
* summary="대시보드 요약 데이터 조회",
|
||||
* description="오늘 현황, 재무 요약, 매출/매입 요약, 할 일 요약을 반환합니다.",
|
||||
* operationId="getDashboardSummary",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="데이터를 조회했습니다."),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/DashboardSummary")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패")
|
||||
* )
|
||||
*/
|
||||
public function summary() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/dashboard/charts",
|
||||
* tags={"Dashboard"},
|
||||
* summary="대시보드 차트 데이터 조회",
|
||||
* description="입금/출금 추이, 거래처별 매출 차트 데이터를 반환합니다.",
|
||||
* operationId="getDashboardCharts",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="period",
|
||||
* in="query",
|
||||
* description="조회 기간 (week: 7일, month: 30일, quarter: 3개월)",
|
||||
* required=false,
|
||||
*
|
||||
* @OA\Schema(type="string", enum={"week","month","quarter"}, default="month")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="데이터를 조회했습니다."),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/DashboardCharts")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패"),
|
||||
* @OA\Response(response=422, description="유효성 검사 실패")
|
||||
* )
|
||||
*/
|
||||
public function charts() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/dashboard/approvals",
|
||||
* tags={"Dashboard"},
|
||||
* summary="결재 현황 조회",
|
||||
* description="결재 대기 문서(결재함)와 내가 기안한 진행중인 문서 목록을 반환합니다.",
|
||||
* operationId="getDashboardApprovals",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="limit",
|
||||
* in="query",
|
||||
* description="각 목록의 최대 항목 수 (1~50)",
|
||||
* required=false,
|
||||
*
|
||||
* @OA\Schema(type="integer", minimum=1, maximum=50, default=10)
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="데이터를 조회했습니다."),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/DashboardApprovals")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패"),
|
||||
* @OA\Response(response=422, description="유효성 검사 실패")
|
||||
* )
|
||||
*/
|
||||
public function approvals() {}
|
||||
}
|
||||
303
app/Swagger/v1/PushApi.php
Normal file
303
app/Swagger/v1/PushApi.php
Normal file
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag(name="Push", description="푸시 알림 관리")
|
||||
*/
|
||||
|
||||
/**
|
||||
* Push 관련 스키마 정의
|
||||
* -----------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* @OA\Schema(
|
||||
* schema="PushDeviceToken",
|
||||
* type="object",
|
||||
* description="푸시 디바이스 토큰",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="tenant_id", type="integer", example=1),
|
||||
* @OA\Property(property="user_id", type="integer", example=5),
|
||||
* @OA\Property(property="token", type="string", example="dGhpcyBpcyBhIHNhbXBsZSBGQ00gdG9rZW4..."),
|
||||
* @OA\Property(property="platform", type="string", enum={"ios", "android", "web"}, example="android"),
|
||||
* @OA\Property(property="device_name", type="string", nullable=true, example="Samsung Galaxy S24"),
|
||||
* @OA\Property(property="app_version", type="string", nullable=true, example="1.0.0"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="last_used_at", type="string", format="date-time", example="2025-12-18 10:30:00"),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-12-18 10:00:00"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-12-18 10:30:00")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="PushNotificationSetting",
|
||||
* type="object",
|
||||
* description="푸시 알림 설정",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="tenant_id", type="integer", example=1),
|
||||
* @OA\Property(property="user_id", type="integer", example=5),
|
||||
* @OA\Property(property="notification_type", type="string", enum={"deposit", "withdrawal", "order", "approval", "attendance", "notice", "system"}, example="deposit"),
|
||||
* @OA\Property(property="is_enabled", type="boolean", example=true),
|
||||
* @OA\Property(property="sound", type="string", enum={"default.wav", "deposit.wav", "withdrawal.wav", "order.wav", "approval.wav", "urgent.wav"}, example="deposit.wav"),
|
||||
* @OA\Property(property="vibrate", type="boolean", example=true),
|
||||
* @OA\Property(property="show_preview", type="boolean", example=true),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-12-18 10:00:00"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-12-18 10:30:00")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="RegisterTokenRequest",
|
||||
* type="object",
|
||||
* required={"token", "platform"},
|
||||
* @OA\Property(property="token", type="string", minLength=10, example="dGhpcyBpcyBhIHNhbXBsZSBGQ00gdG9rZW4...", description="FCM 토큰"),
|
||||
* @OA\Property(property="platform", type="string", enum={"ios", "android", "web"}, example="android", description="디바이스 플랫폼"),
|
||||
* @OA\Property(property="device_name", type="string", nullable=true, maxLength=255, example="Samsung Galaxy S24", description="디바이스명"),
|
||||
* @OA\Property(property="app_version", type="string", nullable=true, maxLength=50, example="1.0.0", description="앱 버전")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="UnregisterTokenRequest",
|
||||
* type="object",
|
||||
* required={"token"},
|
||||
* @OA\Property(property="token", type="string", example="dGhpcyBpcyBhIHNhbXBsZSBGQ00gdG9rZW4...", description="해제할 FCM 토큰")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="UpdatePushSettingsRequest",
|
||||
* type="object",
|
||||
* required={"settings"},
|
||||
* @OA\Property(
|
||||
* property="settings",
|
||||
* type="array",
|
||||
* description="알림 설정 배열",
|
||||
* @OA\Items(
|
||||
* type="object",
|
||||
* required={"notification_type", "is_enabled"},
|
||||
* @OA\Property(property="notification_type", type="string", enum={"deposit", "withdrawal", "order", "approval", "attendance", "notice", "system"}, example="deposit"),
|
||||
* @OA\Property(property="is_enabled", type="boolean", example=true),
|
||||
* @OA\Property(property="sound", type="string", nullable=true, enum={"default.wav", "deposit.wav", "withdrawal.wav", "order.wav", "approval.wav", "urgent.wav"}, example="deposit.wav"),
|
||||
* @OA\Property(property="vibrate", type="boolean", nullable=true, example=true),
|
||||
* @OA\Property(property="show_preview", type="boolean", nullable=true, example=true)
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="NotificationTypesResponse",
|
||||
* type="object",
|
||||
* @OA\Property(
|
||||
* property="types",
|
||||
* type="array",
|
||||
* description="지원하는 알림 유형 목록",
|
||||
* @OA\Items(type="string", example="deposit")
|
||||
* ),
|
||||
* @OA\Property(
|
||||
* property="sounds",
|
||||
* type="array",
|
||||
* description="지원하는 알림음 목록",
|
||||
* @OA\Items(type="string", example="deposit.wav")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
class PushApi
|
||||
{
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/push/register-token",
|
||||
* tags={"Push"},
|
||||
* summary="FCM 토큰 등록",
|
||||
* description="디바이스의 FCM 토큰을 등록합니다. 동일한 토큰이 이미 존재하면 업데이트됩니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(ref="#/components/schemas/RegisterTokenRequest")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="토큰 등록 성공",
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
* @OA\Property(property="message", type="string", example="푸시 토큰이 등록되었습니다."),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/PushDeviceToken")
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function registerToken() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/push/unregister-token",
|
||||
* tags={"Push"},
|
||||
* summary="FCM 토큰 해제",
|
||||
* description="디바이스의 FCM 토큰을 비활성화합니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(ref="#/components/schemas/UnregisterTokenRequest")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="토큰 해제 성공",
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
* @OA\Property(property="message", type="string", example="푸시 토큰이 해제되었습니다."),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="object",
|
||||
* @OA\Property(property="unregistered", type="boolean", example=true)
|
||||
* )
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=400, description="토큰 누락", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function unregisterToken() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/push/tokens",
|
||||
* tags={"Push"},
|
||||
* summary="사용자 토큰 목록 조회",
|
||||
* description="현재 사용자의 활성화된 디바이스 토큰 목록을 조회합니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="조회 성공",
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
* @OA\Items(ref="#/components/schemas/PushDeviceToken")
|
||||
* )
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function getTokens() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/push/settings",
|
||||
* tags={"Push"},
|
||||
* summary="알림 설정 조회",
|
||||
* description="현재 사용자의 알림 유형별 설정을 조회합니다. 설정이 없는 유형은 기본값으로 반환됩니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="조회 성공",
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="object",
|
||||
* description="알림 유형별 설정 (키: notification_type)",
|
||||
* @OA\Property(property="deposit", ref="#/components/schemas/PushNotificationSetting"),
|
||||
* @OA\Property(property="withdrawal", ref="#/components/schemas/PushNotificationSetting"),
|
||||
* @OA\Property(property="order", ref="#/components/schemas/PushNotificationSetting"),
|
||||
* @OA\Property(property="approval", ref="#/components/schemas/PushNotificationSetting"),
|
||||
* @OA\Property(property="attendance", ref="#/components/schemas/PushNotificationSetting"),
|
||||
* @OA\Property(property="notice", ref="#/components/schemas/PushNotificationSetting"),
|
||||
* @OA\Property(property="system", ref="#/components/schemas/PushNotificationSetting")
|
||||
* )
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function getSettings() {}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/push/settings",
|
||||
* tags={"Push"},
|
||||
* summary="알림 설정 업데이트",
|
||||
* description="사용자의 알림 설정을 업데이트합니다. 여러 알림 유형을 한 번에 설정할 수 있습니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(ref="#/components/schemas/UpdatePushSettingsRequest")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="설정 업데이트 성공",
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
* @OA\Property(property="message", type="string", example="알림 설정이 업데이트되었습니다."),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
* @OA\Items(ref="#/components/schemas/PushNotificationSetting")
|
||||
* )
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function updateSettings() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/push/notification-types",
|
||||
* tags={"Push"},
|
||||
* summary="알림 유형 목록 조회",
|
||||
* description="지원하는 알림 유형과 알림음 목록을 조회합니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="조회 성공",
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/NotificationTypesResponse")
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function getNotificationTypes() {}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('push_device_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||
$table->unsignedBigInteger('user_id')->comment('사용자 ID');
|
||||
$table->text('token')->comment('FCM 디바이스 토큰');
|
||||
$table->string('platform', 20)->comment('플랫폼: ios, android, web');
|
||||
$table->string('device_name')->nullable()->comment('디바이스명');
|
||||
$table->string('app_version', 50)->nullable()->comment('앱 버전');
|
||||
$table->boolean('is_active')->default(true)->comment('활성화 여부');
|
||||
$table->timestamp('last_used_at')->nullable()->comment('마지막 사용 시간');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
// 인덱스
|
||||
$table->index(['tenant_id', 'user_id']);
|
||||
$table->index(['user_id', 'is_active']);
|
||||
$table->index('platform');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('push_device_tokens');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('push_notification_settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||
$table->unsignedBigInteger('user_id')->comment('사용자 ID');
|
||||
$table->string('notification_type', 50)->comment('알림 유형: deposit, withdrawal, order, approval 등');
|
||||
$table->boolean('is_enabled')->default(true)->comment('알림 활성화 여부');
|
||||
$table->string('sound', 100)->default('default')->comment('알림음 파일명');
|
||||
$table->boolean('vibrate')->default(true)->comment('진동 여부');
|
||||
$table->boolean('show_preview')->default(true)->comment('미리보기 표시 여부');
|
||||
$table->timestamps();
|
||||
|
||||
// 복합 유니크 키
|
||||
$table->unique(['tenant_id', 'user_id', 'notification_type'], 'push_settings_unique');
|
||||
|
||||
// 인덱스
|
||||
$table->index(['tenant_id', 'user_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('push_notification_settings');
|
||||
}
|
||||
};
|
||||
@@ -15,6 +15,7 @@
|
||||
use App\Http\Controllers\Api\V1\ClientController;
|
||||
use App\Http\Controllers\Api\V1\ClientGroupController;
|
||||
use App\Http\Controllers\Api\V1\CommonController;
|
||||
use App\Http\Controllers\Api\V1\DashboardController;
|
||||
use App\Http\Controllers\Api\V1\DepartmentController;
|
||||
use App\Http\Controllers\Api\V1\DepositController;
|
||||
use App\Http\Controllers\Api\V1\Design\AuditLogController as DesignAuditLogController;
|
||||
@@ -50,6 +51,7 @@
|
||||
// use App\Http\Controllers\Api\V1\ProductBomItemController; // REMOVED: products 테이블 삭제됨
|
||||
// use App\Http\Controllers\Api\V1\ProductController; // REMOVED: products 테이블 삭제됨
|
||||
use App\Http\Controllers\Api\V1\PricingController;
|
||||
use App\Http\Controllers\Api\V1\PushNotificationController;
|
||||
use App\Http\Controllers\Api\V1\PurchaseController;
|
||||
use App\Http\Controllers\Api\V1\QuoteController;
|
||||
use App\Http\Controllers\Api\V1\RefreshController;
|
||||
@@ -412,6 +414,13 @@
|
||||
Route::get('/expense-estimate/export', [ReportController::class, 'expenseEstimateExport'])->name('v1.reports.expense-estimate.export');
|
||||
});
|
||||
|
||||
// Dashboard API (대시보드)
|
||||
Route::prefix('dashboard')->group(function () {
|
||||
Route::get('/summary', [DashboardController::class, 'summary'])->name('v1.dashboard.summary');
|
||||
Route::get('/charts', [DashboardController::class, 'charts'])->name('v1.dashboard.charts');
|
||||
Route::get('/approvals', [DashboardController::class, 'approvals'])->name('v1.dashboard.approvals');
|
||||
});
|
||||
|
||||
// Permission API
|
||||
Route::prefix('permissions')->group(function () {
|
||||
Route::get('departments/{dept_id}/menu-matrix', [PermissionController::class, 'deptMenuMatrix'])->name('v1.permissions.deptMenuMatrix'); // 부서별 권한 메트릭스
|
||||
@@ -461,6 +470,16 @@
|
||||
Route::delete('/common/{id}', [CommonController::class, 'destroy'])->name('v1.settings.common.destroy'); // 공통 코드 삭제
|
||||
});
|
||||
|
||||
// Push Notification API (FCM 푸시 알림)
|
||||
Route::prefix('push')->group(function () {
|
||||
Route::post('/register-token', [PushNotificationController::class, 'registerToken'])->name('v1.push.register-token'); // FCM 토큰 등록
|
||||
Route::post('/unregister-token', [PushNotificationController::class, 'unregisterToken'])->name('v1.push.unregister-token'); // FCM 토큰 해제
|
||||
Route::get('/tokens', [PushNotificationController::class, 'getTokens'])->name('v1.push.tokens'); // 등록된 토큰 목록
|
||||
Route::get('/settings', [PushNotificationController::class, 'getSettings'])->name('v1.push.settings'); // 알림 설정 조회
|
||||
Route::put('/settings', [PushNotificationController::class, 'updateSettings'])->name('v1.push.settings.update'); // 알림 설정 수정
|
||||
Route::get('/notification-types', [PushNotificationController::class, 'getNotificationTypes'])->name('v1.push.notification-types'); // 알림 유형 목록
|
||||
});
|
||||
|
||||
// 회원 프로필(테넌트 기준)
|
||||
Route::prefix('profiles')->group(function () {
|
||||
Route::get('', [TenantUserProfileController::class, 'index'])->name('v1.profiles.index'); // 프로필 목록(테넌트 기준)
|
||||
|
||||
Reference in New Issue
Block a user