feat: Phase 5 API 개발 완료 (사용자 초대, 알림설정, 계정관리, 거래명세서)
5.1 사용자 초대 기능: - UserInvitation 마이그레이션, 모델, 서비스, 컨트롤러, Swagger - 초대 발송/수락/취소/재발송 API 5.2 알림설정 확장: - NotificationSetting 마이그레이션, 모델, 서비스, 컨트롤러, Swagger - 채널별/유형별 알림 설정 관리 5.3 계정정보 수정 API: - 회원탈퇴, 사용중지, 약관동의 관리 - AccountService, AccountController, Swagger 5.4 매출 거래명세서 API: - 거래명세서 조회/발행/이메일발송 - SaleService 확장, Swagger 문서화
This commit is contained in:
@@ -131,4 +131,4 @@ public function handle(): int
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
61
app/Http/Controllers/Api/V1/AccountController.php
Normal file
61
app/Http/Controllers/Api/V1/AccountController.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Account\UpdateAgreementsRequest;
|
||||
use App\Http\Requests\Account\WithdrawRequest;
|
||||
use App\Services\AccountService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class AccountController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccountService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 회원 탈퇴 (SAM 완전 탈퇴)
|
||||
*/
|
||||
public function withdraw(WithdrawRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $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')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\NotificationSetting\BulkUpdateSettingRequest;
|
||||
use App\Http\Requests\NotificationSetting\UpdateSettingRequest;
|
||||
use App\Services\NotificationSettingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class NotificationSettingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationSettingService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 알림 설정 조회
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $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')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
73
app/Http/Controllers/Api/V1/UserInvitationController.php
Normal file
73
app/Http/Controllers/Api/V1/UserInvitationController.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\UserInvitation\AcceptInvitationRequest;
|
||||
use App\Http\Requests\UserInvitation\InviteUserRequest;
|
||||
use App\Http\Requests\UserInvitation\ListInvitationRequest;
|
||||
use App\Services\UserInvitationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class UserInvitationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserInvitationService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 초대 목록 조회
|
||||
*/
|
||||
public function index(ListInvitationRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $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')
|
||||
);
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/Account/UpdateAgreementsRequest.php
Normal file
36
app/Http/Requests/Account/UpdateAgreementsRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Account;
|
||||
|
||||
use App\Services\AccountService;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateAgreementsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'agreements' => ['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' => '동의 여부']),
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/Account/WithdrawRequest.php
Normal file
32
app/Http/Requests/Account/WithdrawRequest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Account;
|
||||
|
||||
use App\Services\AccountService;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class WithdrawRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'password' => ['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' => '탈퇴 사유']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\NotificationSetting;
|
||||
|
||||
use App\Models\NotificationSetting;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class BulkUpdateSettingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'settings' => ['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' => '알림 유형']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\NotificationSetting;
|
||||
|
||||
use App\Models\NotificationSetting;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateSettingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'notification_type' => ['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' => '알림 유형']),
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/UserInvitation/AcceptInvitationRequest.php
Normal file
32
app/Http/Requests/UserInvitation/AcceptInvitationRequest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\UserInvitation;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AcceptInvitationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['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' => '비밀번호']),
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/UserInvitation/InviteUserRequest.php
Normal file
32
app/Http/Requests/UserInvitation/InviteUserRequest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\UserInvitation;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InviteUserRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['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' => '역할']),
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/UserInvitation/ListInvitationRequest.php
Normal file
25
app/Http/Requests/UserInvitation/ListInvitationRequest.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\UserInvitation;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ListInvitationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
16
app/Http/Requests/V1/Sale/SendStatementRequest.php
Normal file
16
app/Http/Requests/V1/Sale/SendStatementRequest.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\Sale;
|
||||
|
||||
use App\Http\Requests\BaseRequest;
|
||||
|
||||
class SendStatementRequest extends BaseRequest
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['nullable', 'string', 'email', 'max:255'],
|
||||
'message' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
226
app/Models/NotificationSetting.php
Normal file
226
app/Models/NotificationSetting.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class NotificationSetting extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'notification_type',
|
||||
'push_enabled',
|
||||
'email_enabled',
|
||||
'sms_enabled',
|
||||
'in_app_enabled',
|
||||
'kakao_enabled',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'push_enabled' => '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,
|
||||
};
|
||||
}
|
||||
}
|
||||
176
app/Models/UserInvitation.php
Normal file
176
app/Models/UserInvitation.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Members\User;
|
||||
use App\Models\Permissions\Role;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserInvitation extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
// 상태 상수
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_ACCEPTED = 'accepted';
|
||||
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
// 기본 만료 기간 (일)
|
||||
public const DEFAULT_EXPIRES_DAYS = 7;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'email',
|
||||
'role_id',
|
||||
'message',
|
||||
'token',
|
||||
'status',
|
||||
'invited_by',
|
||||
'expires_at',
|
||||
'accepted_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => '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,
|
||||
};
|
||||
}
|
||||
}
|
||||
240
app/Services/AccountService.php
Normal file
240
app/Services/AccountService.php
Normal file
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Members\User;
|
||||
use App\Models\Members\UserTenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class AccountService extends Service
|
||||
{
|
||||
/**
|
||||
* 회원 탈퇴 (SAM 완전 탈퇴)
|
||||
* - 모든 테넌트에서 탈퇴
|
||||
* - 사용자 계정 Soft Delete
|
||||
*/
|
||||
public function withdraw(array $data): array
|
||||
{
|
||||
$userId = $this->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' => '기타',
|
||||
];
|
||||
}
|
||||
}
|
||||
180
app/Services/NotificationSettingService.php
Normal file
180
app/Services/NotificationSettingService.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\NotificationSetting;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class NotificationSettingService extends Service
|
||||
{
|
||||
/**
|
||||
* 사용자의 알림 설정 조회
|
||||
*/
|
||||
public function getSettings(): array
|
||||
{
|
||||
$tenantId = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
230
app/Services/UserInvitationService.php
Normal file
230
app/Services/UserInvitationService.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Members\User;
|
||||
use App\Models\Members\UserTenant;
|
||||
use App\Models\UserInvitation;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class UserInvitationService extends Service
|
||||
{
|
||||
/**
|
||||
* 초대 목록 조회
|
||||
*/
|
||||
public function index(array $params): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = $this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
214
app/Swagger/v1/AccountApi.php
Normal file
214
app/Swagger/v1/AccountApi.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="Account",
|
||||
* description="계정 관리 (탈퇴, 사용중지, 약관동의)"
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="AgreementItem",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="type", type="string", example="marketing"),
|
||||
* @OA\Property(property="label", type="string", example="마케팅 정보 수신"),
|
||||
* @OA\Property(property="required", type="boolean", example=false),
|
||||
* @OA\Property(property="agreed", type="boolean", example=true),
|
||||
* @OA\Property(property="agreed_at", type="string", format="date-time", nullable=true, example="2025-12-19T10:00:00+09:00")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="AgreementsResponse",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(
|
||||
* property="agreements",
|
||||
* type="object",
|
||||
* additionalProperties={"$ref": "#/components/schemas/AgreementItem"}
|
||||
* ),
|
||||
* @OA\Property(
|
||||
* property="types",
|
||||
* type="object",
|
||||
* description="약관 유형 레이블",
|
||||
* example={"terms": "이용약관", "privacy": "개인정보 처리방침", "marketing": "마케팅 정보 수신"}
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="WithdrawRequest",
|
||||
* type="object",
|
||||
* required={"password"},
|
||||
*
|
||||
* @OA\Property(property="password", type="string", example="currentPassword123", description="현재 비밀번호 (확인용)"),
|
||||
* @OA\Property(property="reason", type="string", enum={"not_using", "difficult", "alternative", "privacy", "other"}, example="not_using", description="탈퇴 사유"),
|
||||
* @OA\Property(property="detail", type="string", nullable=true, maxLength=500, example="더 이상 필요하지 않습니다.", description="상세 사유")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="WithdrawResponse",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="withdrawn_at", type="string", format="date-time", example="2025-12-19T10:00:00+09:00")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="SuspendResponse",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="suspended", type="boolean", example=true),
|
||||
* @OA\Property(property="new_default_tenant_id", type="integer", nullable=true, example=2, description="새 기본 테넌트 ID (없으면 null)")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="UpdateAgreementsRequest",
|
||||
* type="object",
|
||||
* required={"agreements"},
|
||||
*
|
||||
* @OA\Property(
|
||||
* property="agreements",
|
||||
* type="array",
|
||||
*
|
||||
* @OA\Items(
|
||||
* type="object",
|
||||
* required={"type", "agreed"},
|
||||
*
|
||||
* @OA\Property(property="type", type="string", enum={"terms", "privacy", "marketing", "push", "email", "sms"}, example="marketing"),
|
||||
* @OA\Property(property="agreed", type="boolean", example=true)
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
class AccountApi
|
||||
{
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/account/withdraw",
|
||||
* operationId="withdrawAccount",
|
||||
* tags={"Account"},
|
||||
* summary="회원 탈퇴",
|
||||
* description="SAM 회원 탈퇴 (모든 테넌트에서 탈퇴, 계정 삭제)",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/WithdrawRequest")
|
||||
* ),
|
||||
*
|
||||
* @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/WithdrawResponse")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=400, description="비밀번호 불일치"),
|
||||
* @OA\Response(response=401, description="인증 실패"),
|
||||
* @OA\Response(response=404, description="사용자를 찾을 수 없음")
|
||||
* )
|
||||
*/
|
||||
public function withdraw() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/account/suspend",
|
||||
* operationId="suspendAccount",
|
||||
* tags={"Account"},
|
||||
* summary="사용 중지",
|
||||
* description="현재 테넌트에서만 탈퇴 (다른 테넌트 멤버십 유지)",
|
||||
* 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/SuspendResponse")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패"),
|
||||
* @OA\Response(response=404, description="테넌트 멤버십을 찾을 수 없음")
|
||||
* )
|
||||
*/
|
||||
public function suspend() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/account/agreements",
|
||||
* operationId="getAgreements",
|
||||
* tags={"Account"},
|
||||
* summary="약관 동의 정보 조회",
|
||||
* description="현재 사용자의 약관 동의 정보를 조회합니다.",
|
||||
* 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/AgreementsResponse")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패"),
|
||||
* @OA\Response(response=404, description="사용자를 찾을 수 없음")
|
||||
* )
|
||||
*/
|
||||
public function getAgreements() {}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/account/agreements",
|
||||
* operationId="updateAgreements",
|
||||
* tags={"Account"},
|
||||
* summary="약관 동의 정보 수정",
|
||||
* description="약관 동의 정보를 수정합니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/UpdateAgreementsRequest")
|
||||
* ),
|
||||
*
|
||||
* @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",
|
||||
* type="object",
|
||||
* @OA\Property(
|
||||
* property="agreements",
|
||||
* type="object",
|
||||
* additionalProperties={"$ref": "#/components/schemas/AgreementItem"}
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패"),
|
||||
* @OA\Response(response=404, description="사용자를 찾을 수 없음"),
|
||||
* @OA\Response(response=422, description="유효성 검증 실패")
|
||||
* )
|
||||
*/
|
||||
public function updateAgreements() {}
|
||||
}
|
||||
@@ -70,8 +70,9 @@ class InternalApi
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/ErrorResponse")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function exchangeToken() {}
|
||||
}
|
||||
}
|
||||
|
||||
173
app/Swagger/v1/NotificationSettingApi.php
Normal file
173
app/Swagger/v1/NotificationSettingApi.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="NotificationSetting",
|
||||
* description="알림 설정 관리"
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="NotificationSettingItem",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="notification_type", type="string", example="approval"),
|
||||
* @OA\Property(property="label", type="string", example="전자결재"),
|
||||
* @OA\Property(property="push_enabled", type="boolean", example=true),
|
||||
* @OA\Property(property="email_enabled", type="boolean", example=false),
|
||||
* @OA\Property(property="sms_enabled", type="boolean", example=false),
|
||||
* @OA\Property(property="in_app_enabled", type="boolean", example=true),
|
||||
* @OA\Property(property="kakao_enabled", type="boolean", example=false),
|
||||
* @OA\Property(property="settings", type="object", nullable=true)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="NotificationSettingResponse",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(
|
||||
* property="settings",
|
||||
* type="object",
|
||||
* additionalProperties={"$ref": "#/components/schemas/NotificationSettingItem"}
|
||||
* ),
|
||||
* @OA\Property(
|
||||
* property="types",
|
||||
* type="object",
|
||||
* description="알림 유형 레이블",
|
||||
* example={"approval": "전자결재", "order": "수주", "deposit": "입금"}
|
||||
* ),
|
||||
* @OA\Property(
|
||||
* property="channels",
|
||||
* type="object",
|
||||
* description="채널 레이블",
|
||||
* example={"push": "푸시 알림", "email": "이메일", "sms": "SMS"}
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="UpdateNotificationSettingRequest",
|
||||
* type="object",
|
||||
* required={"notification_type"},
|
||||
*
|
||||
* @OA\Property(property="notification_type", type="string", enum={"approval", "order", "deposit", "withdrawal", "notice", "system", "marketing", "security"}, example="approval", description="알림 유형"),
|
||||
* @OA\Property(property="push_enabled", type="boolean", nullable=true, example=true, description="푸시 알림 활성화"),
|
||||
* @OA\Property(property="email_enabled", type="boolean", nullable=true, example=false, description="이메일 알림 활성화"),
|
||||
* @OA\Property(property="sms_enabled", type="boolean", nullable=true, example=false, description="SMS 알림 활성화"),
|
||||
* @OA\Property(property="in_app_enabled", type="boolean", nullable=true, example=true, description="인앱 알림 활성화"),
|
||||
* @OA\Property(property="kakao_enabled", type="boolean", nullable=true, example=false, description="카카오 알림톡 활성화"),
|
||||
* @OA\Property(property="settings", type="object", nullable=true, description="추가 설정")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="BulkUpdateNotificationSettingRequest",
|
||||
* type="object",
|
||||
* required={"settings"},
|
||||
*
|
||||
* @OA\Property(
|
||||
* property="settings",
|
||||
* type="array",
|
||||
*
|
||||
* @OA\Items(ref="#/components/schemas/UpdateNotificationSettingRequest")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
class NotificationSettingApi
|
||||
{
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/users/me/notification-settings",
|
||||
* operationId="getNotificationSettings",
|
||||
* tags={"NotificationSetting"},
|
||||
* summary="알림 설정 조회",
|
||||
* description="현재 사용자의 알림 설정을 조회합니다.",
|
||||
* 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/NotificationSettingResponse")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패")
|
||||
* )
|
||||
*/
|
||||
public function index() {}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/users/me/notification-settings",
|
||||
* operationId="updateNotificationSetting",
|
||||
* tags={"NotificationSetting"},
|
||||
* summary="알림 설정 업데이트",
|
||||
* description="특정 알림 유형의 설정을 업데이트합니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/UpdateNotificationSettingRequest")
|
||||
* ),
|
||||
*
|
||||
* @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/NotificationSettingItem")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패"),
|
||||
* @OA\Response(response=422, description="유효성 검증 실패")
|
||||
* )
|
||||
*/
|
||||
public function update() {}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/users/me/notification-settings/bulk",
|
||||
* operationId="bulkUpdateNotificationSettings",
|
||||
* tags={"NotificationSetting"},
|
||||
* summary="알림 설정 일괄 업데이트",
|
||||
* description="여러 알림 유형의 설정을 일괄 업데이트합니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/BulkUpdateNotificationSettingRequest")
|
||||
* ),
|
||||
*
|
||||
* @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",
|
||||
* type="array",
|
||||
*
|
||||
* @OA\Items(ref="#/components/schemas/NotificationSettingItem")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패"),
|
||||
* @OA\Response(response=422, description="유효성 검증 실패")
|
||||
* )
|
||||
*/
|
||||
public function bulkUpdate() {}
|
||||
}
|
||||
@@ -81,6 +81,79 @@
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="SaleStatementItem",
|
||||
* type="object",
|
||||
* description="거래명세서 품목",
|
||||
*
|
||||
* @OA\Property(property="description", type="string", example="1월 매출", description="품목 설명"),
|
||||
* @OA\Property(property="quantity", type="integer", example=1, description="수량"),
|
||||
* @OA\Property(property="unit_price", type="number", example=1000000, description="단가"),
|
||||
* @OA\Property(property="supply_amount", type="number", example=1000000, description="공급가액"),
|
||||
* @OA\Property(property="tax_amount", type="number", example=100000, description="세액"),
|
||||
* @OA\Property(property="total_amount", type="number", example=1100000, description="합계")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="SaleStatementParty",
|
||||
* type="object",
|
||||
* description="거래명세서 거래 당사자 정보",
|
||||
*
|
||||
* @OA\Property(property="name", type="string", example="(주)테스트", description="상호"),
|
||||
* @OA\Property(property="business_number", type="string", example="123-45-67890", description="사업자번호"),
|
||||
* @OA\Property(property="representative", type="string", example="홍길동", description="대표자"),
|
||||
* @OA\Property(property="address", type="string", example="서울시 강남구", description="주소"),
|
||||
* @OA\Property(property="tel", type="string", example="02-1234-5678", description="전화번호"),
|
||||
* @OA\Property(property="fax", type="string", example="02-1234-5679", description="팩스"),
|
||||
* @OA\Property(property="email", type="string", example="test@example.com", description="이메일")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="SaleStatement",
|
||||
* type="object",
|
||||
* description="거래명세서 정보",
|
||||
*
|
||||
* @OA\Property(property="statement_number", type="string", example="STSL202501150001", description="거래명세서 번호"),
|
||||
* @OA\Property(property="issued_at", type="string", format="date-time", nullable=true, description="발행일시"),
|
||||
* @OA\Property(property="sale", ref="#/components/schemas/Sale"),
|
||||
* @OA\Property(property="seller", ref="#/components/schemas/SaleStatementParty"),
|
||||
* @OA\Property(property="buyer", ref="#/components/schemas/SaleStatementParty"),
|
||||
* @OA\Property(property="items", type="array", @OA\Items(ref="#/components/schemas/SaleStatementItem")),
|
||||
* @OA\Property(property="summary", type="object",
|
||||
* @OA\Property(property="supply_amount", type="number", example=1000000),
|
||||
* @OA\Property(property="tax_amount", type="number", example=100000),
|
||||
* @OA\Property(property="total_amount", type="number", example=1100000)
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="SaleStatementIssueResponse",
|
||||
* type="object",
|
||||
* description="거래명세서 발행 응답",
|
||||
*
|
||||
* @OA\Property(property="statement_number", type="string", example="STSL202501150001", description="거래명세서 번호"),
|
||||
* @OA\Property(property="issued_at", type="string", format="date-time", example="2025-01-15T10:30:00+09:00", description="발행일시")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="SaleStatementSendRequest",
|
||||
* type="object",
|
||||
* description="거래명세서 이메일 발송 요청",
|
||||
*
|
||||
* @OA\Property(property="email", type="string", format="email", example="buyer@example.com", nullable=true, description="수신자 이메일 (미입력 시 거래처 이메일 사용)"),
|
||||
* @OA\Property(property="message", type="string", example="거래명세서를 발송합니다.", maxLength=1000, nullable=true, description="추가 메시지")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="SaleStatementSendResponse",
|
||||
* type="object",
|
||||
* description="거래명세서 발송 응답",
|
||||
*
|
||||
* @OA\Property(property="sent_to", type="string", format="email", example="buyer@example.com", description="발송 이메일"),
|
||||
* @OA\Property(property="sent_at", type="string", format="date-time", example="2025-01-15T10:30:00+09:00", description="발송일시"),
|
||||
* @OA\Property(property="statement_number", type="string", example="STSL202501150001", description="거래명세서 번호")
|
||||
* )
|
||||
*/
|
||||
class SaleApi
|
||||
{
|
||||
@@ -334,4 +407,111 @@ public function destroy() {}
|
||||
* )
|
||||
*/
|
||||
public function confirm() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/sales/{id}/statement",
|
||||
* tags={"Sales"},
|
||||
* summary="거래명세서 조회",
|
||||
* description="매출에 대한 거래명세서 정보를 조회합니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="매출 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
*
|
||||
* @OA\Property(property="data", ref="#/components/schemas/SaleStatement")
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=404, description="매출 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function getStatement() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/sales/{id}/statement/issue",
|
||||
* tags={"Sales"},
|
||||
* summary="거래명세서 발행",
|
||||
* description="매출에 대한 거래명세서를 발행합니다. 확정(confirmed) 상태의 매출만 발행 가능합니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="매출 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="발행 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
*
|
||||
* @OA\Property(property="data", ref="#/components/schemas/SaleStatementIssueResponse")
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=400, description="발행 불가 (미확정 상태)", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=404, description="매출 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function issueStatement() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/sales/{id}/statement/send",
|
||||
* tags={"Sales"},
|
||||
* summary="거래명세서 이메일 발송",
|
||||
* description="거래명세서를 이메일로 발송합니다. 이메일 미입력 시 거래처 이메일로 발송됩니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="매출 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=false,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/SaleStatementSendRequest")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="발송 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
*
|
||||
* @OA\Property(property="data", ref="#/components/schemas/SaleStatementSendResponse")
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=400, description="발송 불가 (이메일 없음)", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=404, description="매출 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function sendStatement() {}
|
||||
}
|
||||
|
||||
311
app/Swagger/v1/UserInvitationApi.php
Normal file
311
app/Swagger/v1/UserInvitationApi.php
Normal file
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="UserInvitation",
|
||||
* description="사용자 초대 관리"
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="UserInvitation",
|
||||
* type="object",
|
||||
* required={"id", "tenant_id", "email", "token", "status", "invited_by", "expires_at"},
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="tenant_id", type="integer", example=1),
|
||||
* @OA\Property(property="email", type="string", format="email", example="user@example.com"),
|
||||
* @OA\Property(property="role_id", type="integer", nullable=true, example=2),
|
||||
* @OA\Property(property="message", type="string", nullable=true, example="회사에 합류해 주세요!"),
|
||||
* @OA\Property(property="token", type="string", example="abc123..."),
|
||||
* @OA\Property(property="status", type="string", enum={"pending", "accepted", "expired", "cancelled"}, example="pending"),
|
||||
* @OA\Property(property="status_label", type="string", example="대기중"),
|
||||
* @OA\Property(property="invited_by", type="integer", example=1),
|
||||
* @OA\Property(property="expires_at", type="string", format="date-time"),
|
||||
* @OA\Property(property="accepted_at", type="string", format="date-time", nullable=true),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time"),
|
||||
* @OA\Property(
|
||||
* property="role",
|
||||
* type="object",
|
||||
* nullable=true,
|
||||
* @OA\Property(property="id", type="integer"),
|
||||
* @OA\Property(property="name", type="string")
|
||||
* ),
|
||||
* @OA\Property(
|
||||
* property="inviter",
|
||||
* type="object",
|
||||
* @OA\Property(property="id", type="integer"),
|
||||
* @OA\Property(property="name", type="string")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="UserInvitationPagination",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
*
|
||||
* @OA\Items(ref="#/components/schemas/UserInvitation")
|
||||
* ),
|
||||
*
|
||||
* @OA\Property(property="current_page", type="integer", example=1),
|
||||
* @OA\Property(property="last_page", type="integer", example=5),
|
||||
* @OA\Property(property="per_page", type="integer", example=20),
|
||||
* @OA\Property(property="total", type="integer", example=100)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="InviteUserRequest",
|
||||
* type="object",
|
||||
* required={"email"},
|
||||
*
|
||||
* @OA\Property(property="email", type="string", format="email", example="newuser@example.com", description="초대할 사용자 이메일"),
|
||||
* @OA\Property(property="role_id", type="integer", nullable=true, example=2, description="부여할 역할 ID"),
|
||||
* @OA\Property(property="message", type="string", nullable=true, example="SAM 시스템에 합류해 주세요!", description="초대 메시지"),
|
||||
* @OA\Property(property="expires_days", type="integer", nullable=true, example=7, minimum=1, maximum=30, description="만료 기간(일)")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="AcceptInvitationRequest",
|
||||
* type="object",
|
||||
* required={"name", "password", "password_confirmation"},
|
||||
*
|
||||
* @OA\Property(property="name", type="string", example="홍길동", description="사용자 이름"),
|
||||
* @OA\Property(property="password", type="string", format="password", example="password123", description="비밀번호 (8자 이상)"),
|
||||
* @OA\Property(property="password_confirmation", type="string", format="password", example="password123", description="비밀번호 확인"),
|
||||
* @OA\Property(property="phone", type="string", nullable=true, example="010-1234-5678", description="연락처")
|
||||
* )
|
||||
*/
|
||||
class UserInvitationApi
|
||||
{
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/users/invitations",
|
||||
* operationId="getUserInvitations",
|
||||
* tags={"UserInvitation"},
|
||||
* summary="초대 목록 조회",
|
||||
* description="테넌트의 사용자 초대 목록을 조회합니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="status",
|
||||
* in="query",
|
||||
* description="상태 필터",
|
||||
* required=false,
|
||||
*
|
||||
* @OA\Schema(type="string", enum={"pending", "accepted", "expired", "cancelled"})
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="search",
|
||||
* in="query",
|
||||
* description="이메일 검색",
|
||||
* required=false,
|
||||
*
|
||||
* @OA\Schema(type="string")
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="sort_by",
|
||||
* in="query",
|
||||
* description="정렬 기준",
|
||||
* required=false,
|
||||
*
|
||||
* @OA\Schema(type="string", enum={"created_at", "expires_at", "email"}, default="created_at")
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="sort_dir",
|
||||
* in="query",
|
||||
* description="정렬 방향",
|
||||
* required=false,
|
||||
*
|
||||
* @OA\Schema(type="string", enum={"asc", "desc"}, default="desc")
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="per_page",
|
||||
* in="query",
|
||||
* description="페이지당 항목 수",
|
||||
* required=false,
|
||||
*
|
||||
* @OA\Schema(type="integer", default=20, minimum=1, maximum=100)
|
||||
* ),
|
||||
*
|
||||
* @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/UserInvitationPagination")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패"),
|
||||
* @OA\Response(response=403, description="권한 없음")
|
||||
* )
|
||||
*/
|
||||
public function index() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/users/invite",
|
||||
* operationId="inviteUser",
|
||||
* tags={"UserInvitation"},
|
||||
* summary="사용자 초대",
|
||||
* description="새로운 사용자를 테넌트에 초대합니다. 초대 이메일이 발송됩니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/InviteUserRequest")
|
||||
* ),
|
||||
*
|
||||
* @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/UserInvitation")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=400, description="이미 가입된 사용자 또는 대기 중인 초대 존재"),
|
||||
* @OA\Response(response=401, description="인증 실패"),
|
||||
* @OA\Response(response=422, description="유효성 검증 실패")
|
||||
* )
|
||||
*/
|
||||
public function invite() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/users/invitations/{token}/accept",
|
||||
* operationId="acceptInvitation",
|
||||
* tags={"UserInvitation"},
|
||||
* summary="초대 수락",
|
||||
* description="초대를 수락하고 사용자 계정을 생성합니다. 인증 불필요.",
|
||||
* security={{"ApiKeyAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="token",
|
||||
* in="path",
|
||||
* description="초대 토큰",
|
||||
* required=true,
|
||||
*
|
||||
* @OA\Schema(type="string")
|
||||
* ),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/AcceptInvitationRequest")
|
||||
* ),
|
||||
*
|
||||
* @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", type="object",
|
||||
* @OA\Property(property="id", type="integer"),
|
||||
* @OA\Property(property="name", type="string"),
|
||||
* @OA\Property(property="email", type="string")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=400, description="만료된 초대 또는 잘못된 상태"),
|
||||
* @OA\Response(response=404, description="초대를 찾을 수 없음"),
|
||||
* @OA\Response(response=422, description="유효성 검증 실패")
|
||||
* )
|
||||
*/
|
||||
public function accept() {}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/users/invitations/{id}",
|
||||
* operationId="cancelInvitation",
|
||||
* tags={"UserInvitation"},
|
||||
* summary="초대 취소",
|
||||
* description="대기 중인 초대를 취소합니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="초대 ID",
|
||||
* required=true,
|
||||
*
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
*
|
||||
* @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", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=400, description="취소할 수 없는 상태"),
|
||||
* @OA\Response(response=401, description="인증 실패"),
|
||||
* @OA\Response(response=404, description="초대를 찾을 수 없음")
|
||||
* )
|
||||
*/
|
||||
public function cancel() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/users/invitations/{id}/resend",
|
||||
* operationId="resendInvitation",
|
||||
* tags={"UserInvitation"},
|
||||
* summary="초대 재발송",
|
||||
* description="대기 중인 초대 이메일을 재발송합니다. 만료 기간이 연장됩니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="초대 ID",
|
||||
* required=true,
|
||||
*
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
*
|
||||
* @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/UserInvitation")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=400, description="재발송할 수 없는 상태"),
|
||||
* @OA\Response(response=401, description="인증 실패"),
|
||||
* @OA\Response(response=404, description="초대를 찾을 수 없음")
|
||||
* )
|
||||
*/
|
||||
public function resend() {}
|
||||
}
|
||||
@@ -30,4 +30,4 @@ public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('admin_api_deprecations');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_invitations', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notification_settings', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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' => '탈퇴할 수 없는 상태입니다.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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' => '약관 동의 정보가 수정되었습니다.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\Admin\GlobalMenuController;
|
||||
use App\Http\Controllers\Api\V1\AccountController;
|
||||
use App\Http\Controllers\Api\V1\AdminController;
|
||||
use App\Http\Controllers\Api\V1\AiReportController;
|
||||
use App\Http\Controllers\Api\V1\ApiController;
|
||||
@@ -38,8 +39,8 @@
|
||||
use App\Http\Controllers\Api\V1\ItemMaster\ItemBomItemController;
|
||||
use App\Http\Controllers\Api\V1\ItemMaster\ItemFieldController;
|
||||
use App\Http\Controllers\Api\V1\ItemMaster\ItemMasterController;
|
||||
use App\Http\Controllers\Api\V1\ItemMaster\ItemPageController;
|
||||
// use App\Http\Controllers\Api\V1\MaterialController; // REMOVED: materials 테이블 삭제됨
|
||||
use App\Http\Controllers\Api\V1\ItemMaster\ItemPageController;
|
||||
use App\Http\Controllers\Api\V1\ItemMaster\ItemSectionController;
|
||||
use App\Http\Controllers\Api\V1\ItemMaster\SectionTemplateController;
|
||||
use App\Http\Controllers\Api\V1\ItemMaster\UnitOptionController;
|
||||
@@ -50,10 +51,11 @@
|
||||
use App\Http\Controllers\Api\V1\LoanController;
|
||||
use App\Http\Controllers\Api\V1\MenuController;
|
||||
use App\Http\Controllers\Api\V1\ModelSetController;
|
||||
use App\Http\Controllers\Api\V1\NotificationSettingController;
|
||||
use App\Http\Controllers\Api\V1\PaymentController;
|
||||
use App\Http\Controllers\Api\V1\PayrollController;
|
||||
// use App\Http\Controllers\Api\V1\ProductBomItemController; // REMOVED: products 테이블 삭제됨
|
||||
// use App\Http\Controllers\Api\V1\ProductController; // REMOVED: products 테이블 삭제됨
|
||||
use App\Http\Controllers\Api\V1\PayrollController;
|
||||
use App\Http\Controllers\Api\V1\PermissionController;
|
||||
use App\Http\Controllers\Api\V1\PlanController;
|
||||
use App\Http\Controllers\Api\V1\PostController;
|
||||
@@ -69,16 +71,17 @@
|
||||
use App\Http\Controllers\Api\V1\SaleController;
|
||||
use App\Http\Controllers\Api\V1\SiteController;
|
||||
use App\Http\Controllers\Api\V1\SubscriptionController;
|
||||
use App\Http\Controllers\Api\V1\TaxInvoiceController;
|
||||
// 설계 전용 (디자인 네임스페이스)
|
||||
use App\Http\Controllers\Api\V1\TaxInvoiceController;
|
||||
use App\Http\Controllers\Api\V1\TenantController;
|
||||
use App\Http\Controllers\Api\V1\TenantFieldSettingController;
|
||||
use App\Http\Controllers\Api\V1\TenantOptionGroupController;
|
||||
use App\Http\Controllers\Api\V1\TenantOptionValueController;
|
||||
use App\Http\Controllers\Api\V1\TenantStatFieldController;
|
||||
// 모델셋 관리 (견적 시스템)
|
||||
use App\Http\Controllers\Api\V1\TenantStatFieldController;
|
||||
use App\Http\Controllers\Api\V1\TenantUserProfileController;
|
||||
use App\Http\Controllers\Api\V1\UserController;
|
||||
use App\Http\Controllers\Api\V1\UserInvitationController;
|
||||
use App\Http\Controllers\Api\V1\UserRoleController;
|
||||
use App\Http\Controllers\Api\V1\WithdrawalController;
|
||||
use App\Http\Controllers\Api\V1\WorkSettingController;
|
||||
@@ -154,6 +157,28 @@
|
||||
|
||||
Route::get('me/tenants', [UserController::class, 'tenants'])->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 (매입 관리)
|
||||
|
||||
Reference in New Issue
Block a user