From 5742f9a3e49fd3393c488ad4f7a3df1838676be2 Mon Sep 17 00:00:00 2001 From: hskwon Date: Mon, 22 Dec 2025 19:07:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(quote-formula):=20=EB=A7=A4=ED=95=91/?= =?UTF-8?q?=ED=92=88=EB=AA=A9=20=EA=B4=80=EB=A6=AC=20UI=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(Phase=202,=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 - 매핑(Mapping) 관리: - QuoteFormulaMappingController, QuoteFormulaMappingService 추가 - mappings-tab.blade.php 뷰 생성 - 매핑 CRUD 및 순서 변경 API Phase 3 - 품목(Item) 관리: - QuoteFormulaItemController, QuoteFormulaItemService 추가 - items-tab.blade.php 뷰 생성 - 품목 CRUD 및 순서 변경 API - 수량식/단가식 입력 지원 공통: - edit.blade.php에 매핑/품목 탭 연동 - routes/api.php에 API 엔드포인트 추가 --- .../Quote/QuoteFormulaItemController.php | 153 +++++++++ .../Quote/QuoteFormulaMappingController.php | 149 ++++++++ .../Quote/QuoteFormulaItemService.php | 97 ++++++ .../Quote/QuoteFormulaMappingService.php | 93 +++++ resources/views/quote-formulas/edit.blade.php | 66 +++- .../partials/items-tab.blade.php | 324 ++++++++++++++++++ .../partials/mappings-tab.blade.php | 321 +++++++++++++++++ routes/api.php | 20 ++ 8 files changed, 1205 insertions(+), 18 deletions(-) create mode 100644 app/Http/Controllers/Api/Admin/Quote/QuoteFormulaItemController.php create mode 100644 app/Http/Controllers/Api/Admin/Quote/QuoteFormulaMappingController.php create mode 100644 app/Services/Quote/QuoteFormulaItemService.php create mode 100644 app/Services/Quote/QuoteFormulaMappingService.php create mode 100644 resources/views/quote-formulas/partials/items-tab.blade.php create mode 100644 resources/views/quote-formulas/partials/mappings-tab.blade.php diff --git a/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaItemController.php b/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaItemController.php new file mode 100644 index 00000000..6f692fc7 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaItemController.php @@ -0,0 +1,153 @@ +itemService->getItemsByFormula($formulaId); + + return response()->json([ + 'success' => true, + 'data' => $items, + ]); + } + + /** + * 품목 상세 조회 + */ + public function show(int $formulaId, int $itemId): JsonResponse + { + // 수식 소속 확인 + if (! $this->itemService->belongsToFormula($itemId, $formulaId)) { + return response()->json([ + 'success' => false, + 'message' => '해당 수식에 속하지 않는 품목입니다.', + ], 404); + } + + $item = $this->itemService->getItemById($itemId); + + return response()->json([ + 'success' => true, + 'data' => $item, + ]); + } + + /** + * 품목 생성 + */ + public function store(Request $request, int $formulaId): JsonResponse + { + $validated = $request->validate([ + 'item_code' => 'required|string|max:50', + 'item_name' => 'required|string|max:200', + 'specification' => 'nullable|string|max:200', + 'unit' => 'nullable|string|max:20', + 'quantity_formula' => 'nullable|string|max:500', + 'unit_price_formula' => 'nullable|string|max:500', + 'sort_order' => 'nullable|integer|min:0', + ]); + + $item = $this->itemService->createItem($formulaId, $validated); + + return response()->json([ + 'success' => true, + 'message' => '품목이 추가되었습니다.', + 'data' => $item, + ]); + } + + /** + * 품목 수정 + */ + public function update(Request $request, int $formulaId, int $itemId): JsonResponse + { + // 수식 소속 확인 + if (! $this->itemService->belongsToFormula($itemId, $formulaId)) { + return response()->json([ + 'success' => false, + 'message' => '해당 수식에 속하지 않는 품목입니다.', + ], 404); + } + + $validated = $request->validate([ + 'item_code' => 'nullable|string|max:50', + 'item_name' => 'nullable|string|max:200', + 'specification' => 'nullable|string|max:200', + 'unit' => 'nullable|string|max:20', + 'quantity_formula' => 'nullable|string|max:500', + 'unit_price_formula' => 'nullable|string|max:500', + ]); + + $item = $this->itemService->updateItem($itemId, $validated); + + return response()->json([ + 'success' => true, + 'message' => '품목이 수정되었습니다.', + 'data' => $item, + ]); + } + + /** + * 품목 삭제 + */ + public function destroy(int $formulaId, int $itemId): JsonResponse + { + // 수식 소속 확인 + if (! $this->itemService->belongsToFormula($itemId, $formulaId)) { + return response()->json([ + 'success' => false, + 'message' => '해당 수식에 속하지 않는 품목입니다.', + ], 404); + } + + $this->itemService->deleteItem($itemId); + + return response()->json([ + 'success' => true, + 'message' => '품목이 삭제되었습니다.', + ]); + } + + /** + * 순서 변경 + */ + public function reorder(Request $request, int $formulaId): JsonResponse + { + $validated = $request->validate([ + 'item_ids' => 'required|array', + 'item_ids.*' => 'integer', + ]); + + // 모든 품목이 해당 수식에 속하는지 확인 + foreach ($validated['item_ids'] as $itemId) { + if (! $this->itemService->belongsToFormula($itemId, $formulaId)) { + return response()->json([ + 'success' => false, + 'message' => '유효하지 않은 품목 ID가 포함되어 있습니다.', + ], 400); + } + } + + $this->itemService->reorder($validated['item_ids']); + + return response()->json([ + 'success' => true, + 'message' => '순서가 변경되었습니다.', + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaMappingController.php b/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaMappingController.php new file mode 100644 index 00000000..7d0c64a0 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaMappingController.php @@ -0,0 +1,149 @@ +mappingService->getMappingsByFormula($formulaId); + + return response()->json([ + 'success' => true, + 'data' => $mappings, + ]); + } + + /** + * 매핑 상세 조회 + */ + public function show(int $formulaId, int $mappingId): JsonResponse + { + // 수식 소속 확인 + if (! $this->mappingService->belongsToFormula($mappingId, $formulaId)) { + return response()->json([ + 'success' => false, + 'message' => '해당 수식에 속하지 않는 매핑입니다.', + ], 404); + } + + $mapping = $this->mappingService->getMappingById($mappingId); + + return response()->json([ + 'success' => true, + 'data' => $mapping, + ]); + } + + /** + * 매핑 생성 + */ + public function store(Request $request, int $formulaId): JsonResponse + { + $validated = $request->validate([ + 'source_variable' => 'required|string|max:50', + 'source_value' => 'required|string|max:100', + 'result_value' => 'required|string', + 'result_type' => 'nullable|in:fixed,formula', + 'sort_order' => 'nullable|integer|min:0', + ]); + + $mapping = $this->mappingService->createMapping($formulaId, $validated); + + return response()->json([ + 'success' => true, + 'message' => '매핑이 추가되었습니다.', + 'data' => $mapping, + ]); + } + + /** + * 매핑 수정 + */ + public function update(Request $request, int $formulaId, int $mappingId): JsonResponse + { + // 수식 소속 확인 + if (! $this->mappingService->belongsToFormula($mappingId, $formulaId)) { + return response()->json([ + 'success' => false, + 'message' => '해당 수식에 속하지 않는 매핑입니다.', + ], 404); + } + + $validated = $request->validate([ + 'source_variable' => 'nullable|string|max:50', + 'source_value' => 'nullable|string|max:100', + 'result_value' => 'nullable|string', + 'result_type' => 'nullable|in:fixed,formula', + ]); + + $mapping = $this->mappingService->updateMapping($mappingId, $validated); + + return response()->json([ + 'success' => true, + 'message' => '매핑이 수정되었습니다.', + 'data' => $mapping, + ]); + } + + /** + * 매핑 삭제 + */ + public function destroy(int $formulaId, int $mappingId): JsonResponse + { + // 수식 소속 확인 + if (! $this->mappingService->belongsToFormula($mappingId, $formulaId)) { + return response()->json([ + 'success' => false, + 'message' => '해당 수식에 속하지 않는 매핑입니다.', + ], 404); + } + + $this->mappingService->deleteMapping($mappingId); + + return response()->json([ + 'success' => true, + 'message' => '매핑이 삭제되었습니다.', + ]); + } + + /** + * 순서 변경 + */ + public function reorder(Request $request, int $formulaId): JsonResponse + { + $validated = $request->validate([ + 'mapping_ids' => 'required|array', + 'mapping_ids.*' => 'integer', + ]); + + // 모든 매핑이 해당 수식에 속하는지 확인 + foreach ($validated['mapping_ids'] as $mappingId) { + if (! $this->mappingService->belongsToFormula($mappingId, $formulaId)) { + return response()->json([ + 'success' => false, + 'message' => '유효하지 않은 매핑 ID가 포함되어 있습니다.', + ], 400); + } + } + + $this->mappingService->reorder($validated['mapping_ids']); + + return response()->json([ + 'success' => true, + 'message' => '순서가 변경되었습니다.', + ]); + } +} diff --git a/app/Services/Quote/QuoteFormulaItemService.php b/app/Services/Quote/QuoteFormulaItemService.php new file mode 100644 index 00000000..29d3ed16 --- /dev/null +++ b/app/Services/Quote/QuoteFormulaItemService.php @@ -0,0 +1,97 @@ +orderBy('sort_order') + ->get(); + } + + /** + * 품목 상세 조회 + */ + public function getItemById(int $id): ?QuoteFormulaItem + { + return QuoteFormulaItem::find($id); + } + + /** + * 품목 생성 + */ + public function createItem(int $formulaId, array $data): QuoteFormulaItem + { + // 순서 자동 설정 + if (! isset($data['sort_order'])) { + $maxOrder = QuoteFormulaItem::where('formula_id', $formulaId)->max('sort_order') ?? 0; + $data['sort_order'] = $maxOrder + 1; + } + + return QuoteFormulaItem::create([ + 'formula_id' => $formulaId, + 'item_code' => $data['item_code'], + 'item_name' => $data['item_name'], + 'specification' => $data['specification'] ?? null, + 'unit' => $data['unit'] ?? 'EA', + 'quantity_formula' => $data['quantity_formula'] ?? '1', + 'unit_price_formula' => $data['unit_price_formula'] ?? null, + 'sort_order' => $data['sort_order'], + ]); + } + + /** + * 품목 수정 + */ + public function updateItem(int $itemId, array $data): QuoteFormulaItem + { + $item = QuoteFormulaItem::findOrFail($itemId); + + $item->update([ + 'item_code' => $data['item_code'] ?? $item->item_code, + 'item_name' => $data['item_name'] ?? $item->item_name, + 'specification' => $data['specification'] ?? $item->specification, + 'unit' => $data['unit'] ?? $item->unit, + 'quantity_formula' => $data['quantity_formula'] ?? $item->quantity_formula, + 'unit_price_formula' => $data['unit_price_formula'] ?? $item->unit_price_formula, + ]); + + return $item->fresh(); + } + + /** + * 품목 삭제 + */ + public function deleteItem(int $itemId): void + { + QuoteFormulaItem::destroy($itemId); + } + + /** + * 순서 변경 + */ + public function reorder(array $itemIds): void + { + foreach ($itemIds as $order => $id) { + QuoteFormulaItem::where('id', $id)->update(['sort_order' => $order + 1]); + } + } + + /** + * 품목이 수식에 속하는지 확인 + */ + public function belongsToFormula(int $itemId, int $formulaId): bool + { + return QuoteFormulaItem::where('id', $itemId) + ->where('formula_id', $formulaId) + ->exists(); + } +} diff --git a/app/Services/Quote/QuoteFormulaMappingService.php b/app/Services/Quote/QuoteFormulaMappingService.php new file mode 100644 index 00000000..d6ee4673 --- /dev/null +++ b/app/Services/Quote/QuoteFormulaMappingService.php @@ -0,0 +1,93 @@ +orderBy('sort_order') + ->get(); + } + + /** + * 매핑 상세 조회 + */ + public function getMappingById(int $id): ?QuoteFormulaMapping + { + return QuoteFormulaMapping::find($id); + } + + /** + * 매핑 생성 + */ + public function createMapping(int $formulaId, array $data): QuoteFormulaMapping + { + // 순서 자동 설정 + if (! isset($data['sort_order'])) { + $maxOrder = QuoteFormulaMapping::where('formula_id', $formulaId)->max('sort_order') ?? 0; + $data['sort_order'] = $maxOrder + 1; + } + + return QuoteFormulaMapping::create([ + 'formula_id' => $formulaId, + 'source_variable' => $data['source_variable'], + 'source_value' => $data['source_value'], + 'result_value' => $data['result_value'], + 'result_type' => $data['result_type'] ?? 'fixed', + 'sort_order' => $data['sort_order'], + ]); + } + + /** + * 매핑 수정 + */ + public function updateMapping(int $mappingId, array $data): QuoteFormulaMapping + { + $mapping = QuoteFormulaMapping::findOrFail($mappingId); + + $mapping->update([ + 'source_variable' => $data['source_variable'] ?? $mapping->source_variable, + 'source_value' => $data['source_value'] ?? $mapping->source_value, + 'result_value' => $data['result_value'] ?? $mapping->result_value, + 'result_type' => $data['result_type'] ?? $mapping->result_type, + ]); + + return $mapping->fresh(); + } + + /** + * 매핑 삭제 + */ + public function deleteMapping(int $mappingId): void + { + QuoteFormulaMapping::destroy($mappingId); + } + + /** + * 순서 변경 + */ + public function reorder(array $mappingIds): void + { + foreach ($mappingIds as $order => $id) { + QuoteFormulaMapping::where('id', $id)->update(['sort_order' => $order + 1]); + } + } + + /** + * 매핑이 수식에 속하는지 확인 + */ + public function belongsToFormula(int $mappingId, int $formulaId): bool + { + return QuoteFormulaMapping::where('id', $mappingId) + ->where('formula_id', $formulaId) + ->exists(); + } +} diff --git a/resources/views/quote-formulas/edit.blade.php b/resources/views/quote-formulas/edit.blade.php index 1532b1ea..5fb6ddbf 100644 --- a/resources/views/quote-formulas/edit.blade.php +++ b/resources/views/quote-formulas/edit.blade.php @@ -47,14 +47,14 @@ class="py-4 px-6 border-b-2 font-medium text-sm transition-colors flex items-cen class="py-4 px-6 border-b-2 font-medium text-sm transition-colors flex items-center gap-2" x-show="formula?.type === 'mapping'"> 매핑 설정 - + @@ -71,26 +71,14 @@ class="py-4 px-6 border-b-2 font-medium text-sm transition-colors flex items-cen @include('quote-formulas.partials.ranges-tab') - +
-
- - - -

매핑 관리 UI는 준비 중입니다.

-

Phase 2에서 구현 예정

-
+ @include('quote-formulas.partials.mappings-tab')
- +
-
- - - -

품목 관리 UI는 준비 중입니다.

-

Phase 3에서 구현 예정

-
+ @include('quote-formulas.partials.items-tab')
@@ -107,6 +95,8 @@ function formulaEditor() { categories: [], availableVariables: [], ranges: [], + mappings: [], + items: [], form: { category_id: '', type: '', @@ -180,6 +170,16 @@ function formulaEditor() { if (result.data.type === 'range') { await this.loadRanges(); } + + // 매핑 데이터 로드 + if (result.data.type === 'mapping') { + await this.loadMappings(); + } + + // 품목 데이터 로드 + if (result.data.output_type === 'item') { + await this.loadItems(); + } } else { showToast(result.message || '데이터를 불러오는데 실패했습니다.', 'error'); window.location.href = '{{ route("quote-formulas.index") }}'; @@ -205,6 +205,36 @@ function formulaEditor() { } }, + async loadMappings() { + const formulaId = {{ $id }}; + try { + const res = await fetch(`/api/admin/quote-formulas/formulas/${formulaId}/mappings`, { + headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' } + }); + const result = await res.json(); + if (result.success) { + this.mappings = result.data; + } + } catch (err) { + console.error('매핑 로드 실패:', err); + } + }, + + async loadItems() { + const formulaId = {{ $id }}; + try { + const res = await fetch(`/api/admin/quote-formulas/formulas/${formulaId}/items`, { + headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' } + }); + const result = await res.json(); + if (result.success) { + this.items = result.data; + } + } catch (err) { + console.error('품목 로드 실패:', err); + } + }, + async saveFormula() { const formulaId = {{ $id }}; const errorDiv = document.getElementById('errorMessage'); diff --git a/resources/views/quote-formulas/partials/items-tab.blade.php b/resources/views/quote-formulas/partials/items-tab.blade.php new file mode 100644 index 00000000..b29449e6 --- /dev/null +++ b/resources/views/quote-formulas/partials/items-tab.blade.php @@ -0,0 +1,324 @@ +{{-- 품목 설정 탭 --}} +
+ +
+
+

품목 설정

+

수식 결과로 생성되는 출력 품목을 정의합니다.

+
+ +
+ + +
+ + + + + + + + + + + + + + + + +
#품목코드품목명규격단위수량식단가식액션
+
+ + +
+ + + +

설정된 품목이 없습니다

+

위의 "품목 추가" 버튼을 클릭하여 첫 번째 품목을 추가하세요.

+
+ + +
+

품목 설정 가이드

+
    +
  • item_code: 품목 고유 코드 (예: PT-MOTOR-150)
  • +
  • item_name: 품목명 (예: 개폐전동기 150kg)
  • +
  • specification: 규격 (예: 150K(S))
  • +
  • quantity_formula: 수량 계산식 (예: 1, 2, CEIL(H1/3000))
  • +
  • unit_price_formula: 단가 계산식 (빈 값은 마스터 가격 참조)
  • +
+
+

예시: 모터 품목

+ + + + + + +
품목코드PT-MOTOR-150
품목명개폐전동기 150kg
규격150K(S)
수량식1
단가식285000 (또는 마스터 참조)
+
+
+ + +
+
+ +
+ + +
+

+ +
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ + +

정수 또는 수식 (변수 사용 가능: W0, H0, S, H1, K 등)

+
+ + +
+ + +

빈 값이면 5130 마스터 가격을 참조합니다.

+
+ +
+ + +
+
+
+
+
+
+ + \ No newline at end of file diff --git a/resources/views/quote-formulas/partials/mappings-tab.blade.php b/resources/views/quote-formulas/partials/mappings-tab.blade.php new file mode 100644 index 00000000..bf5dc00b --- /dev/null +++ b/resources/views/quote-formulas/partials/mappings-tab.blade.php @@ -0,0 +1,321 @@ +{{-- 매핑 설정 탭 --}} +
+ +
+
+

매핑 설정

+

소스 변수의 값에 따라 특정 결과를 반환합니다.

+
+ +
+ + +
+ + +

매핑에 사용할 변수명을 입력하세요 (예: 제어기 유형 CONTROL_TYPE)

+
+ + +
+ 소스 변수: + +
+ + +
+ + + + + + + + + + + + + +
#소스값결과값유형액션
+
+ + +
+ + + +

설정된 매핑이 없습니다

+

위의 "매핑 추가" 버튼을 클릭하여 첫 번째 매핑을 추가하세요.

+
+ + +
+

매핑 설정 가이드

+
    +
  • source_variable: 매핑 조건에 사용할 변수명
  • +
  • source_value: 변수의 특정 값 (예: EMB, EXP)
  • +
  • result_value: 해당 값일 때 반환할 결과
  • +
+
+

예시: 제어기 유형 매핑 (소스 변수: CONTROL_TYPE)

+ + + + +
EMB매립형
EXP노출형
BOX_1P콘트롤박스
+
+
+ + +
+
+ +
+ + +
+

+ +
+ +
+ + +
+ +
+ + +

변수()가 이 값일 때 매핑이 적용됩니다.

+
+ +
+ +
+ + +
+
+ +
+ + +

+ 수식에서 다른 변수를 참조할 수 있습니다. +

+
+ +
+ + +
+
+
+
+
+
+ + \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 45dfc3dc..4bbb1552 100644 --- a/routes/api.php +++ b/routes/api.php @@ -557,6 +557,26 @@ Route::delete('/{rangeId}', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaRangeController::class, 'destroy'])->name('destroy'); Route::post('/reorder', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaRangeController::class, 'reorder'])->name('reorder'); }); + + // 수식별 매핑 관리 API + Route::prefix('{formulaId}/mappings')->name('mappings.')->group(function () { + Route::get('/', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaMappingController::class, 'index'])->name('index'); + Route::post('/', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaMappingController::class, 'store'])->name('store'); + Route::get('/{mappingId}', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaMappingController::class, 'show'])->name('show'); + Route::put('/{mappingId}', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaMappingController::class, 'update'])->name('update'); + Route::delete('/{mappingId}', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaMappingController::class, 'destroy'])->name('destroy'); + Route::post('/reorder', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaMappingController::class, 'reorder'])->name('reorder'); + }); + + // 수식별 품목 관리 API + Route::prefix('{formulaId}/items')->name('items.')->group(function () { + Route::get('/', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaItemController::class, 'index'])->name('index'); + Route::post('/', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaItemController::class, 'store'])->name('store'); + Route::get('/{itemId}', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaItemController::class, 'show'])->name('show'); + Route::put('/{itemId}', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaItemController::class, 'update'])->name('update'); + Route::delete('/{itemId}', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaItemController::class, 'destroy'])->name('destroy'); + Route::post('/reorder', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaItemController::class, 'reorder'])->name('reorder'); + }); }); }); });