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:
2025-12-19 14:52:53 +09:00
parent c7b25710a0
commit 3020026abf
31 changed files with 2735 additions and 8 deletions

View File

@@ -131,4 +131,4 @@ public function handle(): int
return self::SUCCESS;
}
}
}

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

View File

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

View File

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

View File

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

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

View 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' => '동의 여부']),
];
}
}

View 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' => '탈퇴 사유']),
];
}
}

View File

@@ -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' => '알림 유형']),
];
}
}

View File

@@ -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' => '알림 유형']),
];
}
}

View 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' => '비밀번호']),
];
}
}

View 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' => '역할']),
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

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

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

View 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' => '기타',
];
}
}

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

View File

@@ -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,
],
];
}
}

View 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,
]);
}
}

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

View File

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

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

View File

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

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