From 3020026abfff8e6ff5fd3171ec3e490a570f1cf3 Mon Sep 17 00:00:00 2001 From: hskwon Date: Fri, 19 Dec 2025 14:52:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=205=20API=20=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20(=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=B4=88?= =?UTF-8?q?=EB=8C=80,=20=EC=95=8C=EB=A6=BC=EC=84=A4=EC=A0=95,=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=EA=B4=80=EB=A6=AC,=20=EA=B1=B0=EB=9E=98=EB=AA=85?= =?UTF-8?q?=EC=84=B8=EC=84=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5.1 사용자 초대 기능: - UserInvitation 마이그레이션, 모델, 서비스, 컨트롤러, Swagger - 초대 발송/수락/취소/재발송 API 5.2 알림설정 확장: - NotificationSetting 마이그레이션, 모델, 서비스, 컨트롤러, Swagger - 채널별/유형별 알림 설정 관리 5.3 계정정보 수정 API: - 회원탈퇴, 사용중지, 약관동의 관리 - AccountService, AccountController, Swagger 5.4 매출 거래명세서 API: - 거래명세서 조회/발행/이메일발송 - SaleService 확장, Swagger 문서화 --- app/Console/Commands/FcmTestCommand.php | 2 +- .../Controllers/Api/V1/AccountController.php | 61 ++++ .../Api/V1/NotificationSettingController.php | 50 +++ .../Api/V1/PushNotificationController.php | 2 +- .../Controllers/Api/V1/SaleController.php | 31 ++ .../Api/V1/UserInvitationController.php | 73 ++++ .../Account/UpdateAgreementsRequest.php | 36 ++ app/Http/Requests/Account/WithdrawRequest.php | 32 ++ .../BulkUpdateSettingRequest.php | 39 +++ .../UpdateSettingRequest.php | 36 ++ .../AcceptInvitationRequest.php | 32 ++ .../UserInvitation/InviteUserRequest.php | 32 ++ .../UserInvitation/ListInvitationRequest.php | 25 ++ .../Requests/V1/Sale/SendStatementRequest.php | 16 + app/Models/NotificationSetting.php | 226 +++++++++++++ app/Models/UserInvitation.php | 176 ++++++++++ app/Services/AccountService.php | 240 ++++++++++++++ app/Services/NotificationSettingService.php | 180 ++++++++++ app/Services/SaleService.php | 151 +++++++++ app/Services/UserInvitationService.php | 230 +++++++++++++ app/Swagger/v1/AccountApi.php | 214 ++++++++++++ app/Swagger/v1/InternalApi.php | 3 +- app/Swagger/v1/NotificationSettingApi.php | 173 ++++++++++ app/Swagger/v1/SaleApi.php | 180 ++++++++++ app/Swagger/v1/UserInvitationApi.php | 311 ++++++++++++++++++ ...00_create_admin_api_deprecations_table.php | 2 +- ...9_100001_create_user_invitations_table.php | 41 +++ ...002_create_notification_settings_table.php | 47 +++ lang/ko/error.php | 35 ++ lang/ko/message.php | 30 ++ routes/api.php | 37 ++- 31 files changed, 2735 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/AccountController.php create mode 100644 app/Http/Controllers/Api/V1/NotificationSettingController.php create mode 100644 app/Http/Controllers/Api/V1/UserInvitationController.php create mode 100644 app/Http/Requests/Account/UpdateAgreementsRequest.php create mode 100644 app/Http/Requests/Account/WithdrawRequest.php create mode 100644 app/Http/Requests/NotificationSetting/BulkUpdateSettingRequest.php create mode 100644 app/Http/Requests/NotificationSetting/UpdateSettingRequest.php create mode 100644 app/Http/Requests/UserInvitation/AcceptInvitationRequest.php create mode 100644 app/Http/Requests/UserInvitation/InviteUserRequest.php create mode 100644 app/Http/Requests/UserInvitation/ListInvitationRequest.php create mode 100644 app/Http/Requests/V1/Sale/SendStatementRequest.php create mode 100644 app/Models/NotificationSetting.php create mode 100644 app/Models/UserInvitation.php create mode 100644 app/Services/AccountService.php create mode 100644 app/Services/NotificationSettingService.php create mode 100644 app/Services/UserInvitationService.php create mode 100644 app/Swagger/v1/AccountApi.php create mode 100644 app/Swagger/v1/NotificationSettingApi.php create mode 100644 app/Swagger/v1/UserInvitationApi.php create mode 100644 database/migrations/2025_12_19_100001_create_user_invitations_table.php create mode 100644 database/migrations/2025_12_19_100002_create_notification_settings_table.php diff --git a/app/Console/Commands/FcmTestCommand.php b/app/Console/Commands/FcmTestCommand.php index 296aa1a..c8ae248 100644 --- a/app/Console/Commands/FcmTestCommand.php +++ b/app/Console/Commands/FcmTestCommand.php @@ -131,4 +131,4 @@ public function handle(): int return self::SUCCESS; } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/V1/AccountController.php b/app/Http/Controllers/Api/V1/AccountController.php new file mode 100644 index 0000000..b825d11 --- /dev/null +++ b/app/Http/Controllers/Api/V1/AccountController.php @@ -0,0 +1,61 @@ + $this->service->withdraw($request->validated()), + __('message.account.withdrawn') + ); + } + + /** + * 사용 중지 (특정 테넌트에서만 탈퇴) + */ + public function suspend(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->suspend(), + __('message.account.suspended') + ); + } + + /** + * 약관 동의 정보 조회 + */ + public function getAgreements(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->getAgreements(), + __('message.fetched') + ); + } + + /** + * 약관 동의 정보 수정 + */ + public function updateAgreements(UpdateAgreementsRequest $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->updateAgreements($request->validated()), + __('message.updated') + ); + } +} diff --git a/app/Http/Controllers/Api/V1/NotificationSettingController.php b/app/Http/Controllers/Api/V1/NotificationSettingController.php new file mode 100644 index 0000000..8f7873c --- /dev/null +++ b/app/Http/Controllers/Api/V1/NotificationSettingController.php @@ -0,0 +1,50 @@ + $this->service->getSettings(), + __('message.fetched') + ); + } + + /** + * 알림 설정 업데이트 (단일) + */ + public function update(UpdateSettingRequest $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->updateSetting($request->validated()), + __('message.updated') + ); + } + + /** + * 알림 설정 일괄 업데이트 + */ + public function bulkUpdate(BulkUpdateSettingRequest $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->bulkUpdateSettings($request->validated()['settings']), + __('message.bulk_upsert') + ); + } +} diff --git a/app/Http/Controllers/Api/V1/PushNotificationController.php b/app/Http/Controllers/Api/V1/PushNotificationController.php index f461e2e..8f71413 100644 --- a/app/Http/Controllers/Api/V1/PushNotificationController.php +++ b/app/Http/Controllers/Api/V1/PushNotificationController.php @@ -2,11 +2,11 @@ namespace App\Http\Controllers\Api\V1; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\Push\RegisterTokenRequest; use App\Http\Requests\Push\UpdateSettingsRequest; use App\Services\PushNotificationService; -use App\Helpers\ApiResponse; use Illuminate\Http\Request; class PushNotificationController extends Controller diff --git a/app/Http/Controllers/Api/V1/SaleController.php b/app/Http/Controllers/Api/V1/SaleController.php index 9a1bcd1..942b6b3 100644 --- a/app/Http/Controllers/Api/V1/SaleController.php +++ b/app/Http/Controllers/Api/V1/SaleController.php @@ -4,6 +4,7 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\V1\Sale\SendStatementRequest; use App\Http\Requests\V1\Sale\StoreSaleRequest; use App\Http\Requests\V1\Sale\UpdateSaleRequest; use App\Services\SaleService; @@ -103,4 +104,34 @@ public function summary(Request $request) return ApiResponse::success($summary, __('message.fetched')); } + + /** + * 거래명세서 조회 + */ + public function getStatement(int $id) + { + $statement = $this->service->getStatement($id); + + return ApiResponse::success($statement, __('message.fetched')); + } + + /** + * 거래명세서 발행 + */ + public function issueStatement(int $id) + { + $result = $this->service->issueStatement($id); + + return ApiResponse::success($result, __('message.sale.statement_issued')); + } + + /** + * 거래명세서 이메일 발송 + */ + public function sendStatement(int $id, SendStatementRequest $request) + { + $result = $this->service->sendStatement($id, $request->validated()); + + return ApiResponse::success($result, __('message.sale.statement_sent')); + } } diff --git a/app/Http/Controllers/Api/V1/UserInvitationController.php b/app/Http/Controllers/Api/V1/UserInvitationController.php new file mode 100644 index 0000000..f714be4 --- /dev/null +++ b/app/Http/Controllers/Api/V1/UserInvitationController.php @@ -0,0 +1,73 @@ + $this->service->index($request->validated()), + __('message.fetched') + ); + } + + /** + * 사용자 초대 발송 + */ + public function invite(InviteUserRequest $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->invite($request->validated()), + __('message.invitation.sent') + ); + } + + /** + * 초대 수락 + */ + public function accept(string $token, AcceptInvitationRequest $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->accept($token, $request->validated()), + __('message.invitation.accepted') + ); + } + + /** + * 초대 취소 + */ + public function cancel(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->cancel($id), + __('message.invitation.cancelled') + ); + } + + /** + * 초대 재발송 + */ + public function resend(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->resend($id), + __('message.invitation.resent') + ); + } +} diff --git a/app/Http/Requests/Account/UpdateAgreementsRequest.php b/app/Http/Requests/Account/UpdateAgreementsRequest.php new file mode 100644 index 0000000..1e12b5a --- /dev/null +++ b/app/Http/Requests/Account/UpdateAgreementsRequest.php @@ -0,0 +1,36 @@ + ['required', 'array', 'min:1'], + 'agreements.*.type' => ['required', 'string', Rule::in(array_keys(AccountService::getAgreementTypes()))], + 'agreements.*.agreed' => ['required', 'boolean'], + ]; + } + + public function messages(): array + { + return [ + 'agreements.required' => __('validation.required', ['attribute' => '약관 동의 정보']), + 'agreements.array' => __('validation.array', ['attribute' => '약관 동의 정보']), + 'agreements.*.type.required' => __('validation.required', ['attribute' => '약관 유형']), + 'agreements.*.type.in' => __('validation.in', ['attribute' => '약관 유형']), + 'agreements.*.agreed.required' => __('validation.required', ['attribute' => '동의 여부']), + 'agreements.*.agreed.boolean' => __('validation.boolean', ['attribute' => '동의 여부']), + ]; + } +} diff --git a/app/Http/Requests/Account/WithdrawRequest.php b/app/Http/Requests/Account/WithdrawRequest.php new file mode 100644 index 0000000..f0026d6 --- /dev/null +++ b/app/Http/Requests/Account/WithdrawRequest.php @@ -0,0 +1,32 @@ + ['required', 'string'], + 'reason' => ['nullable', 'string', Rule::in(array_keys(AccountService::getWithdrawalReasons()))], + 'detail' => ['nullable', 'string', 'max:500'], + ]; + } + + public function messages(): array + { + return [ + 'password.required' => __('validation.required', ['attribute' => '비밀번호']), + 'reason.in' => __('validation.in', ['attribute' => '탈퇴 사유']), + ]; + } +} diff --git a/app/Http/Requests/NotificationSetting/BulkUpdateSettingRequest.php b/app/Http/Requests/NotificationSetting/BulkUpdateSettingRequest.php new file mode 100644 index 0000000..c8dd2fa --- /dev/null +++ b/app/Http/Requests/NotificationSetting/BulkUpdateSettingRequest.php @@ -0,0 +1,39 @@ + ['required', 'array', 'min:1'], + 'settings.*.notification_type' => ['required', 'string', Rule::in(NotificationSetting::getAllTypes())], + 'settings.*.push_enabled' => ['nullable', 'boolean'], + 'settings.*.email_enabled' => ['nullable', 'boolean'], + 'settings.*.sms_enabled' => ['nullable', 'boolean'], + 'settings.*.in_app_enabled' => ['nullable', 'boolean'], + 'settings.*.kakao_enabled' => ['nullable', 'boolean'], + 'settings.*.settings' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'settings.required' => __('validation.required', ['attribute' => '설정']), + 'settings.array' => __('validation.array', ['attribute' => '설정']), + 'settings.*.notification_type.required' => __('validation.required', ['attribute' => '알림 유형']), + 'settings.*.notification_type.in' => __('validation.in', ['attribute' => '알림 유형']), + ]; + } +} diff --git a/app/Http/Requests/NotificationSetting/UpdateSettingRequest.php b/app/Http/Requests/NotificationSetting/UpdateSettingRequest.php new file mode 100644 index 0000000..585904f --- /dev/null +++ b/app/Http/Requests/NotificationSetting/UpdateSettingRequest.php @@ -0,0 +1,36 @@ + ['required', 'string', Rule::in(NotificationSetting::getAllTypes())], + 'push_enabled' => ['nullable', 'boolean'], + 'email_enabled' => ['nullable', 'boolean'], + 'sms_enabled' => ['nullable', 'boolean'], + 'in_app_enabled' => ['nullable', 'boolean'], + 'kakao_enabled' => ['nullable', 'boolean'], + 'settings' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'notification_type.required' => __('validation.required', ['attribute' => '알림 유형']), + 'notification_type.in' => __('validation.in', ['attribute' => '알림 유형']), + ]; + } +} diff --git a/app/Http/Requests/UserInvitation/AcceptInvitationRequest.php b/app/Http/Requests/UserInvitation/AcceptInvitationRequest.php new file mode 100644 index 0000000..bb8c4ff --- /dev/null +++ b/app/Http/Requests/UserInvitation/AcceptInvitationRequest.php @@ -0,0 +1,32 @@ + ['required', 'string', 'max:100'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + 'phone' => ['nullable', 'string', 'max:20'], + ]; + } + + public function messages(): array + { + return [ + 'name.required' => __('validation.required', ['attribute' => '이름']), + 'password.required' => __('validation.required', ['attribute' => '비밀번호']), + 'password.min' => __('validation.min.string', ['attribute' => '비밀번호', 'min' => 8]), + 'password.confirmed' => __('validation.confirmed', ['attribute' => '비밀번호']), + ]; + } +} diff --git a/app/Http/Requests/UserInvitation/InviteUserRequest.php b/app/Http/Requests/UserInvitation/InviteUserRequest.php new file mode 100644 index 0000000..9757892 --- /dev/null +++ b/app/Http/Requests/UserInvitation/InviteUserRequest.php @@ -0,0 +1,32 @@ + ['required', 'email', 'max:255'], + 'role_id' => ['nullable', 'integer', 'exists:roles,id'], + 'message' => ['nullable', 'string', 'max:1000'], + 'expires_days' => ['nullable', 'integer', 'min:1', 'max:30'], + ]; + } + + public function messages(): array + { + return [ + 'email.required' => __('validation.required', ['attribute' => '이메일']), + 'email.email' => __('validation.email', ['attribute' => '이메일']), + 'role_id.exists' => __('validation.exists', ['attribute' => '역할']), + ]; + } +} diff --git a/app/Http/Requests/UserInvitation/ListInvitationRequest.php b/app/Http/Requests/UserInvitation/ListInvitationRequest.php new file mode 100644 index 0000000..fc23c50 --- /dev/null +++ b/app/Http/Requests/UserInvitation/ListInvitationRequest.php @@ -0,0 +1,25 @@ + ['nullable', 'string', 'in:pending,accepted,expired,cancelled'], + 'search' => ['nullable', 'string', 'max:255'], + 'sort_by' => ['nullable', 'string', 'in:created_at,expires_at,email'], + 'sort_dir' => ['nullable', 'string', 'in:asc,desc'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + 'page' => ['nullable', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/V1/Sale/SendStatementRequest.php b/app/Http/Requests/V1/Sale/SendStatementRequest.php new file mode 100644 index 0000000..5c43074 --- /dev/null +++ b/app/Http/Requests/V1/Sale/SendStatementRequest.php @@ -0,0 +1,16 @@ + ['nullable', 'string', 'email', 'max:255'], + 'message' => ['nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/app/Models/NotificationSetting.php b/app/Models/NotificationSetting.php new file mode 100644 index 0000000..bed7dc6 --- /dev/null +++ b/app/Models/NotificationSetting.php @@ -0,0 +1,226 @@ + 'boolean', + 'email_enabled' => 'boolean', + 'sms_enabled' => 'boolean', + 'in_app_enabled' => 'boolean', + 'kakao_enabled' => 'boolean', + 'settings' => 'array', + ]; + + /** + * 알림 유형 상수 + */ + public const TYPE_APPROVAL = 'approval'; // 결재 + + public const TYPE_ORDER = 'order'; // 수주 + + public const TYPE_DEPOSIT = 'deposit'; // 입금 + + public const TYPE_WITHDRAWAL = 'withdrawal'; // 출금 + + public const TYPE_NOTICE = 'notice'; // 공지사항 + + public const TYPE_SYSTEM = 'system'; // 시스템 + + public const TYPE_MARKETING = 'marketing'; // 마케팅 + + public const TYPE_SECURITY = 'security'; // 보안 (로그인, 비밀번호 변경 등) + + /** + * 알림 채널 상수 + */ + public const CHANNEL_PUSH = 'push'; + + public const CHANNEL_EMAIL = 'email'; + + public const CHANNEL_SMS = 'sms'; + + public const CHANNEL_IN_APP = 'in_app'; + + public const CHANNEL_KAKAO = 'kakao'; + + /** + * 모든 알림 유형 반환 + */ + public static function getAllTypes(): array + { + return [ + self::TYPE_APPROVAL, + self::TYPE_ORDER, + self::TYPE_DEPOSIT, + self::TYPE_WITHDRAWAL, + self::TYPE_NOTICE, + self::TYPE_SYSTEM, + self::TYPE_MARKETING, + self::TYPE_SECURITY, + ]; + } + + /** + * 모든 채널 반환 + */ + public static function getAllChannels(): array + { + return [ + self::CHANNEL_PUSH, + self::CHANNEL_EMAIL, + self::CHANNEL_SMS, + self::CHANNEL_IN_APP, + self::CHANNEL_KAKAO, + ]; + } + + /** + * 알림 유형별 기본 설정 반환 + */ + public static function getDefaultSettings(string $type): array + { + // 보안 관련 알림은 이메일 기본 활성화 + if ($type === self::TYPE_SECURITY) { + return [ + 'push_enabled' => true, + 'email_enabled' => true, + 'sms_enabled' => false, + 'in_app_enabled' => true, + 'kakao_enabled' => false, + ]; + } + + // 마케팅은 기본 비활성화 + if ($type === self::TYPE_MARKETING) { + return [ + 'push_enabled' => false, + 'email_enabled' => false, + 'sms_enabled' => false, + 'in_app_enabled' => false, + 'kakao_enabled' => false, + ]; + } + + // 기타 알림은 푸시, 인앱만 기본 활성화 + return [ + 'push_enabled' => true, + 'email_enabled' => false, + 'sms_enabled' => false, + 'in_app_enabled' => true, + 'kakao_enabled' => false, + ]; + } + + /** + * 알림 유형 레이블 + */ + public static function getTypeLabels(): array + { + return [ + self::TYPE_APPROVAL => '전자결재', + self::TYPE_ORDER => '수주', + self::TYPE_DEPOSIT => '입금', + self::TYPE_WITHDRAWAL => '출금', + self::TYPE_NOTICE => '공지사항', + self::TYPE_SYSTEM => '시스템', + self::TYPE_MARKETING => '마케팅', + self::TYPE_SECURITY => '보안', + ]; + } + + /** + * 채널 레이블 + */ + public static function getChannelLabels(): array + { + return [ + self::CHANNEL_PUSH => '푸시 알림', + self::CHANNEL_EMAIL => '이메일', + self::CHANNEL_SMS => 'SMS', + self::CHANNEL_IN_APP => '인앱 알림', + self::CHANNEL_KAKAO => '카카오 알림톡', + ]; + } + + /** + * 사용자 관계 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 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); + } + + /** + * 특정 채널이 활성화되어 있는지 확인 + */ + public function isChannelEnabled(string $channel): bool + { + return match ($channel) { + self::CHANNEL_PUSH => $this->push_enabled, + self::CHANNEL_EMAIL => $this->email_enabled, + self::CHANNEL_SMS => $this->sms_enabled, + self::CHANNEL_IN_APP => $this->in_app_enabled, + self::CHANNEL_KAKAO => $this->kakao_enabled, + default => false, + }; + } + + /** + * 채널 설정 업데이트 + */ + public function setChannelEnabled(string $channel, bool $enabled): void + { + match ($channel) { + self::CHANNEL_PUSH => $this->push_enabled = $enabled, + self::CHANNEL_EMAIL => $this->email_enabled = $enabled, + self::CHANNEL_SMS => $this->sms_enabled = $enabled, + self::CHANNEL_IN_APP => $this->in_app_enabled = $enabled, + self::CHANNEL_KAKAO => $this->kakao_enabled = $enabled, + default => null, + }; + } +} diff --git a/app/Models/UserInvitation.php b/app/Models/UserInvitation.php new file mode 100644 index 0000000..799f802 --- /dev/null +++ b/app/Models/UserInvitation.php @@ -0,0 +1,176 @@ + 'datetime', + 'accepted_at' => 'datetime', + ]; + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 역할 관계 + */ + public function role(): BelongsTo + { + return $this->belongsTo(Role::class); + } + + /** + * 초대한 사용자 관계 + */ + public function inviter(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by'); + } + + /** + * 토큰 생성 + */ + public static function generateToken(): string + { + return Str::random(64); + } + + /** + * 만료 일시 계산 + */ + public static function calculateExpiresAt(int $days = self::DEFAULT_EXPIRES_DAYS): \DateTime + { + return now()->addDays($days); + } + + /** + * 만료 여부 확인 + */ + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + /** + * 수락 가능 여부 확인 + */ + public function canAccept(): bool + { + return $this->status === self::STATUS_PENDING && ! $this->isExpired(); + } + + /** + * 취소 가능 여부 확인 + */ + public function canCancel(): bool + { + return $this->status === self::STATUS_PENDING; + } + + /** + * 초대 수락 처리 + */ + public function markAsAccepted(): void + { + $this->status = self::STATUS_ACCEPTED; + $this->accepted_at = now(); + $this->save(); + } + + /** + * 초대 만료 처리 + */ + public function markAsExpired(): void + { + $this->status = self::STATUS_EXPIRED; + $this->save(); + } + + /** + * 초대 취소 처리 + */ + public function markAsCancelled(): void + { + $this->status = self::STATUS_CANCELLED; + $this->save(); + } + + /** + * Scope: 대기 중인 초대만 + */ + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + /** + * Scope: 만료된 초대 (상태 업데이트 대상) + */ + public function scopeExpiredPending($query) + { + return $query->where('status', self::STATUS_PENDING) + ->where('expires_at', '<', now()); + } + + /** + * Scope: 특정 이메일 초대 + */ + public function scopeForEmail($query, string $email) + { + return $query->where('email', $email); + } + + /** + * 상태 라벨 반환 + */ + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => '대기중', + self::STATUS_ACCEPTED => '수락됨', + self::STATUS_EXPIRED => '만료됨', + self::STATUS_CANCELLED => '취소됨', + default => $this->status, + }; + } +} diff --git a/app/Services/AccountService.php b/app/Services/AccountService.php new file mode 100644 index 0000000..7d9bbfb --- /dev/null +++ b/app/Services/AccountService.php @@ -0,0 +1,240 @@ +apiUserId(); + $user = User::find($userId); + + if (! $user) { + throw new NotFoundHttpException(__('error.not_found_resource', ['resource' => '사용자'])); + } + + // 비밀번호 확인 + if (! password_verify($data['password'], $user->password)) { + throw new BadRequestHttpException(__('error.account.invalid_password')); + } + + return DB::transaction(function () use ($user, $data) { + // 1. 모든 테넌트 연결 해제 (soft delete) + UserTenant::where('user_id', $user->id)->delete(); + + // 2. 탈퇴 사유 저장 (options에 기록) + $options = $user->options ?? []; + $options['withdrawal'] = [ + 'reason' => $data['reason'] ?? null, + 'detail' => $data['detail'] ?? null, + 'withdrawn_at' => now()->toIso8601String(), + ]; + $user->options = $options; + $user->save(); + + // 3. 사용자 soft delete + $user->delete(); + + // 4. 토큰 삭제 + $user->tokens()->delete(); + + return [ + 'withdrawn_at' => now()->toIso8601String(), + ]; + }); + } + + /** + * 사용 중지 (특정 테넌트에서만 탈퇴) + */ + public function suspend(): array + { + $userId = $this->apiUserId(); + $tenantId = $this->tenantId(); + + $userTenant = UserTenant::where('user_id', $userId) + ->where('tenant_id', $tenantId) + ->first(); + + if (! $userTenant) { + throw new NotFoundHttpException(__('error.account.tenant_membership_not_found')); + } + + return DB::transaction(function () use ($userTenant, $userId, $tenantId) { + // 1. 현재 테넌트에서 비활성화 + $userTenant->is_active = false; + $userTenant->left_at = now(); + $userTenant->save(); + + // 2. 다른 활성 테넌트가 있으면 기본 테넌트 변경 + $otherActiveTenant = UserTenant::where('user_id', $userId) + ->where('tenant_id', '!=', $tenantId) + ->where('is_active', true) + ->first(); + + if ($otherActiveTenant) { + // 다른 테넌트로 기본 변경 + UserTenant::where('user_id', $userId)->update(['is_default' => false]); + $otherActiveTenant->is_default = true; + $otherActiveTenant->save(); + + return [ + 'suspended' => true, + 'new_default_tenant_id' => $otherActiveTenant->tenant_id, + ]; + } + + return [ + 'suspended' => true, + 'new_default_tenant_id' => null, + ]; + }); + } + + /** + * 약관 동의 정보 조회 + */ + public function getAgreements(): array + { + $userId = $this->apiUserId(); + $user = User::find($userId); + + if (! $user) { + throw new NotFoundHttpException(__('error.not_found_resource', ['resource' => '사용자'])); + } + + $options = $user->options ?? []; + $agreements = $options['agreements'] ?? self::getDefaultAgreements(); + + return [ + 'agreements' => $agreements, + 'types' => self::getAgreementTypes(), + ]; + } + + /** + * 약관 동의 정보 수정 + */ + public function updateAgreements(array $data): array + { + $userId = $this->apiUserId(); + $user = User::find($userId); + + if (! $user) { + throw new NotFoundHttpException(__('error.not_found_resource', ['resource' => '사용자'])); + } + + $options = $user->options ?? []; + $currentAgreements = $options['agreements'] ?? self::getDefaultAgreements(); + + // 수신 데이터로 업데이트 + foreach ($data['agreements'] as $agreement) { + $type = $agreement['type']; + if (isset($currentAgreements[$type])) { + $currentAgreements[$type]['agreed'] = $agreement['agreed']; + $currentAgreements[$type]['agreed_at'] = $agreement['agreed'] + ? now()->toIso8601String() + : null; + } + } + + $options['agreements'] = $currentAgreements; + $user->options = $options; + $user->save(); + + return [ + 'agreements' => $currentAgreements, + ]; + } + + /** + * 기본 약관 동의 항목 + */ + public static function getDefaultAgreements(): array + { + return [ + 'terms' => [ + 'type' => 'terms', + 'label' => '이용약관', + 'required' => true, + 'agreed' => false, + 'agreed_at' => null, + ], + 'privacy' => [ + 'type' => 'privacy', + 'label' => '개인정보 처리방침', + 'required' => true, + 'agreed' => false, + 'agreed_at' => null, + ], + 'marketing' => [ + 'type' => 'marketing', + 'label' => '마케팅 정보 수신', + 'required' => false, + 'agreed' => false, + 'agreed_at' => null, + ], + 'push' => [ + 'type' => 'push', + 'label' => '푸시 알림 수신', + 'required' => false, + 'agreed' => false, + 'agreed_at' => null, + ], + 'email' => [ + 'type' => 'email', + 'label' => '이메일 수신', + 'required' => false, + 'agreed' => false, + 'agreed_at' => null, + ], + 'sms' => [ + 'type' => 'sms', + 'label' => 'SMS 수신', + 'required' => false, + 'agreed' => false, + 'agreed_at' => null, + ], + ]; + } + + /** + * 약관 유형 레이블 + */ + public static function getAgreementTypes(): array + { + return [ + 'terms' => '이용약관', + 'privacy' => '개인정보 처리방침', + 'marketing' => '마케팅 정보 수신', + 'push' => '푸시 알림 수신', + 'email' => '이메일 수신', + 'sms' => 'SMS 수신', + ]; + } + + /** + * 탈퇴 사유 목록 + */ + public static function getWithdrawalReasons(): array + { + return [ + 'not_using' => '서비스를 더 이상 사용하지 않음', + 'difficult' => '사용하기 어려움', + 'alternative' => '다른 서비스 이용', + 'privacy' => '개인정보 보호 우려', + 'other' => '기타', + ]; + } +} diff --git a/app/Services/NotificationSettingService.php b/app/Services/NotificationSettingService.php new file mode 100644 index 0000000..01c6216 --- /dev/null +++ b/app/Services/NotificationSettingService.php @@ -0,0 +1,180 @@ +tenantId(); + $userId = $this->apiUserId(); + + $settings = NotificationSetting::where('tenant_id', $tenantId) + ->forUser($userId) + ->get() + ->keyBy('notification_type'); + + // 모든 알림 유형에 대한 설정 반환 (없으면 기본값) + $result = []; + foreach (NotificationSetting::getAllTypes() as $type) { + if ($settings->has($type)) { + $setting = $settings->get($type); + $result[$type] = [ + 'notification_type' => $type, + 'label' => NotificationSetting::getTypeLabels()[$type] ?? $type, + 'push_enabled' => $setting->push_enabled, + 'email_enabled' => $setting->email_enabled, + 'sms_enabled' => $setting->sms_enabled, + 'in_app_enabled' => $setting->in_app_enabled, + 'kakao_enabled' => $setting->kakao_enabled, + 'settings' => $setting->settings, + ]; + } else { + $defaults = NotificationSetting::getDefaultSettings($type); + $result[$type] = array_merge([ + 'notification_type' => $type, + 'label' => NotificationSetting::getTypeLabels()[$type] ?? $type, + 'settings' => null, + ], $defaults); + } + } + + return [ + 'settings' => $result, + 'types' => NotificationSetting::getTypeLabels(), + 'channels' => NotificationSetting::getChannelLabels(), + ]; + } + + /** + * 특정 알림 유형 설정 업데이트 + */ + public function updateSetting(array $data): NotificationSetting + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $notificationType = $data['notification_type']; + + // 알림 유형 유효성 검증 + if (! in_array($notificationType, NotificationSetting::getAllTypes(), true)) { + throw new \InvalidArgumentException(__('error.notification.invalid_type')); + } + + return NotificationSetting::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'notification_type' => $notificationType, + ], + [ + 'push_enabled' => $data['push_enabled'] ?? true, + 'email_enabled' => $data['email_enabled'] ?? false, + 'sms_enabled' => $data['sms_enabled'] ?? false, + 'in_app_enabled' => $data['in_app_enabled'] ?? true, + 'kakao_enabled' => $data['kakao_enabled'] ?? false, + 'settings' => $data['settings'] ?? null, + ] + ); + } + + /** + * 알림 설정 일괄 업데이트 + */ + public function bulkUpdateSettings(array $settings): Collection + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($tenantId, $userId, $settings) { + $updated = collect(); + + foreach ($settings as $setting) { + $notificationType = $setting['notification_type']; + + // 알림 유형 유효성 검증 + if (! in_array($notificationType, NotificationSetting::getAllTypes(), true)) { + continue; + } + + $record = NotificationSetting::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'notification_type' => $notificationType, + ], + [ + 'push_enabled' => $setting['push_enabled'] ?? true, + 'email_enabled' => $setting['email_enabled'] ?? false, + 'sms_enabled' => $setting['sms_enabled'] ?? false, + 'in_app_enabled' => $setting['in_app_enabled'] ?? true, + 'kakao_enabled' => $setting['kakao_enabled'] ?? false, + 'settings' => $setting['settings'] ?? null, + ] + ); + + $updated->push($record); + } + + return $updated; + }); + } + + /** + * 기본 알림 설정 초기화 + */ + public function initializeDefaultSettings(): void + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + foreach (NotificationSetting::getAllTypes() as $type) { + $defaults = NotificationSetting::getDefaultSettings($type); + + NotificationSetting::firstOrCreate( + [ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'notification_type' => $type, + ], + $defaults + ); + } + } + + /** + * 특정 알림 유형에서 특정 채널이 활성화되어 있는지 확인 + */ + public function isChannelEnabled(int $userId, string $notificationType, string $channel): bool + { + $tenantId = $this->tenantId(); + + $setting = NotificationSetting::where('tenant_id', $tenantId) + ->forUser($userId) + ->ofType($notificationType) + ->first(); + + if (! $setting) { + // 기본 설정 반환 + $defaults = NotificationSetting::getDefaultSettings($notificationType); + + return match ($channel) { + NotificationSetting::CHANNEL_PUSH => $defaults['push_enabled'], + NotificationSetting::CHANNEL_EMAIL => $defaults['email_enabled'], + NotificationSetting::CHANNEL_SMS => $defaults['sms_enabled'], + NotificationSetting::CHANNEL_IN_APP => $defaults['in_app_enabled'], + NotificationSetting::CHANNEL_KAKAO => $defaults['kakao_enabled'], + default => false, + }; + } + + return $setting->isChannelEnabled($channel); + } +} diff --git a/app/Services/SaleService.php b/app/Services/SaleService.php index 8ec8ea6..985a3da 100644 --- a/app/Services/SaleService.php +++ b/app/Services/SaleService.php @@ -275,4 +275,155 @@ private function generateSaleNumber(int $tenantId, string $saleDate): string return $prefix.str_pad($newSeq, 4, '0', STR_PAD_LEFT); } + + /** + * 거래명세서 조회 + */ + public function getStatement(int $id): array + { + $tenantId = $this->tenantId(); + + $sale = Sale::query() + ->where('tenant_id', $tenantId) + ->with(['client', 'deposit', 'creator:id,name']) + ->findOrFail($id); + + // 거래명세서 데이터 구성 + return [ + 'statement_number' => $this->generateStatementNumber($sale), + 'issued_at' => $sale->statement_issued_at, + 'sale' => $sale, + 'seller' => $this->getSellerInfo($tenantId), + 'buyer' => $this->getBuyerInfo($sale->client), + 'items' => $this->getSaleItems($sale), + 'summary' => [ + 'supply_amount' => $sale->supply_amount, + 'tax_amount' => $sale->tax_amount, + 'total_amount' => $sale->total_amount, + ], + ]; + } + + /** + * 거래명세서 발행 + */ + public function issueStatement(int $id): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $sale = Sale::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 확정된 매출만 거래명세서 발행 가능 + if ($sale->status !== 'confirmed') { + throw new \Exception(__('error.sale.statement_requires_confirmed')); + } + + // 발행 시간 기록 + $sale->statement_issued_at = now(); + $sale->statement_issued_by = $userId; + $sale->save(); + + return [ + 'statement_number' => $this->generateStatementNumber($sale), + 'issued_at' => $sale->statement_issued_at->toIso8601String(), + ]; + }); + } + + /** + * 거래명세서 이메일 발송 + */ + public function sendStatement(int $id, array $data): array + { + $tenantId = $this->tenantId(); + + $sale = Sale::query() + ->where('tenant_id', $tenantId) + ->with(['client']) + ->findOrFail($id); + + // 수신자 이메일 확인 + $recipientEmail = $data['email'] ?? $sale->client?->email; + if (empty($recipientEmail)) { + throw new \Exception(__('error.sale.recipient_email_required')); + } + + // TODO: 실제 이메일 발송 로직 구현 + // Mail::to($recipientEmail)->send(new SaleStatementMail($sale, $data['message'] ?? null)); + + return [ + 'sent_to' => $recipientEmail, + 'sent_at' => now()->toIso8601String(), + 'statement_number' => $this->generateStatementNumber($sale), + ]; + } + + /** + * 거래명세서 번호 생성 + */ + private function generateStatementNumber(Sale $sale): string + { + return 'ST'.$sale->sale_number; + } + + /** + * 판매자 정보 조회 + */ + private function getSellerInfo(int $tenantId): array + { + $tenant = \App\Models\Tenants\Tenant::find($tenantId); + + return [ + 'name' => $tenant->name ?? '', + 'business_number' => $tenant->business_number ?? '', + 'representative' => $tenant->representative ?? '', + 'address' => $tenant->address ?? '', + 'tel' => $tenant->tel ?? '', + 'fax' => $tenant->fax ?? '', + 'email' => $tenant->email ?? '', + ]; + } + + /** + * 구매자 정보 조회 + */ + private function getBuyerInfo($client): array + { + if (! $client) { + return []; + } + + return [ + 'name' => $client->name ?? '', + 'business_number' => $client->business_number ?? '', + 'representative' => $client->representative ?? '', + 'address' => $client->address ?? '', + 'tel' => $client->tel ?? '', + 'fax' => $client->fax ?? '', + 'email' => $client->email ?? '', + ]; + } + + /** + * 매출 품목 조회 (향후 확장) + */ + private function getSaleItems(Sale $sale): array + { + // TODO: 매출 품목 테이블이 있다면 조회 + // 현재는 기본 매출 정보만 반환 + return [ + [ + 'description' => $sale->description ?? '매출', + 'quantity' => 1, + 'unit_price' => $sale->supply_amount, + 'supply_amount' => $sale->supply_amount, + 'tax_amount' => $sale->tax_amount, + 'total_amount' => $sale->total_amount, + ], + ]; + } } diff --git a/app/Services/UserInvitationService.php b/app/Services/UserInvitationService.php new file mode 100644 index 0000000..8d55e2f --- /dev/null +++ b/app/Services/UserInvitationService.php @@ -0,0 +1,230 @@ +tenantId(); + + $query = UserInvitation::query() + ->where('tenant_id', $tenantId) + ->with(['role:id,name', 'inviter:id,name']); + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 이메일 검색 + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('email', 'like', "%{$search}%"); + }); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'created_at'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + // 페이지네이션 + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 사용자 초대 발송 + */ + public function invite(array $data): UserInvitation + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + $email = $data['email']; + + // 이미 테넌트에 가입된 사용자인지 확인 + $existingUser = User::where('email', $email)->first(); + if ($existingUser) { + $existingMembership = UserTenant::where('tenant_id', $tenantId) + ->where('user_id', $existingUser->id) + ->whereNull('deleted_at') + ->exists(); + + if ($existingMembership) { + throw new BadRequestHttpException(__('error.invitation.already_member')); + } + } + + // 이미 대기 중인 초대가 있는지 확인 + $pendingInvitation = UserInvitation::where('tenant_id', $tenantId) + ->where('email', $email) + ->pending() + ->first(); + + if ($pendingInvitation) { + throw new BadRequestHttpException(__('error.invitation.already_pending')); + } + + // 초대 생성 + $invitation = new UserInvitation; + $invitation->tenant_id = $tenantId; + $invitation->email = $email; + $invitation->role_id = $data['role_id'] ?? null; + $invitation->message = $data['message'] ?? null; + $invitation->token = UserInvitation::generateToken(); + $invitation->status = UserInvitation::STATUS_PENDING; + $invitation->invited_by = $userId; + $invitation->expires_at = UserInvitation::calculateExpiresAt($data['expires_days'] ?? UserInvitation::DEFAULT_EXPIRES_DAYS); + $invitation->save(); + + // 초대 이메일 발송 (비동기 처리 권장) + $this->sendInvitationEmail($invitation); + + return $invitation->load(['role:id,name', 'inviter:id,name']); + }); + } + + /** + * 초대 수락 + */ + public function accept(string $token, array $userData): User + { + return DB::transaction(function () use ($token, $userData) { + // 토큰으로 초대 조회 (테넌트 스코프 제외) + $invitation = UserInvitation::withoutGlobalScopes() + ->where('token', $token) + ->first(); + + if (! $invitation) { + throw new NotFoundHttpException(__('error.invitation.not_found')); + } + + if (! $invitation->canAccept()) { + if ($invitation->isExpired()) { + $invitation->markAsExpired(); + throw new BadRequestHttpException(__('error.invitation.expired')); + } + throw new BadRequestHttpException(__('error.invitation.invalid_status')); + } + + // 기존 사용자 확인 또는 신규 생성 + $user = User::where('email', $invitation->email)->first(); + + if (! $user) { + // 신규 사용자 생성 + $user = new User; + $user->email = $invitation->email; + $user->name = $userData['name']; + $user->password = $userData['password']; + $user->phone = $userData['phone'] ?? null; + $user->save(); + } + + // 테넌트 멤버십 생성 + $userTenant = new UserTenant; + $userTenant->user_id = $user->id; + $userTenant->tenant_id = $invitation->tenant_id; + $userTenant->is_active = true; + $userTenant->is_default = ! $user->userTenants()->exists(); // 첫 테넌트면 기본으로 + $userTenant->joined_at = now(); + $userTenant->save(); + + // 역할 부여 (있는 경우) + if ($invitation->role_id) { + $user->assignRole($invitation->role->name); + } + + // 초대 수락 처리 + $invitation->markAsAccepted(); + + return $user; + }); + } + + /** + * 초대 취소 + */ + public function cancel(int $id): bool + { + $tenantId = $this->tenantId(); + + $invitation = UserInvitation::where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $invitation->canCancel()) { + throw new BadRequestHttpException(__('error.invitation.cannot_cancel')); + } + + $invitation->markAsCancelled(); + + return true; + } + + /** + * 초대 재발송 + */ + public function resend(int $id): UserInvitation + { + $tenantId = $this->tenantId(); + + $invitation = UserInvitation::where('tenant_id', $tenantId) + ->findOrFail($id); + + if ($invitation->status !== UserInvitation::STATUS_PENDING) { + throw new BadRequestHttpException(__('error.invitation.cannot_resend')); + } + + // 만료 기간 연장 및 토큰 재생성 + $invitation->token = UserInvitation::generateToken(); + $invitation->expires_at = UserInvitation::calculateExpiresAt(); + $invitation->save(); + + // 이메일 재발송 + $this->sendInvitationEmail($invitation); + + return $invitation->load(['role:id,name', 'inviter:id,name']); + } + + /** + * 만료된 초대 일괄 처리 (스케줄러용) + */ + public function expirePendingInvitations(): int + { + return UserInvitation::withoutGlobalScopes() + ->expiredPending() + ->update(['status' => UserInvitation::STATUS_EXPIRED]); + } + + /** + * 초대 이메일 발송 + */ + protected function sendInvitationEmail(UserInvitation $invitation): void + { + // TODO: 실제 메일 발송 로직 구현 + // Mail::to($invitation->email)->queue(new UserInvitationMail($invitation)); + + // 현재는 로그로 대체 + \Log::info('User invitation email sent', [ + 'email' => $invitation->email, + 'token' => $invitation->token, + 'tenant_id' => $invitation->tenant_id, + ]); + } +} diff --git a/app/Swagger/v1/AccountApi.php b/app/Swagger/v1/AccountApi.php new file mode 100644 index 0000000..2624436 --- /dev/null +++ b/app/Swagger/v1/AccountApi.php @@ -0,0 +1,214 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete()->comment('테넌트 ID'); + $table->string('email', 255)->comment('초대 대상 이메일'); + $table->foreignId('role_id')->nullable()->constrained('roles')->nullOnDelete()->comment('부여할 역할 ID'); + $table->text('message')->nullable()->comment('초대 메시지'); + $table->string('token', 64)->unique()->comment('초대 토큰'); + $table->string('status', 20)->default('pending')->index()->comment('상태 (pending/accepted/expired/cancelled)'); + $table->foreignId('invited_by')->constrained('users')->cascadeOnDelete()->comment('초대한 사용자 ID'); + $table->timestamp('expires_at')->comment('만료 일시'); + $table->timestamp('accepted_at')->nullable()->comment('수락 일시'); + $table->timestamps(); + + // 복합 인덱스: 테넌트별 이메일 검색 + $table->index(['tenant_id', 'email']); + // 복합 인덱스: 상태별 만료 일시 (스케줄러용) + $table->index(['status', 'expires_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_invitations'); + } +}; diff --git a/database/migrations/2025_12_19_100002_create_notification_settings_table.php b/database/migrations/2025_12_19_100002_create_notification_settings_table.php new file mode 100644 index 0000000..33fcaec --- /dev/null +++ b/database/migrations/2025_12_19_100002_create_notification_settings_table.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete()->comment('테넌트 ID'); + $table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('사용자 ID'); + + // 알림 유형별 채널 설정 + $table->string('notification_type', 50)->comment('알림 유형 (approval, order, deposit, withdrawal, notice, system 등)'); + + // 채널별 활성화 여부 + $table->boolean('push_enabled')->default(true)->comment('푸시 알림 활성화'); + $table->boolean('email_enabled')->default(true)->comment('이메일 알림 활성화'); + $table->boolean('sms_enabled')->default(false)->comment('SMS 알림 활성화'); + $table->boolean('in_app_enabled')->default(true)->comment('인앱 알림 활성화'); + $table->boolean('kakao_enabled')->default(false)->comment('카카오 알림톡 활성화'); + + // 추가 설정 + $table->json('settings')->nullable()->comment('추가 설정 (우선순위, 알림 시간대 등)'); + + $table->timestamps(); + + // 복합 유니크 인덱스 + $table->unique(['tenant_id', 'user_id', 'notification_type'], 'notification_settings_unique'); + $table->index(['user_id', 'notification_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notification_settings'); + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index 607ba6b..f478a92 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -260,4 +260,39 @@ 'invalid_signature' => '서명이 유효하지 않습니다.', 'token_issue_failed' => '토큰 발급에 실패했습니다.', ], + + // 사용자 초대 관련 + 'invitation' => [ + 'already_member' => '이미 테넌트에 등록된 사용자입니다.', + 'already_pending' => '대기 중인 초대가 이미 존재합니다.', + 'not_found' => '초대를 찾을 수 없습니다.', + 'expired' => '만료된 초대입니다.', + 'invalid_status' => '유효하지 않은 초대 상태입니다.', + 'cannot_cancel' => '취소할 수 없는 초대입니다.', + 'cannot_resend' => '재발송할 수 없는 초대입니다.', + ], + + // 알림 설정 관련 + 'notification_setting' => [ + 'not_found' => '알림 설정을 찾을 수 없습니다.', + 'invalid_type' => '유효하지 않은 알림 유형입니다.', + 'invalid_channel' => '유효하지 않은 알림 채널입니다.', + ], + + // 매출 관련 + 'sale' => [ + 'cannot_edit' => '확정된 매출은 수정할 수 없습니다.', + 'cannot_delete' => '확정된 매출은 삭제할 수 없습니다.', + 'cannot_confirm' => '확정할 수 없는 상태입니다.', + 'statement_requires_confirmed' => '확정된 매출만 거래명세서를 발행할 수 있습니다.', + 'recipient_email_required' => '수신자 이메일이 필요합니다.', + ], + + // 계정 관리 관련 + 'account' => [ + 'invalid_password' => '비밀번호가 일치하지 않습니다.', + 'tenant_membership_not_found' => '테넌트 멤버십 정보를 찾을 수 없습니다.', + 'already_withdrawn' => '이미 탈퇴한 계정입니다.', + 'cannot_withdraw' => '탈퇴할 수 없는 상태입니다.', + ], ]; diff --git a/lang/ko/message.php b/lang/ko/message.php index ce9c9eb..1fcfa67 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -270,6 +270,13 @@ 'purchase' => '매입', ], + // 매출 관리 + 'sale' => [ + 'confirmed' => '매출이 확정되었습니다.', + 'statement_issued' => '거래명세서가 발행되었습니다.', + 'statement_sent' => '거래명세서가 발송되었습니다.', + ], + // 급여 관리 'payroll' => [ 'fetched' => '급여를 조회했습니다.', @@ -316,4 +323,27 @@ 'internal' => [ 'token_exchanged' => '토큰이 발급되었습니다.', ], + + // 사용자 초대 관리 + 'invitation' => [ + 'sent' => '초대가 발송되었습니다.', + 'accepted' => '초대가 수락되었습니다.', + 'cancelled' => '초대가 취소되었습니다.', + 'resent' => '초대가 재발송되었습니다.', + ], + + // 알림 설정 관리 + 'notification_setting' => [ + 'fetched' => '알림 설정을 조회했습니다.', + 'updated' => '알림 설정이 수정되었습니다.', + 'bulk_updated' => '알림 설정이 일괄 저장되었습니다.', + 'initialized' => '알림 설정이 초기화되었습니다.', + ], + + // 계정 관리 + 'account' => [ + 'withdrawn' => '회원 탈퇴가 완료되었습니다.', + 'suspended' => '사용 중지가 완료되었습니다.', + 'agreements_updated' => '약관 동의 정보가 수정되었습니다.', + ], ]; diff --git a/routes/api.php b/routes/api.php index 9406cf6..9b789f2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ name('v1.users.me.tenants.index'); // 내 테넌트 목록 Route::patch('me/tenants/switch', [UserController::class, 'switchTenant'])->name('v1.users.me.tenants.switch'); // 활성 테넌트 전환 + + // 사용자 초대 API + Route::get('invitations', [UserInvitationController::class, 'index'])->name('v1.users.invitations.index'); // 초대 목록 + Route::post('invite', [UserInvitationController::class, 'invite'])->name('v1.users.invite'); // 초대 발송 + Route::post('invitations/{token}/accept', [UserInvitationController::class, 'accept'])->name('v1.users.invitations.accept'); // 초대 수락 + Route::delete('invitations/{id}', [UserInvitationController::class, 'cancel'])->whereNumber('id')->name('v1.users.invitations.cancel'); // 초대 취소 + Route::post('invitations/{id}/resend', [UserInvitationController::class, 'resend'])->whereNumber('id')->name('v1.users.invitations.resend'); // 초대 재발송 + + // 알림 설정 API (auth:sanctum 필수) + Route::middleware('auth:sanctum')->group(function () { + Route::get('me/notification-settings', [NotificationSettingController::class, 'index'])->name('v1.users.me.notification-settings.index'); // 알림 설정 조회 + Route::put('me/notification-settings', [NotificationSettingController::class, 'update'])->name('v1.users.me.notification-settings.update'); // 알림 설정 수정 + Route::put('me/notification-settings/bulk', [NotificationSettingController::class, 'bulkUpdate'])->name('v1.users.me.notification-settings.bulk'); // 알림 일괄 설정 + }); + }); + + // Account API (계정 관리 - 탈퇴, 사용중지, 약관동의) + Route::prefix('account')->middleware('auth:sanctum')->group(function () { + Route::post('withdraw', [AccountController::class, 'withdraw'])->name('v1.account.withdraw'); // 회원 탈퇴 + Route::post('suspend', [AccountController::class, 'suspend'])->name('v1.account.suspend'); // 사용 중지 (테넌트) + Route::get('agreements', [AccountController::class, 'getAgreements'])->name('v1.account.agreements.index'); // 약관 동의 조회 + Route::put('agreements', [AccountController::class, 'updateAgreements'])->name('v1.account.agreements.update'); // 약관 동의 수정 }); // Tenant API @@ -453,6 +478,10 @@ Route::put('/{id}', [SaleController::class, 'update'])->whereNumber('id')->name('v1.sales.update'); Route::delete('/{id}', [SaleController::class, 'destroy'])->whereNumber('id')->name('v1.sales.destroy'); Route::post('/{id}/confirm', [SaleController::class, 'confirm'])->whereNumber('id')->name('v1.sales.confirm'); + // 거래명세서 API + Route::get('/{id}/statement', [SaleController::class, 'getStatement'])->whereNumber('id')->name('v1.sales.statement.show'); + Route::post('/{id}/statement/issue', [SaleController::class, 'issueStatement'])->whereNumber('id')->name('v1.sales.statement.issue'); + Route::post('/{id}/statement/send', [SaleController::class, 'sendStatement'])->whereNumber('id')->name('v1.sales.statement.send'); }); // Purchase API (매입 관리)