From 23aa38baef1e3898bdca4d1f8ebb1648a2f8479c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 14 Mar 2026 16:53:13 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[sales]=20=EC=98=81=EC=97=85=20?= =?UTF-8?q?=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=97=90=20=ED=94=84=EB=A1=9C=EB=AA=A8?= =?UTF-8?q?=EC=85=98=20=ED=95=A0=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 개발비 할인 (비율/금액/전액면제), 구독료 할인, 무료기간, 메모 - 상품 변경 시 프로모션 최대값 자동 조절 (clampPromoValues) - 프로모션 데이터 management options에 저장/로드 - 합계 영역에 프로모션 적용 금액, 절감액 표시 --- .../Sales/SalesContractController.php | 17 + app/Models/Sales/SalesTenantManagement.php | 3 + .../partials/product-selection.blade.php | 294 +++++++++++++++++- 3 files changed, 309 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/Sales/SalesContractController.php b/app/Http/Controllers/Sales/SalesContractController.php index ed9fe6e9..92115ba0 100644 --- a/app/Http/Controllers/Sales/SalesContractController.php +++ b/app/Http/Controllers/Sales/SalesContractController.php @@ -30,6 +30,13 @@ public function saveProducts(Request $request): JsonResponse 'products.*.category_id' => 'required|exists:codebridge.sales_product_categories,id', 'products.*.registration_fee' => 'required|numeric|min:0', 'products.*.subscription_fee' => 'required|numeric|min:0', + 'promotion' => 'nullable|array', + 'promotion.dev_discount_type' => 'nullable|string|in:percent,amount', + 'promotion.dev_discount_amount' => 'nullable|numeric|min:0', + 'promotion.dev_waive' => 'nullable|boolean', + 'promotion.sub_discount_percent' => 'nullable|numeric|min:0|max:50', + 'promotion.free_months' => 'nullable|integer|in:0,1,2,3,6', + 'promotion.note' => 'nullable|string|max:200', ]); // tenant_id 또는 prospect_id 중 하나는 필수 @@ -79,6 +86,16 @@ public function saveProducts(Request $request): JsonResponse $totalRegistrationFee = SalesContractProduct::where('management_id', $management->id) ->sum('registration_fee'); $management->update(['total_registration_fee' => $totalRegistrationFee]); + + // 프로모션 저장 + if (! empty($validated['promotion'])) { + $opts = $management->options ?? []; + $opts['promotion'] = array_merge($validated['promotion'], [ + 'applied_at' => now()->toDateTimeString(), + 'applied_by_user_id' => auth()->id(), + ]); + $management->update(['options' => $opts]); + } }); return response()->json([ diff --git a/app/Models/Sales/SalesTenantManagement.php b/app/Models/Sales/SalesTenantManagement.php index 4e405239..27c3ce26 100644 --- a/app/Models/Sales/SalesTenantManagement.php +++ b/app/Models/Sales/SalesTenantManagement.php @@ -38,6 +38,7 @@ class SalesTenantManagement extends Model use SoftDeletes; protected $connection = 'codebridge'; + protected $table = 'sales_tenant_managements'; protected $fillable = [ @@ -72,6 +73,7 @@ class SalesTenantManagement extends Model 'balance_paid_date', 'balance_status', 'total_registration_fee', + 'options', ]; protected $casts = [ @@ -94,6 +96,7 @@ class SalesTenantManagement extends Model 'balance_amount' => 'decimal:2', 'balance_paid_date' => 'date', 'total_registration_fee' => 'decimal:2', + 'options' => 'array', ]; /** diff --git a/resources/views/sales/modals/partials/product-selection.blade.php b/resources/views/sales/modals/partials/product-selection.blade.php index 96c81301..6f365ca7 100644 --- a/resources/views/sales/modals/partials/product-selection.blade.php +++ b/resources/views/sales/modals/partials/product-selection.blade.php @@ -20,6 +20,7 @@ $management = SalesTenantManagement::where('tenant_id', $entity->id)->first(); } $managementId = $management?->id; + $savedPromotion = $management?->options['promotion'] ?? null; // 이미 선택된 상품들 조회 (management_id 기반) $selectedProducts = []; @@ -208,6 +209,140 @@ class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-i @endforeach + {{-- 영업 재량 할인/프로모션 --}} +
+
+
+ + + +
+

영업 재량 할인/프로모션

+

파트너 재량으로 추가 할인 적용

+
+
+
+ + + + +
+
+
+ {{-- 개발비 할인 --}} +
+ +
+ +
+ + +
+
+
+ + (100% 할인 적용) +
+
+ + {{-- 구독료 할인 --}} +
+
+ 구독료 할인 + +
+ +
+ 0% + +
+
+ + {{-- 무료 기간 --}} +
+ 무료 사용 기간 +
+ +
+

+ 첫 개월 구독료가 면제됩니다 +

+
+ + {{-- 프로모션 메모 --}} +
+ + +
+ + {{-- 프로모션 요약 --}} +
+

적용 프로모션 요약

+

+ 개발비 전액 면제 +

+

+ 개발비 + +

+

+ 구독료 +

+

+ 구독료 +

+
+ + {{-- 초기화 버튼 --}} +
+ +
+
+
+ {{-- 합계 영역 (전체 카테고리 합산) --}}
@@ -218,18 +353,55 @@ class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-i

총 개발비

-

+

+

월 구독료

