diff --git a/app/Http/Controllers/Sales/SalesProductController.php b/app/Http/Controllers/Sales/SalesProductController.php new file mode 100644 index 00000000..f24baa66 --- /dev/null +++ b/app/Http/Controllers/Sales/SalesProductController.php @@ -0,0 +1,268 @@ +ordered() + ->with(['products' => fn($q) => $q->ordered()]) + ->get(); + + $currentCategoryCode = $request->input('category', $categories->first()?->code); + $currentCategory = $categories->firstWhere('code', $currentCategoryCode) ?? $categories->first(); + + return view('sales.products.index', compact('categories', 'currentCategory')); + } + + /** + * 상품 목록 (HTMX용) + */ + public function productList(Request $request): View + { + $categoryCode = $request->input('category'); + $category = SalesProductCategory::where('code', $categoryCode) + ->with(['products' => fn($q) => $q->ordered()]) + ->first(); + + return view('sales.products.partials.product-list', compact('category')); + } + + /** + * 상품 저장 + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'category_id' => 'required|exists:sales_product_categories,id', + 'code' => 'required|string|max:50', + 'name' => 'required|string|max:100', + 'description' => 'nullable|string', + 'development_fee' => 'required|numeric|min:0', + 'subscription_fee' => 'required|numeric|min:0', + 'commission_rate' => 'nullable|numeric|min:0|max:100', + 'allow_flexible_pricing' => 'boolean', + 'is_required' => 'boolean', + ]); + + // 코드 중복 체크 + $exists = SalesProduct::where('category_id', $validated['category_id']) + ->where('code', $validated['code']) + ->exists(); + + if ($exists) { + return response()->json([ + 'success' => false, + 'message' => '이미 존재하는 상품 코드입니다.', + ], 422); + } + + // 순서 설정 (마지막) + $maxOrder = SalesProduct::where('category_id', $validated['category_id'])->max('display_order') ?? 0; + $validated['display_order'] = $maxOrder + 1; + $validated['commission_rate'] = $validated['commission_rate'] ?? 25.00; + + $product = SalesProduct::create($validated); + + return response()->json([ + 'success' => true, + 'message' => '상품이 등록되었습니다.', + 'product' => $product, + ]); + } + + /** + * 상품 수정 + */ + public function update(Request $request, int $id): JsonResponse + { + $product = SalesProduct::findOrFail($id); + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:100', + 'description' => 'nullable|string', + 'development_fee' => 'sometimes|numeric|min:0', + 'subscription_fee' => 'sometimes|numeric|min:0', + 'commission_rate' => 'nullable|numeric|min:0|max:100', + 'allow_flexible_pricing' => 'boolean', + 'is_required' => 'boolean', + 'is_active' => 'boolean', + ]); + + $product->update($validated); + + return response()->json([ + 'success' => true, + 'message' => '상품이 수정되었습니다.', + 'product' => $product->fresh(), + ]); + } + + /** + * 상품 삭제 (soft delete) + */ + public function destroy(int $id): JsonResponse + { + $product = SalesProduct::findOrFail($id); + $product->delete(); + + return response()->json([ + 'success' => true, + 'message' => '상품이 삭제되었습니다.', + ]); + } + + /** + * 활성화 토글 + */ + public function toggleActive(int $id): JsonResponse + { + $product = SalesProduct::findOrFail($id); + $product->update(['is_active' => !$product->is_active]); + + return response()->json([ + 'success' => true, + 'message' => $product->is_active ? '상품이 활성화되었습니다.' : '상품이 비활성화되었습니다.', + 'is_active' => $product->is_active, + ]); + } + + /** + * 순서 변경 + */ + public function reorder(Request $request): JsonResponse + { + $validated = $request->validate([ + 'orders' => 'required|array', + 'orders.*.id' => 'required|exists:sales_products,id', + 'orders.*.order' => 'required|integer|min:0', + ]); + + foreach ($validated['orders'] as $item) { + SalesProduct::where('id', $item['id'])->update(['display_order' => $item['order']]); + } + + return response()->json([ + 'success' => true, + 'message' => '순서가 변경되었습니다.', + ]); + } + + // ==================== 카테고리 관리 ==================== + + /** + * 카테고리 목록 + */ + public function categories(): JsonResponse + { + $categories = SalesProductCategory::ordered()->get(); + + return response()->json([ + 'success' => true, + 'data' => $categories, + ]); + } + + /** + * 카테고리 생성 + */ + public function storeCategory(Request $request): JsonResponse + { + $validated = $request->validate([ + 'code' => 'required|string|max:50|unique:sales_product_categories,code', + 'name' => 'required|string|max:100', + 'description' => 'nullable|string', + 'base_storage' => 'nullable|string|max:20', + ]); + + $maxOrder = SalesProductCategory::max('display_order') ?? 0; + $validated['display_order'] = $maxOrder + 1; + + $category = SalesProductCategory::create($validated); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 생성되었습니다.', + 'category' => $category, + ]); + } + + /** + * 카테고리 수정 + */ + public function updateCategory(Request $request, int $id): JsonResponse + { + $category = SalesProductCategory::findOrFail($id); + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:100', + 'description' => 'nullable|string', + 'base_storage' => 'nullable|string|max:20', + 'is_active' => 'boolean', + ]); + + $category->update($validated); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 수정되었습니다.', + 'category' => $category->fresh(), + ]); + } + + /** + * 카테고리 삭제 + */ + public function deleteCategory(int $id): JsonResponse + { + $category = SalesProductCategory::findOrFail($id); + + // 상품이 있으면 삭제 불가 + if ($category->products()->exists()) { + return response()->json([ + 'success' => false, + 'message' => '상품이 있는 카테고리는 삭제할 수 없습니다.', + ], 422); + } + + $category->delete(); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 삭제되었습니다.', + ]); + } + + // ==================== API (영업 시나리오용) ==================== + + /** + * 상품 목록 API (카테고리 포함) + */ + public function getProductsApi(): JsonResponse + { + $categories = SalesProductCategory::active() + ->ordered() + ->with(['products' => fn($q) => $q->active()->ordered()]) + ->get(); + + return response()->json([ + 'success' => true, + 'data' => $categories, + ]); + } +} diff --git a/app/Models/Sales/SalesContractProduct.php b/app/Models/Sales/SalesContractProduct.php new file mode 100644 index 00000000..1158c34e --- /dev/null +++ b/app/Models/Sales/SalesContractProduct.php @@ -0,0 +1,106 @@ + 'integer', + 'management_id' => 'integer', + 'category_id' => 'integer', + 'product_id' => 'integer', + 'development_fee' => 'decimal:2', + 'subscription_fee' => 'decimal:2', + 'discount_rate' => 'decimal:2', + 'created_by' => 'integer', + ]; + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 영업관리 관계 + */ + public function management(): BelongsTo + { + return $this->belongsTo(SalesTenantManagement::class, 'management_id'); + } + + /** + * 카테고리 관계 + */ + public function category(): BelongsTo + { + return $this->belongsTo(SalesProductCategory::class, 'category_id'); + } + + /** + * 상품 관계 + */ + public function product(): BelongsTo + { + return $this->belongsTo(SalesProduct::class, 'product_id'); + } + + /** + * 등록자 관계 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 테넌트별 총 개발비 + */ + public static function getTotalDevelopmentFee(int $tenantId): float + { + return self::where('tenant_id', $tenantId)->sum('development_fee') ?? 0; + } + + /** + * 테넌트별 총 구독료 + */ + public static function getTotalSubscriptionFee(int $tenantId): float + { + return self::where('tenant_id', $tenantId)->sum('subscription_fee') ?? 0; + } +} diff --git a/app/Models/Sales/SalesProduct.php b/app/Models/Sales/SalesProduct.php new file mode 100644 index 00000000..5c6856d2 --- /dev/null +++ b/app/Models/Sales/SalesProduct.php @@ -0,0 +1,103 @@ + 'integer', + 'development_fee' => 'decimal:2', + 'subscription_fee' => 'decimal:2', + 'commission_rate' => 'decimal:2', + 'allow_flexible_pricing' => 'boolean', + 'is_required' => 'boolean', + 'display_order' => 'integer', + 'is_active' => 'boolean', + ]; + + /** + * 카테고리 관계 + */ + public function category(): BelongsTo + { + return $this->belongsTo(SalesProductCategory::class, 'category_id'); + } + + /** + * 활성 상품 스코프 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * 정렬 스코프 + */ + public function scopeOrdered($query) + { + return $query->orderBy('display_order')->orderBy('name'); + } + + /** + * 수당 계산 (개발비 기준) + */ + public function getCommissionAttribute(): float + { + return $this->development_fee * ($this->commission_rate / 100); + } + + /** + * 포맷된 개발비 + */ + public function getFormattedDevelopmentFeeAttribute(): string + { + return '₩' . number_format($this->development_fee); + } + + /** + * 포맷된 구독료 + */ + public function getFormattedSubscriptionFeeAttribute(): string + { + return '₩' . number_format($this->subscription_fee); + } +} diff --git a/app/Models/Sales/SalesProductCategory.php b/app/Models/Sales/SalesProductCategory.php new file mode 100644 index 00000000..ba8cd2ce --- /dev/null +++ b/app/Models/Sales/SalesProductCategory.php @@ -0,0 +1,71 @@ + 'integer', + 'is_active' => 'boolean', + ]; + + /** + * 상품 관계 + */ + public function products(): HasMany + { + return $this->hasMany(SalesProduct::class, 'category_id'); + } + + /** + * 활성 상품만 + */ + public function activeProducts(): HasMany + { + return $this->products()->where('is_active', true)->orderBy('display_order'); + } + + /** + * 활성 카테고리 스코프 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * 정렬 스코프 + */ + public function scopeOrdered($query) + { + return $query->orderBy('display_order')->orderBy('name'); + } +} diff --git a/resources/views/sales/products/index.blade.php b/resources/views/sales/products/index.blade.php new file mode 100644 index 00000000..3c736dc0 --- /dev/null +++ b/resources/views/sales/products/index.blade.php @@ -0,0 +1,372 @@ +@extends('layouts.app') + +@section('title', '상품관리') + +@section('content') +
SAM 솔루션 상품 및 요금 설정
+{{ $product->code }}
+{{ $product->description }}
+ @endif + + {{-- 가격 정보 --}} +등록된 상품이 없습니다
+상품 추가 버튼을 클릭하여 새 상품을 등록하세요
+