diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 94d66fb..2058dec 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2025-12-19 16:12:19 +> **자동 생성**: 2025-12-19 16:48:57 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -368,6 +368,34 @@ ### quotes - **items()**: hasMany → `quote_items` - **revisions()**: hasMany → `quote_revisions` +### quote_formulas +**모델**: `App\Models\Quote\QuoteFormula` + +- **category()**: belongsTo → `quote_formula_categories` +- **ranges()**: hasMany → `quote_formula_ranges` +- **mappings()**: hasMany → `quote_formula_mappings` +- **items()**: hasMany → `quote_formula_items` + +### quote_formula_categorys +**모델**: `App\Models\Quote\QuoteFormulaCategory` + +- **formulas()**: hasMany → `quote_formulas` + +### quote_formula_items +**모델**: `App\Models\Quote\QuoteFormulaItem` + +- **formula()**: belongsTo → `quote_formulas` + +### quote_formula_mappings +**모델**: `App\Models\Quote\QuoteFormulaMapping` + +- **formula()**: belongsTo → `quote_formulas` + +### quote_formula_ranges +**모델**: `App\Models\Quote\QuoteFormulaRange` + +- **formula()**: belongsTo → `quote_formulas` + ### quote_items **모델**: `App\Models\Quote\QuoteItem` @@ -439,6 +467,12 @@ ### cards - **creator()**: belongsTo → `users` - **updater()**: belongsTo → `users` +### data_exports +**모델**: `App\Models\Tenants\DataExport` + +- **tenant()**: belongsTo → `tenants` +- **creator()**: belongsTo → `users` + ### departments **모델**: `App\Models\Tenants\Department` diff --git a/app/Http/Controllers/Api/V1/PaymentController.php b/app/Http/Controllers/Api/V1/PaymentController.php index 5981c05..d675d8c 100644 --- a/app/Http/Controllers/Api/V1/PaymentController.php +++ b/app/Http/Controllers/Api/V1/PaymentController.php @@ -85,4 +85,14 @@ public function refund(PaymentActionRequest $request, int $id): JsonResponse return ApiResponse::handle('message.payment.refunded', $result); } + + /** + * 결제 명세서 조회 + */ + public function statement(int $id): JsonResponse + { + $result = $this->paymentService->statement($id); + + return ApiResponse::handle('message.fetched', $result); + } } diff --git a/app/Http/Controllers/Api/V1/SubscriptionController.php b/app/Http/Controllers/Api/V1/SubscriptionController.php index b205c58..6742ea6 100644 --- a/app/Http/Controllers/Api/V1/SubscriptionController.php +++ b/app/Http/Controllers/Api/V1/SubscriptionController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; +use App\Http\Requests\V1\Subscription\ExportStoreRequest; use App\Http\Requests\V1\Subscription\SubscriptionCancelRequest; use App\Http\Requests\V1\Subscription\SubscriptionIndexRequest; use App\Http\Requests\V1\Subscription\SubscriptionStoreRequest; @@ -95,4 +96,34 @@ public function resume(int $id): JsonResponse return ApiResponse::handle('message.subscription.resumed', $result); } + + /** + * 사용량 조회 + */ + public function usage(): JsonResponse + { + $result = $this->subscriptionService->usage(); + + return ApiResponse::handle('message.fetched', $result); + } + + /** + * 내보내기 요청 + */ + public function export(ExportStoreRequest $request): JsonResponse + { + $result = $this->subscriptionService->createExport($request->validated()); + + return ApiResponse::handle('message.export.requested', $result, 201); + } + + /** + * 내보내기 상태 조회 + */ + public function exportStatus(int $id): JsonResponse + { + $result = $this->subscriptionService->getExport($id); + + return ApiResponse::handle('message.fetched', $result); + } } diff --git a/app/Http/Requests/V1/Subscription/ExportStoreRequest.php b/app/Http/Requests/V1/Subscription/ExportStoreRequest.php new file mode 100644 index 0000000..bc0e8bc --- /dev/null +++ b/app/Http/Requests/V1/Subscription/ExportStoreRequest.php @@ -0,0 +1,33 @@ + ['required', 'string', Rule::in(DataExport::TYPES)], + 'options' => ['nullable', 'array'], + 'options.format' => ['nullable', 'string', Rule::in(['xlsx', 'csv', 'json'])], + 'options.include_deleted' => ['nullable', 'boolean'], + ]; + } + + public function attributes(): array + { + return [ + 'export_type' => __('field.export_type'), + 'options' => __('field.options'), + ]; + } +} diff --git a/app/Models/Tenants/DataExport.php b/app/Models/Tenants/DataExport.php new file mode 100644 index 0000000..e07fce5 --- /dev/null +++ b/app/Models/Tenants/DataExport.php @@ -0,0 +1,206 @@ + '대기중', + self::STATUS_PROCESSING => '처리중', + self::STATUS_COMPLETED => '완료', + self::STATUS_FAILED => '실패', + ]; + + // ========================================================================= + // 모델 설정 + // ========================================================================= + + protected $fillable = [ + 'tenant_id', + 'export_type', + 'status', + 'file_path', + 'file_name', + 'file_size', + 'options', + 'started_at', + 'completed_at', + 'error_message', + 'created_by', + ]; + + protected $casts = [ + 'options' => 'array', + 'file_size' => 'integer', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + + protected $attributes = [ + 'status' => self::STATUS_PENDING, + ]; + + // ========================================================================= + // 관계 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + /** + * 상태 라벨 + */ + public function getStatusLabelAttribute(): string + { + return self::STATUS_LABELS[$this->status] ?? $this->status; + } + + /** + * 파일 크기 포맷 + */ + public function getFileSizeFormattedAttribute(): string + { + if (! $this->file_size) { + return '-'; + } + + $units = ['B', 'KB', 'MB', 'GB']; + $bytes = max($this->file_size, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + $bytes /= (1 << (10 * $pow)); + + return round($bytes, 2).' '.$units[$pow]; + } + + /** + * 완료 여부 + */ + public function getIsCompletedAttribute(): bool + { + return $this->status === self::STATUS_COMPLETED; + } + + /** + * 다운로드 가능 여부 + */ + public function getIsDownloadableAttribute(): bool + { + return $this->status === self::STATUS_COMPLETED && $this->file_path; + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 처리 시작 + */ + public function markAsProcessing(): bool + { + $this->status = self::STATUS_PROCESSING; + $this->started_at = now(); + + return $this->save(); + } + + /** + * 처리 완료 + */ + public function markAsCompleted(string $filePath, string $fileName, int $fileSize): bool + { + $this->status = self::STATUS_COMPLETED; + $this->file_path = $filePath; + $this->file_name = $fileName; + $this->file_size = $fileSize; + $this->completed_at = now(); + + return $this->save(); + } + + /** + * 처리 실패 + */ + public function markAsFailed(string $errorMessage): bool + { + $this->status = self::STATUS_FAILED; + $this->error_message = $errorMessage; + $this->completed_at = now(); + + return $this->save(); + } +} diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php index 12ae5c3..7ed7e28 100644 --- a/app/Services/PaymentService.php +++ b/app/Services/PaymentService.php @@ -4,6 +4,7 @@ use App\Models\Tenants\Payment; use App\Models\Tenants\Subscription; +use App\Models\Tenants\Tenant; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -273,4 +274,84 @@ public function refund(int $id, ?string $reason = null): Payment return $payment->fresh(['subscription.plan']); } + + // ========================================================================= + // 결제 명세서 + // ========================================================================= + + /** + * 결제 명세서 조회 + */ + public function statement(int $id): array + { + $tenantId = $this->tenantId(); + + // 테넌트 검증 및 결제 조회 + $subscriptionIds = Subscription::query() + ->where('tenant_id', $tenantId) + ->pluck('id'); + + $payment = Payment::query() + ->whereIn('subscription_id', $subscriptionIds) + ->with(['subscription.plan']) + ->findOrFail($id); + + // 테넌트 정보 조회 + $tenant = Tenant::findOrFail($tenantId); + + $subscription = $payment->subscription; + $plan = $subscription->plan; + + return [ + 'statement_no' => sprintf('INV-%s-%06d', $payment->paid_at?->format('Ymd') ?? now()->format('Ymd'), $payment->id), + 'issued_at' => now()->toIso8601String(), + 'payment' => [ + 'id' => $payment->id, + 'amount' => $payment->amount, + 'formatted_amount' => $payment->formatted_amount, + 'payment_method' => $payment->payment_method, + 'payment_method_label' => $payment->payment_method_label, + 'transaction_id' => $payment->transaction_id, + 'status' => $payment->status, + 'status_label' => $payment->status_label, + 'paid_at' => $payment->paid_at?->toIso8601String(), + 'memo' => $payment->memo, + ], + 'subscription' => [ + 'id' => $subscription->id, + 'started_at' => $subscription->started_at?->toDateString(), + 'ended_at' => $subscription->ended_at?->toDateString(), + 'status' => $subscription->status, + 'status_label' => $subscription->status_label, + ], + 'plan' => $plan ? [ + 'id' => $plan->id, + 'name' => $plan->name, + 'code' => $plan->code, + 'price' => $plan->price, + 'billing_cycle' => $plan->billing_cycle, + 'billing_cycle_label' => $plan->billing_cycle_label ?? $plan->billing_cycle, + ] : null, + 'customer' => [ + 'tenant_id' => $tenant->id, + 'company_name' => $tenant->company_name, + 'business_number' => $tenant->business_number ?? null, + 'representative' => $tenant->representative ?? null, + 'address' => $tenant->address ?? null, + 'email' => $tenant->email ?? null, + 'phone' => $tenant->phone ?? null, + ], + 'items' => [ + [ + 'description' => $plan ? sprintf('%s 구독 (%s)', $plan->name, $subscription->started_at?->format('Y.m.d') ?? '-') : '구독 서비스', + 'quantity' => 1, + 'unit_price' => $payment->amount, + 'amount' => $payment->amount, + ], + ], + 'subtotal' => $payment->amount, + 'tax' => 0, // VAT 별도 시 계산 필요 + 'total' => $payment->amount, + ]; + } } diff --git a/app/Services/SubscriptionService.php b/app/Services/SubscriptionService.php index 59b76db..2467af6 100644 --- a/app/Services/SubscriptionService.php +++ b/app/Services/SubscriptionService.php @@ -2,12 +2,15 @@ namespace App\Services; +use App\Models\Tenants\DataExport; use App\Models\Tenants\Payment; use App\Models\Tenants\Plan; use App\Models\Tenants\Subscription; +use App\Models\Tenants\Tenant; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class SubscriptionService extends Service { @@ -294,4 +297,136 @@ public function resume(int $id): Subscription return $subscription->fresh(['plan']); } + + // ========================================================================= + // 사용량 조회 + // ========================================================================= + + /** + * 사용량 조회 + */ + public function usage(): array + { + $tenantId = $this->tenantId(); + + $tenant = Tenant::with(['subscription.plan'])->findOrFail($tenantId); + + // 사용자 수 + $userCount = $tenant->users()->count(); + $maxUsers = $tenant->max_users ?? 0; + + // 저장공간 + $storageUsed = $tenant->storage_used ?? 0; + $storageLimit = $tenant->storage_limit ?? 0; + + // 구독 정보 + $subscription = $tenant->subscription; + $remainingDays = null; + $planName = null; + + if ($subscription && $subscription->is_valid) { + $remainingDays = $subscription->remaining_days; + $planName = $subscription->plan?->name; + } + + return [ + 'users' => [ + 'used' => $userCount, + 'limit' => $maxUsers, + 'percentage' => $maxUsers > 0 ? round(($userCount / $maxUsers) * 100, 1) : 0, + ], + 'storage' => [ + 'used' => $storageUsed, + 'used_formatted' => $tenant->getStorageUsedFormatted(), + 'limit' => $storageLimit, + 'limit_formatted' => $tenant->getStorageLimitFormatted(), + 'percentage' => $storageLimit > 0 ? round(($storageUsed / $storageLimit) * 100, 1) : 0, + ], + 'subscription' => [ + 'plan' => $planName, + 'status' => $subscription?->status, + 'remaining_days' => $remainingDays, + 'started_at' => $subscription?->started_at?->toDateString(), + 'ended_at' => $subscription?->ended_at?->toDateString(), + ], + ]; + } + + // ========================================================================= + // 데이터 내보내기 + // ========================================================================= + + /** + * 내보내기 요청 생성 + */ + public function createExport(array $data): DataExport + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 진행 중인 내보내기가 있는지 확인 + $pendingExport = DataExport::where('tenant_id', $tenantId) + ->whereIn('status', [DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING]) + ->first(); + + if ($pendingExport) { + throw new BadRequestHttpException(__('error.export.already_in_progress')); + } + + $export = DataExport::create([ + 'tenant_id' => $tenantId, + 'export_type' => $data['export_type'] ?? DataExport::TYPE_ALL, + 'status' => DataExport::STATUS_PENDING, + 'options' => $data['options'] ?? null, + 'created_by' => $userId, + ]); + + // TODO: 비동기 Job 디스패치 + // dispatch(new ProcessDataExport($export)); + + return $export; + } + + /** + * 내보내기 상태 조회 + */ + public function getExport(int $id): DataExport + { + $tenantId = $this->tenantId(); + + $export = DataExport::where('tenant_id', $tenantId)->find($id); + + if (! $export) { + throw new NotFoundHttpException(__('error.export.not_found')); + } + + return $export; + } + + /** + * 내보내기 목록 조회 + */ + public function getExports(array $params = []): LengthAwarePaginator + { + $tenantId = $this->tenantId(); + + $query = DataExport::where('tenant_id', $tenantId) + ->with('creator:id,name,email'); + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 유형 필터 + if (! empty($params['export_type'])) { + $query->where('export_type', $params['export_type']); + } + + $query->orderBy('created_at', 'desc'); + + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } } diff --git a/app/Swagger/v1/PaymentApi.php b/app/Swagger/v1/PaymentApi.php index 73cb85c..3a3df59 100644 --- a/app/Swagger/v1/PaymentApi.php +++ b/app/Swagger/v1/PaymentApi.php @@ -85,6 +85,64 @@ * description="결제 수단별 집계" * ) * ) + * + * @OA\Schema( + * schema="PaymentStatement", + * type="object", + * description="결제 명세서", + * + * @OA\Property(property="statement_no", type="string", example="INV-20250115-000001", description="명세서 번호"), + * @OA\Property(property="issued_at", type="string", format="date-time", description="발행일시"), + * @OA\Property(property="payment", type="object", + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="amount", type="number", format="float", example=29000), + * @OA\Property(property="formatted_amount", type="string", example="29,000원"), + * @OA\Property(property="payment_method", type="string", example="card"), + * @OA\Property(property="payment_method_label", type="string", example="카드"), + * @OA\Property(property="transaction_id", type="string", example="TXN123456789", nullable=true), + * @OA\Property(property="status", type="string", example="completed"), + * @OA\Property(property="status_label", type="string", example="완료"), + * @OA\Property(property="paid_at", type="string", format="date-time", nullable=true), + * @OA\Property(property="memo", type="string", nullable=true) + * ), + * @OA\Property(property="subscription", type="object", + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="started_at", type="string", format="date", example="2025-01-01"), + * @OA\Property(property="ended_at", type="string", format="date", example="2025-02-01", nullable=true), + * @OA\Property(property="status", type="string", example="active"), + * @OA\Property(property="status_label", type="string", example="활성") + * ), + * @OA\Property(property="plan", type="object", nullable=true, + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="name", type="string", example="스타터"), + * @OA\Property(property="code", type="string", example="starter"), + * @OA\Property(property="price", type="number", format="float", example=29000), + * @OA\Property(property="billing_cycle", type="string", example="monthly"), + * @OA\Property(property="billing_cycle_label", type="string", example="월간") + * ), + * @OA\Property(property="customer", type="object", + * @OA\Property(property="tenant_id", type="integer", example=1), + * @OA\Property(property="company_name", type="string", example="테스트 회사"), + * @OA\Property(property="business_number", type="string", example="123-45-67890", nullable=true), + * @OA\Property(property="representative", type="string", example="홍길동", nullable=true), + * @OA\Property(property="address", type="string", example="서울시 강남구", nullable=true), + * @OA\Property(property="email", type="string", example="contact@test.com", nullable=true), + * @OA\Property(property="phone", type="string", example="02-1234-5678", nullable=true) + * ), + * @OA\Property(property="items", type="array", + * + * @OA\Items(type="object", + * + * @OA\Property(property="description", type="string", example="스타터 구독 (2025.01.01)"), + * @OA\Property(property="quantity", type="integer", example=1), + * @OA\Property(property="unit_price", type="number", format="float", example=29000), + * @OA\Property(property="amount", type="number", format="float", example=29000) + * ) + * ), + * @OA\Property(property="subtotal", type="number", format="float", example=29000, description="소계"), + * @OA\Property(property="tax", type="number", format="float", example=0, description="세금"), + * @OA\Property(property="total", type="number", format="float", example=29000, description="총액") + * ) */ class PaymentApi { @@ -361,4 +419,37 @@ public function cancel() {} * ) */ public function refund() {} + + /** + * @OA\Get( + * path="/api/v1/payments/{id}/statement", + * tags={"Payments"}, + * 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/PaymentStatement") + * ) + * } + * ) + * ), + * + * @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 statement() {} } diff --git a/app/Swagger/v1/SubscriptionApi.php b/app/Swagger/v1/SubscriptionApi.php index 646f183..c7eb160 100644 --- a/app/Swagger/v1/SubscriptionApi.php +++ b/app/Swagger/v1/SubscriptionApi.php @@ -56,6 +56,69 @@ * * @OA\Property(property="reason", type="string", example="서비스 불만족", maxLength=500, nullable=true, description="취소 사유") * ) + * + * @OA\Schema( + * schema="UsageResponse", + * type="object", + * description="사용량 정보", + * + * @OA\Property(property="users", type="object", + * @OA\Property(property="used", type="integer", example=5, description="현재 사용자 수"), + * @OA\Property(property="limit", type="integer", example=10, description="최대 사용자 수"), + * @OA\Property(property="percentage", type="number", format="float", example=50.0, description="사용률 (%)") + * ), + * @OA\Property(property="storage", type="object", + * @OA\Property(property="used", type="integer", example=1288490188, description="사용 용량 (bytes)"), + * @OA\Property(property="used_formatted", type="string", example="1.2 GB", description="사용 용량 (포맷)"), + * @OA\Property(property="limit", type="integer", example=10737418240, description="최대 용량 (bytes)"), + * @OA\Property(property="limit_formatted", type="string", example="10 GB", description="최대 용량 (포맷)"), + * @OA\Property(property="percentage", type="number", format="float", example=12.0, description="사용률 (%)") + * ), + * @OA\Property(property="subscription", type="object", + * @OA\Property(property="plan", type="string", example="스타터", nullable=true, description="요금제명"), + * @OA\Property(property="status", type="string", example="active", nullable=true, description="구독 상태"), + * @OA\Property(property="remaining_days", type="integer", example=25, nullable=true, description="남은 일수"), + * @OA\Property(property="started_at", type="string", format="date", example="2025-01-01", nullable=true), + * @OA\Property(property="ended_at", type="string", format="date", example="2025-02-01", nullable=true) + * ) + * ) + * + * @OA\Schema( + * schema="DataExport", + * type="object", + * description="데이터 내보내기 정보", + * + * @OA\Property(property="id", type="integer", example=1, description="ID"), + * @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"), + * @OA\Property(property="export_type", type="string", enum={"all","users","products","orders","clients"}, example="all", description="내보내기 유형"), + * @OA\Property(property="status", type="string", enum={"pending","processing","completed","failed"}, example="pending", description="상태"), + * @OA\Property(property="status_label", type="string", example="대기중", description="상태 라벨"), + * @OA\Property(property="file_path", type="string", nullable=true, description="파일 경로"), + * @OA\Property(property="file_name", type="string", nullable=true, description="파일명"), + * @OA\Property(property="file_size", type="integer", nullable=true, description="파일 크기 (bytes)"), + * @OA\Property(property="file_size_formatted", type="string", example="1.5 MB", description="파일 크기 (포맷)"), + * @OA\Property(property="options", type="object", nullable=true, description="내보내기 옵션"), + * @OA\Property(property="started_at", type="string", format="date-time", nullable=true, description="시작 시간"), + * @OA\Property(property="completed_at", type="string", format="date-time", nullable=true, description="완료 시간"), + * @OA\Property(property="error_message", type="string", nullable=true, description="에러 메시지"), + * @OA\Property(property="is_completed", type="boolean", example=false, description="완료 여부"), + * @OA\Property(property="is_downloadable", type="boolean", example=false, description="다운로드 가능 여부"), + * @OA\Property(property="created_at", type="string", format="date-time"), + * @OA\Property(property="updated_at", type="string", format="date-time") + * ) + * + * @OA\Schema( + * schema="ExportCreateRequest", + * type="object", + * required={"export_type"}, + * description="내보내기 요청", + * + * @OA\Property(property="export_type", type="string", enum={"all","users","products","orders","clients"}, example="all", description="내보내기 유형"), + * @OA\Property(property="options", type="object", nullable=true, + * @OA\Property(property="format", type="string", enum={"xlsx","csv","json"}, example="xlsx", description="파일 포맷"), + * @OA\Property(property="include_deleted", type="boolean", example=false, description="삭제된 데이터 포함 여부") + * ) + * ) */ class SubscriptionApi { @@ -359,4 +422,105 @@ public function suspend() {} * ) */ public function resume() {} + + /** + * @OA\Get( + * path="/api/v1/subscriptions/usage", + * tags={"Subscriptions"}, + * summary="사용량 조회", + * description="테넌트의 사용자, 저장소, 구독 사용량 정보를 조회합니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Response( + * response=200, + * description="조회 성공", + * + * @OA\JsonContent( + * allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema( + * + * @OA\Property(property="data", ref="#/components/schemas/UsageResponse") + * ) + * } + * ) + * ), + * + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function usage() {} + + /** + * @OA\Post( + * path="/api/v1/subscriptions/export", + * tags={"Subscriptions"}, + * summary="데이터 내보내기 요청", + * description="테넌트 데이터를 내보내기 요청합니다. 백그라운드에서 처리되며 완료 후 다운로드 가능합니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\RequestBody( + * required=true, + * + * @OA\JsonContent(ref="#/components/schemas/ExportCreateRequest") + * ), + * + * @OA\Response( + * response=201, + * description="요청 성공", + * + * @OA\JsonContent( + * allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema( + * + * @OA\Property(property="data", ref="#/components/schemas/DataExport") + * ) + * } + * ) + * ), + * + * @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=422, description="유효성 검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function export() {} + + /** + * @OA\Get( + * path="/api/v1/subscriptions/export/{id}", + * tags={"Subscriptions"}, + * 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/DataExport") + * ) + * } + * ) + * ), + * + * @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 exportStatus() {} } diff --git a/database/migrations/2025_12_19_164035_create_data_exports_table.php b/database/migrations/2025_12_19_164035_create_data_exports_table.php new file mode 100644 index 0000000..ad8f06c --- /dev/null +++ b/database/migrations/2025_12_19_164035_create_data_exports_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete()->comment('테넌트 ID'); + $table->string('export_type', 50)->comment('내보내기 유형: all, users, products, orders, clients'); + $table->string('status', 20)->default('pending')->comment('상태: pending, processing, completed, failed'); + $table->string('file_path')->nullable()->comment('생성된 파일 경로'); + $table->string('file_name')->nullable()->comment('다운로드 파일명'); + $table->unsignedBigInteger('file_size')->nullable()->comment('파일 크기 (bytes)'); + $table->json('options')->nullable()->comment('내보내기 옵션'); + $table->timestamp('started_at')->nullable()->comment('처리 시작 시간'); + $table->timestamp('completed_at')->nullable()->comment('처리 완료 시간'); + $table->text('error_message')->nullable()->comment('에러 메시지'); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('생성자'); + $table->timestamps(); + + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('data_exports'); + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index f478a92..f9013a5 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -295,4 +295,20 @@ 'already_withdrawn' => '이미 탈퇴한 계정입니다.', 'cannot_withdraw' => '탈퇴할 수 없는 상태입니다.', ], + + // 구독 관련 + 'subscription' => [ + 'already_active' => '이미 활성화된 구독이 있습니다.', + 'not_cancellable' => '취소할 수 없는 상태입니다.', + 'not_renewable' => '갱신할 수 없는 상태입니다.', + 'not_suspendable' => '일시정지할 수 없는 상태입니다.', + 'not_resumable' => '재개할 수 없는 상태입니다.', + ], + + // 데이터 내보내기 관련 + 'export' => [ + 'already_in_progress' => '이미 진행 중인 내보내기가 있습니다.', + 'not_found' => '내보내기 요청을 찾을 수 없습니다.', + 'failed' => '내보내기 처리 중 오류가 발생했습니다.', + ], ]; diff --git a/lang/ko/message.php b/lang/ko/message.php index 1fcfa67..1434784 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -346,4 +346,25 @@ 'suspended' => '사용 중지가 완료되었습니다.', 'agreements_updated' => '약관 동의 정보가 수정되었습니다.', ], + + // 구독 관리 + 'subscription' => [ + 'cancelled' => '구독이 취소되었습니다.', + 'renewed' => '구독이 갱신되었습니다.', + 'suspended' => '구독이 일시정지되었습니다.', + 'resumed' => '구독이 재개되었습니다.', + ], + + // 데이터 내보내기 + 'export' => [ + 'requested' => '내보내기 요청이 접수되었습니다.', + 'completed' => '내보내기가 완료되었습니다.', + ], + + // 결제 관리 + 'payment' => [ + 'completed' => '결제가 완료되었습니다.', + 'cancelled' => '결제가 취소되었습니다.', + 'refunded' => '환불이 완료되었습니다.', + ], ]; diff --git a/routes/api.php b/routes/api.php index 3d4cc00..65d30df 100644 --- a/routes/api.php +++ b/routes/api.php @@ -453,6 +453,9 @@ Route::get('', [SubscriptionController::class, 'index'])->name('v1.subscriptions.index'); Route::post('', [SubscriptionController::class, 'store'])->name('v1.subscriptions.store'); Route::get('/current', [SubscriptionController::class, 'current'])->name('v1.subscriptions.current'); + Route::get('/usage', [SubscriptionController::class, 'usage'])->name('v1.subscriptions.usage'); + Route::post('/export', [SubscriptionController::class, 'export'])->name('v1.subscriptions.export'); + Route::get('/export/{id}', [SubscriptionController::class, 'exportStatus'])->whereNumber('id')->name('v1.subscriptions.export.status'); Route::get('/{id}', [SubscriptionController::class, 'show'])->whereNumber('id')->name('v1.subscriptions.show'); Route::post('/{id}/cancel', [SubscriptionController::class, 'cancel'])->whereNumber('id')->name('v1.subscriptions.cancel'); Route::post('/{id}/renew', [SubscriptionController::class, 'renew'])->whereNumber('id')->name('v1.subscriptions.renew'); @@ -469,6 +472,7 @@ Route::post('/{id}/complete', [PaymentController::class, 'complete'])->whereNumber('id')->name('v1.payments.complete'); Route::post('/{id}/cancel', [PaymentController::class, 'cancel'])->whereNumber('id')->name('v1.payments.cancel'); Route::post('/{id}/refund', [PaymentController::class, 'refund'])->whereNumber('id')->name('v1.payments.refund'); + Route::get('/{id}/statement', [PaymentController::class, 'statement'])->whereNumber('id')->name('v1.payments.statement'); }); // Sale API (매출 관리)