-

+

+
+ {{-- 프로모션 할인 금액 --}} + {{-- 1년차 총 비용 --}} -
- 1년차 총 비용 - +
+
+ + 1년차 총 비용 + + + +
+
+ {{-- 프로모션 메모 --}} +
@@ -312,6 +484,15 @@ function productSelection() { entityId: {{ $entity->id }}, managementId: {{ $managementId ?? 'null' }}, + // 프로모션 + showPromo: {{ $savedPromotion ? 'true' : 'false' }}, + promoDevType: '{{ $savedPromotion['dev_discount_type'] ?? 'percent' }}', + promoDevAmount: {{ $savedPromotion['dev_discount_amount'] ?? 0 }}, + promoDevWaive: {{ !empty($savedPromotion['dev_waive']) ? 'true' : 'false' }}, + promoSubPercent: {{ $savedPromotion['sub_discount_percent'] ?? 0 }}, + promoFreeMonths: {{ $savedPromotion['free_months'] ?? 0 }}, + promoNote: '{{ addslashes($savedPromotion['note'] ?? '') }}', + isSelected(id) { return this.selected.hasOwnProperty(id); }, @@ -330,6 +511,7 @@ function productSelection() { [id]: { regFee: p.regFee, subFee: p.subFee } }; } + this.$nextTick(() => this.clampPromoValues()); }, getRegFee(id) { @@ -355,6 +537,7 @@ function productSelection() { } this.selected = { ...this.selected, [id]: update }; + this.$nextTick(() => this.clampPromoValues()); }, toggleLinkedPricing() { @@ -403,6 +586,95 @@ function productSelection() { return '₩' + Number(value).toLocaleString(); }, + // --- 프로모션 계산 --- + hasAnyPromo() { + return this.promoDevWaive || this.promoDevAmount > 0 || this.promoSubPercent > 0 || this.promoFreeMonths > 0; + }, + + promoDevDiscountMax() { + const totalReg = this.totalRegFee(); + // 최저 개발비 합산 (min_development_fee가 있는 상품들의 최저가 합산) + let totalMin = 0; + Object.keys(this.selected).forEach(id => { + const p = productMap[id]; + if (p) totalMin += p.minDevFee; + }); + const maxDiscount = totalReg - totalMin; + if (this.promoDevType === 'percent') { + if (totalReg <= 0) return 0; + return Math.min(50, Math.floor(maxDiscount / totalReg * 100 / 5) * 5); + } + return Math.max(0, maxDiscount); + }, + + promoSubMaxPercent() { + // 최저 구독료 기준으로 최대 할인율 계산 + const totalSub = this.totalSubFee(); + if (totalSub <= 0) return 0; + let totalMinSub = 0; + Object.keys(this.selected).forEach(id => { + const p = productMap[id]; + if (p) totalMinSub += p.minSubFee; + }); + const maxPercent = Math.floor((1 - totalMinSub / totalSub) * 100 / 5) * 5; + return Math.min(50, Math.max(0, maxPercent)); + }, + + promoRegDiscount() { + const totalReg = this.totalRegFee(); + if (this.promoDevWaive) { + let totalMin = 0; + Object.keys(this.selected).forEach(id => { + const p = productMap[id]; + if (p) totalMin += p.minDevFee; + }); + return Math.max(0, totalReg - totalMin); + } + if (this.promoDevAmount <= 0) return 0; + if (this.promoDevType === 'percent') { + return Math.floor(totalReg * this.promoDevAmount / 100); + } + return Math.min(this.promoDevAmount, totalReg); + }, + + promoAdjustedRegFee() { + return Math.max(0, this.totalRegFee() - this.promoRegDiscount()); + }, + + promoAdjustedSubFee() { + const base = this.totalSubFee(); + if (this.promoSubPercent <= 0) return base; + return Math.floor(base * (100 - this.promoSubPercent) / 100); + }, + + promoFirstYearTotal() { + const regFee = this.promoAdjustedRegFee(); + const subFee = this.promoAdjustedSubFee(); + const paidMonths = 12 - this.promoFreeMonths; + return regFee + subFee * paidMonths; + }, + + promoTotalSaving() { + const originalTotal = this.totalRegFee() + this.totalSubFee() * 12; + return originalTotal - this.promoFirstYearTotal(); + }, + + resetPromo() { + this.promoDevType = 'percent'; + this.promoDevAmount = 0; + this.promoDevWaive = false; + this.promoSubPercent = 0; + this.promoFreeMonths = 0; + this.promoNote = ''; + }, + + clampPromoValues() { + const devMax = this.promoDevDiscountMax(); + if (this.promoDevAmount > devMax) this.promoDevAmount = devMax; + const subMax = this.promoSubMaxPercent(); + if (this.promoSubPercent > subMax) this.promoSubPercent = subMax; + }, + async saveSelection() { this.saving = true; try { @@ -434,6 +706,18 @@ function productSelection() { } requestData.management_id = this.managementId; + // 프로모션 정보 포함 + if (this.hasAnyPromo()) { + requestData.promotion = { + dev_discount_type: this.promoDevType, + dev_discount_amount: this.promoDevAmount, + dev_waive: this.promoDevWaive, + sub_discount_percent: this.promoSubPercent, + free_months: this.promoFreeMonths, + note: this.promoNote, + }; + } + const response = await fetch('/sales/contracts/products', { method: 'POST', headers: {