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 @@ +{{-- 품목 설정 탭 --}} +
+ +
+
+

품목 설정

+

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

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

설정된 품목이 없습니다

+

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

+
+ + +
+

품목 설정 가이드

+ +
+

예시: 모터 품목

+ + + + + + +
품목코드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)

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

설정된 매핑이 없습니다

+

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

+
+ + +
+

매핑 설정 가이드

+ +
+

예시: 제어기 유형 매핑 (소스 변수: 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'); + }); }); }); });