diff --git a/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaCategoryController.php b/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaCategoryController.php new file mode 100644 index 00000000..32b42a44 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaCategoryController.php @@ -0,0 +1,201 @@ +only(['search', 'is_active', 'trashed', 'sort_by', 'sort_direction']); + $categories = $this->categoryService->getCategories($filters, 15); + + // HTMX 요청이면 HTML 파셜 반환 + if ($request->header('HX-Request')) { + return view('quote-formulas.categories.partials.table', compact('categories')); + } + + return response()->json([ + 'success' => true, + 'data' => $categories, + ]); + } + + /** + * 카테고리 통계 + */ + public function stats(): JsonResponse + { + $stats = $this->categoryService->getCategoryStats(); + + return response()->json([ + 'success' => true, + 'data' => $stats, + ]); + } + + /** + * 카테고리 상세 조회 + */ + public function show(int $id): JsonResponse + { + $category = $this->categoryService->getCategoryById($id, true); + + if (! $category) { + return response()->json([ + 'success' => false, + 'message' => '카테고리를 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $category, + ]); + } + + /** + * 카테고리 생성 + */ + public function store(StoreQuoteFormulaCategoryRequest $request): JsonResponse + { + $validated = $request->validated(); + + // 코드 중복 체크 + if ($this->categoryService->isCodeExists($validated['code'])) { + return response()->json([ + 'success' => false, + 'message' => '이미 사용 중인 카테고리 코드입니다.', + ], 422); + } + + $category = $this->categoryService->createCategory($validated); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 생성되었습니다.', + 'data' => $category, + ]); + } + + /** + * 카테고리 수정 + */ + public function update(UpdateQuoteFormulaCategoryRequest $request, int $id): JsonResponse + { + $validated = $request->validated(); + + // 코드 중복 체크 (자신 제외) + if (isset($validated['code']) && $this->categoryService->isCodeExists($validated['code'], $id)) { + return response()->json([ + 'success' => false, + 'message' => '이미 사용 중인 카테고리 코드입니다.', + ], 422); + } + + $this->categoryService->updateCategory($id, $validated); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 수정되었습니다.', + ]); + } + + /** + * 카테고리 삭제 (Soft Delete) + */ + public function destroy(int $id): JsonResponse + { + $this->categoryService->deleteCategory($id); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 삭제되었습니다.', + ]); + } + + /** + * 카테고리 복원 + */ + public function restore(int $id): JsonResponse + { + $this->categoryService->restoreCategory($id); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 복원되었습니다.', + ]); + } + + /** + * 카테고리 영구 삭제 + */ + public function forceDestroy(int $id): JsonResponse + { + $this->categoryService->forceDeleteCategory($id); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 영구 삭제되었습니다.', + ]); + } + + /** + * 카테고리 활성/비활성 토글 + */ + public function toggleActive(int $id): JsonResponse + { + $category = $this->categoryService->toggleActive($id); + + return response()->json([ + 'success' => true, + 'message' => $category->is_active ? '카테고리가 활성화되었습니다.' : '카테고리가 비활성화되었습니다.', + 'data' => ['is_active' => $category->is_active], + ]); + } + + /** + * 카테고리 순서 변경 + */ + public function reorder(Request $request): JsonResponse + { + $validated = $request->validate([ + 'category_ids' => 'required|array', + 'category_ids.*' => 'integer|exists:quote_formula_categories,id', + ]); + + $this->categoryService->reorder($validated['category_ids']); + + return response()->json([ + 'success' => true, + 'message' => '순서가 변경되었습니다.', + ]); + } + + /** + * 드롭다운용 카테고리 목록 + */ + public function dropdown(): JsonResponse + { + $categories = $this->categoryService->getActiveCategories(); + + return response()->json([ + 'success' => true, + 'data' => $categories, + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaController.php b/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaController.php new file mode 100644 index 00000000..d912f781 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaController.php @@ -0,0 +1,311 @@ +only(['search', 'category_id', 'type', 'is_active', 'trashed', 'sort_by', 'sort_direction']); + $formulas = $this->formulaService->getFormulas($filters, 15); + + // HTMX 요청이면 HTML 파셜 반환 + if ($request->header('HX-Request')) { + return view('quote-formulas.partials.table', compact('formulas')); + } + + return response()->json([ + 'success' => true, + 'data' => $formulas, + ]); + } + + /** + * 수식 통계 + */ + public function stats(): JsonResponse + { + $stats = $this->formulaService->getFormulaStats(); + + return response()->json([ + 'success' => true, + 'data' => $stats, + ]); + } + + /** + * 수식 상세 조회 + */ + public function show(int $id): JsonResponse + { + $formula = $this->formulaService->getFormulaById($id, true); + + if (! $formula) { + return response()->json([ + 'success' => false, + 'message' => '수식을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $formula, + ]); + } + + /** + * 수식 생성 + */ + public function store(StoreQuoteFormulaRequest $request): JsonResponse + { + $validated = $request->validated(); + + // 변수명 중복 체크 + if ($this->formulaService->isVariableExists($validated['variable'])) { + return response()->json([ + 'success' => false, + 'message' => '이미 사용 중인 변수명입니다.', + ], 422); + } + + $formula = $this->formulaService->createFormula($validated); + + return response()->json([ + 'success' => true, + 'message' => '수식이 생성되었습니다.', + 'data' => $formula, + ]); + } + + /** + * 수식 수정 + */ + public function update(UpdateQuoteFormulaRequest $request, int $id): JsonResponse + { + $validated = $request->validated(); + + // 변수명 중복 체크 (자신 제외) + if (isset($validated['variable']) && $this->formulaService->isVariableExists($validated['variable'], $id)) { + return response()->json([ + 'success' => false, + 'message' => '이미 사용 중인 변수명입니다.', + ], 422); + } + + $this->formulaService->updateFormula($id, $validated); + + return response()->json([ + 'success' => true, + 'message' => '수식이 수정되었습니다.', + ]); + } + + /** + * 수식 삭제 (Soft Delete) + */ + public function destroy(int $id): JsonResponse + { + $this->formulaService->deleteFormula($id); + + return response()->json([ + 'success' => true, + 'message' => '수식이 삭제되었습니다.', + ]); + } + + /** + * 수식 복원 + */ + public function restore(int $id): JsonResponse + { + $this->formulaService->restoreFormula($id); + + return response()->json([ + 'success' => true, + 'message' => '수식이 복원되었습니다.', + ]); + } + + /** + * 수식 영구 삭제 + */ + public function forceDestroy(int $id): JsonResponse + { + $this->formulaService->forceDeleteFormula($id); + + return response()->json([ + 'success' => true, + 'message' => '수식이 영구 삭제되었습니다.', + ]); + } + + /** + * 수식 활성/비활성 토글 + */ + public function toggleActive(int $id): JsonResponse + { + $formula = $this->formulaService->toggleActive($id); + + return response()->json([ + 'success' => true, + 'message' => $formula->is_active ? '수식이 활성화되었습니다.' : '수식이 비활성화되었습니다.', + 'data' => ['is_active' => $formula->is_active], + ]); + } + + /** + * 카테고리별 수식 목록 (실행 순서) + */ + public function byCategory(int $categoryId): JsonResponse + { + $formulas = $this->formulaService->getFormulasByCategory($categoryId); + + return response()->json([ + 'success' => true, + 'data' => $formulas, + ]); + } + + /** + * 수식 순서 변경 + */ + public function reorder(Request $request): JsonResponse + { + $validated = $request->validate([ + 'formula_ids' => 'required|array', + 'formula_ids.*' => 'integer|exists:quote_formulas,id', + ]); + + $this->formulaService->reorder($validated['formula_ids']); + + return response()->json([ + 'success' => true, + 'message' => '순서가 변경되었습니다.', + ]); + } + + /** + * 사용 가능한 변수 목록 + */ + public function variables(): JsonResponse + { + $variables = $this->formulaService->getAvailableVariables(); + + return response()->json([ + 'success' => true, + 'data' => $variables, + ]); + } + + // ========================================================================= + // 수식 검증 및 테스트 API + // ========================================================================= + + /** + * 수식 문법 검증 + */ + public function validate(Request $request): JsonResponse + { + $validated = $request->validate([ + 'formula' => 'required|string|max:2000', + ]); + + $result = $this->evaluatorService->validateFormula($validated['formula']); + + return response()->json([ + 'success' => $result['success'], + 'data' => $result, + ]); + } + + /** + * 단일 수식 테스트 실행 + */ + public function test(Request $request): JsonResponse + { + $validated = $request->validate([ + 'formula' => 'required|string|max:2000', + 'variables' => 'nullable|array', + ]); + + $this->evaluatorService->resetVariables(); + + $result = $this->evaluatorService->evaluate( + $validated['formula'], + $validated['variables'] ?? [] + ); + + $errors = $this->evaluatorService->getErrors(); + + return response()->json([ + 'success' => empty($errors), + 'data' => [ + 'result' => $result, + 'errors' => $errors, + ], + ]); + } + + /** + * 전체 수식 시뮬레이션 + */ + public function simulate(Request $request): JsonResponse + { + $validated = $request->validate([ + 'category_id' => 'nullable|integer|exists:quote_formula_categories,id', + 'input_variables' => 'required|array', + ]); + + // 카테고리별 수식 조회 + $categoryId = $validated['category_id'] ?? null; + $formulas = $categoryId + ? $this->formulaService->getFormulasByCategory($categoryId) + : $this->formulaService->getAllActiveFormulas(); + + // 카테고리별로 그룹핑 + $formulasByCategory = $formulas->groupBy(fn ($f) => $f->category->code); + + // 전체 실행 + $this->evaluatorService->resetVariables(); + $result = $this->evaluatorService->executeAll( + $formulasByCategory, + $validated['input_variables'] + ); + + return response()->json([ + 'success' => empty($result['errors']), + 'data' => $result, + ]); + } + + /** + * 수식 복제 + */ + public function duplicate(int $id): JsonResponse + { + $formula = $this->formulaService->duplicateFormula($id); + + return response()->json([ + 'success' => true, + 'message' => '수식이 복제되었습니다.', + 'data' => $formula, + ]); + } +} diff --git a/app/Http/Controllers/QuoteFormulaController.php b/app/Http/Controllers/QuoteFormulaController.php new file mode 100644 index 00000000..dd8ae4c5 --- /dev/null +++ b/app/Http/Controllers/QuoteFormulaController.php @@ -0,0 +1,69 @@ +check(); + } + + public function rules(): array + { + return [ + 'code' => 'required|string|max:50|regex:/^[A-Z][A-Z0-9_]*$/', + 'name' => 'required|string|max:100', + 'description' => 'nullable|string|max:500', + 'sort_order' => 'nullable|integer|min:1', + 'is_active' => 'boolean', + ]; + } + + public function messages(): array + { + return [ + 'code.required' => '카테고리 코드는 필수입니다.', + 'code.regex' => '카테고리 코드는 대문자로 시작하고 대문자, 숫자, 언더스코어만 사용할 수 있습니다.', + 'code.max' => '카테고리 코드는 50자 이하로 입력해주세요.', + 'name.required' => '카테고리 이름은 필수입니다.', + 'name.max' => '카테고리 이름은 100자 이하로 입력해주세요.', + 'description.max' => '설명은 500자 이하로 입력해주세요.', + 'sort_order.integer' => '정렬 순서는 숫자로 입력해주세요.', + 'sort_order.min' => '정렬 순서는 1 이상이어야 합니다.', + ]; + } +} diff --git a/app/Http/Requests/Quote/StoreQuoteFormulaRequest.php b/app/Http/Requests/Quote/StoreQuoteFormulaRequest.php new file mode 100644 index 00000000..d816e536 --- /dev/null +++ b/app/Http/Requests/Quote/StoreQuoteFormulaRequest.php @@ -0,0 +1,70 @@ +check(); + } + + public function rules(): array + { + return [ + 'category_id' => 'required|integer|exists:quote_formula_categories,id', + 'product_id' => 'nullable|integer', + 'name' => 'required|string|max:200', + 'variable' => 'required|string|max:50|regex:/^[A-Z][A-Z0-9_]*$/', + 'type' => 'required|in:input,calculation,range,mapping', + 'formula' => 'nullable|string|max:2000', + 'output_type' => 'in:variable,item', + 'description' => 'nullable|string|max:500', + 'sort_order' => 'nullable|integer|min:1', + 'is_active' => 'boolean', + + // 범위 규칙 + 'ranges' => 'array', + 'ranges.*.min_value' => 'nullable|numeric', + 'ranges.*.max_value' => 'nullable|numeric', + 'ranges.*.condition_variable' => 'required_with:ranges|string|max:50', + 'ranges.*.result_value' => 'required_with:ranges|string|max:500', + 'ranges.*.result_type' => 'in:fixed,formula', + + // 매핑 규칙 + 'mappings' => 'array', + 'mappings.*.source_variable' => 'required_with:mappings|string|max:50', + 'mappings.*.source_value' => 'required_with:mappings|string|max:200', + 'mappings.*.result_value' => 'required_with:mappings|string|max:500', + 'mappings.*.result_type' => 'in:fixed,formula', + + // 품목 출력 + 'items' => 'array', + 'items.*.item_code' => 'required_with:items|string|max:50', + 'items.*.item_name' => 'required_with:items|string|max:200', + 'items.*.specification' => 'nullable|string|max:100', + 'items.*.unit' => 'required_with:items|string|max:20', + 'items.*.quantity_formula' => 'required_with:items|string|max:500', + 'items.*.unit_price_formula' => 'nullable|string|max:500', + ]; + } + + public function messages(): array + { + return [ + 'category_id.required' => '카테고리를 선택해주세요.', + 'category_id.exists' => '유효하지 않은 카테고리입니다.', + 'name.required' => '수식 이름은 필수입니다.', + 'name.max' => '수식 이름은 200자 이하로 입력해주세요.', + 'variable.required' => '변수명은 필수입니다.', + 'variable.regex' => '변수명은 대문자로 시작하고 대문자, 숫자, 언더스코어만 사용할 수 있습니다.', + 'variable.max' => '변수명은 50자 이하로 입력해주세요.', + 'type.required' => '수식 유형을 선택해주세요.', + 'type.in' => '유효하지 않은 수식 유형입니다.', + 'formula.max' => '수식은 2000자 이하로 입력해주세요.', + 'output_type.in' => '유효하지 않은 출력 유형입니다.', + ]; + } +} diff --git a/app/Http/Requests/Quote/UpdateQuoteFormulaCategoryRequest.php b/app/Http/Requests/Quote/UpdateQuoteFormulaCategoryRequest.php new file mode 100644 index 00000000..e1dbdabf --- /dev/null +++ b/app/Http/Requests/Quote/UpdateQuoteFormulaCategoryRequest.php @@ -0,0 +1,38 @@ +check(); + } + + public function rules(): array + { + return [ + 'code' => 'sometimes|required|string|max:50|regex:/^[A-Z][A-Z0-9_]*$/', + 'name' => 'sometimes|required|string|max:100', + 'description' => 'nullable|string|max:500', + 'sort_order' => 'nullable|integer|min:1', + 'is_active' => 'boolean', + ]; + } + + public function messages(): array + { + return [ + 'code.required' => '카테고리 코드는 필수입니다.', + 'code.regex' => '카테고리 코드는 대문자로 시작하고 대문자, 숫자, 언더스코어만 사용할 수 있습니다.', + 'code.max' => '카테고리 코드는 50자 이하로 입력해주세요.', + 'name.required' => '카테고리 이름은 필수입니다.', + 'name.max' => '카테고리 이름은 100자 이하로 입력해주세요.', + 'description.max' => '설명은 500자 이하로 입력해주세요.', + 'sort_order.integer' => '정렬 순서는 숫자로 입력해주세요.', + 'sort_order.min' => '정렬 순서는 1 이상이어야 합니다.', + ]; + } +} diff --git a/app/Http/Requests/Quote/UpdateQuoteFormulaRequest.php b/app/Http/Requests/Quote/UpdateQuoteFormulaRequest.php new file mode 100644 index 00000000..ffc7fa29 --- /dev/null +++ b/app/Http/Requests/Quote/UpdateQuoteFormulaRequest.php @@ -0,0 +1,70 @@ +check(); + } + + public function rules(): array + { + return [ + 'category_id' => 'sometimes|required|integer|exists:quote_formula_categories,id', + 'product_id' => 'nullable|integer', + 'name' => 'sometimes|required|string|max:200', + 'variable' => 'sometimes|required|string|max:50|regex:/^[A-Z][A-Z0-9_]*$/', + 'type' => 'sometimes|required|in:input,calculation,range,mapping', + 'formula' => 'nullable|string|max:2000', + 'output_type' => 'in:variable,item', + 'description' => 'nullable|string|max:500', + 'sort_order' => 'nullable|integer|min:1', + 'is_active' => 'boolean', + + // 범위 규칙 + 'ranges' => 'array', + 'ranges.*.min_value' => 'nullable|numeric', + 'ranges.*.max_value' => 'nullable|numeric', + 'ranges.*.condition_variable' => 'required_with:ranges|string|max:50', + 'ranges.*.result_value' => 'required_with:ranges|string|max:500', + 'ranges.*.result_type' => 'in:fixed,formula', + + // 매핑 규칙 + 'mappings' => 'array', + 'mappings.*.source_variable' => 'required_with:mappings|string|max:50', + 'mappings.*.source_value' => 'required_with:mappings|string|max:200', + 'mappings.*.result_value' => 'required_with:mappings|string|max:500', + 'mappings.*.result_type' => 'in:fixed,formula', + + // 품목 출력 + 'items' => 'array', + 'items.*.item_code' => 'required_with:items|string|max:50', + 'items.*.item_name' => 'required_with:items|string|max:200', + 'items.*.specification' => 'nullable|string|max:100', + 'items.*.unit' => 'required_with:items|string|max:20', + 'items.*.quantity_formula' => 'required_with:items|string|max:500', + 'items.*.unit_price_formula' => 'nullable|string|max:500', + ]; + } + + public function messages(): array + { + return [ + 'category_id.required' => '카테고리를 선택해주세요.', + 'category_id.exists' => '유효하지 않은 카테고리입니다.', + 'name.required' => '수식 이름은 필수입니다.', + 'name.max' => '수식 이름은 200자 이하로 입력해주세요.', + 'variable.required' => '변수명은 필수입니다.', + 'variable.regex' => '변수명은 대문자로 시작하고 대문자, 숫자, 언더스코어만 사용할 수 있습니다.', + 'variable.max' => '변수명은 50자 이하로 입력해주세요.', + 'type.required' => '수식 유형을 선택해주세요.', + 'type.in' => '유효하지 않은 수식 유형입니다.', + 'formula.max' => '수식은 2000자 이하로 입력해주세요.', + 'output_type.in' => '유효하지 않은 출력 유형입니다.', + ]; + } +} diff --git a/app/Models/Quote/QuoteFormula.php b/app/Models/Quote/QuoteFormula.php new file mode 100644 index 00000000..f431371f --- /dev/null +++ b/app/Models/Quote/QuoteFormula.php @@ -0,0 +1,218 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + + protected $attributes = [ + 'type' => 'calculation', + 'output_type' => 'variable', + 'sort_order' => 0, + 'is_active' => true, + ]; + + // 수식 유형 상수 + public const TYPE_INPUT = 'input'; + + public const TYPE_CALCULATION = 'calculation'; + + public const TYPE_RANGE = 'range'; + + public const TYPE_MAPPING = 'mapping'; + + // 출력 유형 상수 + public const OUTPUT_VARIABLE = 'variable'; + + public const OUTPUT_ITEM = 'item'; + + // ========================================================================= + // Relationships + // ========================================================================= + + /** + * 카테고리 관계 + */ + public function category(): BelongsTo + { + return $this->belongsTo(QuoteFormulaCategory::class, 'category_id'); + } + + /** + * 범위 규칙 + */ + public function ranges(): HasMany + { + return $this->hasMany(QuoteFormulaRange::class, 'formula_id') + ->orderBy('sort_order'); + } + + /** + * 매핑 규칙 + */ + public function mappings(): HasMany + { + return $this->hasMany(QuoteFormulaMapping::class, 'formula_id') + ->orderBy('sort_order'); + } + + /** + * 품목 출력 + */ + public function items(): HasMany + { + return $this->hasMany(QuoteFormulaItem::class, 'formula_id') + ->orderBy('sort_order'); + } + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + // ========================================================================= + // Scopes + // ========================================================================= + + /** + * 수식이 공통 수식인지 확인 + */ + public function isCommon(): bool + { + return is_null($this->product_id); + } + + /** + * Scope: 공통 수식 + */ + public function scopeCommon(Builder $query): Builder + { + return $query->whereNull('product_id'); + } + + /** + * Scope: 특정 제품 수식 (공통 + 제품 전용) + */ + public function scopeForProduct(Builder $query, ?int $productId): Builder + { + return $query->where(function ($q) use ($productId) { + $q->whereNull('product_id'); + if ($productId) { + $q->orWhere('product_id', $productId); + } + }); + } + + /** + * Scope: 활성화된 수식 + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + /** + * Scope: 정렬 순서 + */ + public function scopeOrdered(Builder $query): Builder + { + return $query->orderBy('sort_order'); + } + + /** + * Scope: 유형별 필터 + */ + public function scopeOfType(Builder $query, string $type): Builder + { + return $query->where('type', $type); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * 유형 레이블 조회 + */ + public function getTypeLabelAttribute(): string + { + return match ($this->type) { + self::TYPE_INPUT => '입력값', + self::TYPE_CALCULATION => '계산식', + self::TYPE_RANGE => '범위별', + self::TYPE_MAPPING => '매핑', + default => $this->type, + }; + } + + /** + * 출력 유형 레이블 조회 + */ + public function getOutputTypeLabelAttribute(): string + { + return match ($this->output_type) { + self::OUTPUT_VARIABLE => '변수', + self::OUTPUT_ITEM => '품목', + default => $this->output_type, + }; + } +} diff --git a/app/Models/Quote/QuoteFormulaCategory.php b/app/Models/Quote/QuoteFormulaCategory.php new file mode 100644 index 00000000..e1a05cbe --- /dev/null +++ b/app/Models/Quote/QuoteFormulaCategory.php @@ -0,0 +1,109 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + + protected $attributes = [ + 'sort_order' => 0, + 'is_active' => true, + ]; + + // ========================================================================= + // Relationships + // ========================================================================= + + /** + * 카테고리에 속한 수식들 + */ + public function formulas(): HasMany + { + return $this->hasMany(QuoteFormula::class, 'category_id') + ->orderBy('sort_order'); + } + + /** + * 활성화된 수식만 + */ + public function activeFormulas(): HasMany + { + return $this->formulas()->where('is_active', true); + } + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + // ========================================================================= + // Scopes + // ========================================================================= + + /** + * Scope: 활성화된 카테고리 + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + /** + * Scope: 정렬 순서 + */ + public function scopeOrdered(Builder $query): Builder + { + return $query->orderBy('sort_order'); + } +} diff --git a/app/Models/Quote/QuoteFormulaItem.php b/app/Models/Quote/QuoteFormulaItem.php new file mode 100644 index 00000000..b5965d25 --- /dev/null +++ b/app/Models/Quote/QuoteFormulaItem.php @@ -0,0 +1,70 @@ + 'integer', + ]; + + protected $attributes = [ + 'sort_order' => 0, + ]; + + // ========================================================================= + // Relationships + // ========================================================================= + + public function formula(): BelongsTo + { + return $this->belongsTo(QuoteFormula::class, 'formula_id'); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * 품목 표시 문자열 + */ + public function getDisplayNameAttribute(): string + { + $name = "[{$this->item_code}] {$this->item_name}"; + + if ($this->specification) { + $name .= " ({$this->specification})"; + } + + return $name; + } +} diff --git a/app/Models/Quote/QuoteFormulaMapping.php b/app/Models/Quote/QuoteFormulaMapping.php new file mode 100644 index 00000000..70317863 --- /dev/null +++ b/app/Models/Quote/QuoteFormulaMapping.php @@ -0,0 +1,65 @@ + 'integer', + ]; + + protected $attributes = [ + 'result_type' => 'fixed', + 'sort_order' => 0, + ]; + + public const RESULT_FIXED = 'fixed'; + + public const RESULT_FORMULA = 'formula'; + + // ========================================================================= + // Relationships + // ========================================================================= + + public function formula(): BelongsTo + { + return $this->belongsTo(QuoteFormula::class, 'formula_id'); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * 매핑 조건 표시 문자열 + */ + public function getConditionLabelAttribute(): string + { + return "{$this->source_variable} = '{$this->source_value}'"; + } +} diff --git a/app/Models/Quote/QuoteFormulaRange.php b/app/Models/Quote/QuoteFormulaRange.php new file mode 100644 index 00000000..24f68500 --- /dev/null +++ b/app/Models/Quote/QuoteFormulaRange.php @@ -0,0 +1,107 @@ + 'decimal:4', + 'max_value' => 'decimal:4', + 'sort_order' => 'integer', + ]; + + protected $attributes = [ + 'result_type' => 'fixed', + 'sort_order' => 0, + ]; + + public const RESULT_FIXED = 'fixed'; + + public const RESULT_FORMULA = 'formula'; + + // ========================================================================= + // Relationships + // ========================================================================= + + public function formula(): BelongsTo + { + return $this->belongsTo(QuoteFormula::class, 'formula_id'); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * 값이 범위 내에 있는지 확인 + */ + public function isInRange($value): bool + { + $min = $this->min_value; + $max = $this->max_value; + + if (is_null($min) && is_null($max)) { + return true; + } + + if (is_null($min)) { + return $value <= $max; + } + + if (is_null($max)) { + return $value >= $min; + } + + return $value >= $min && $value <= $max; + } + + /** + * 범위 표시 문자열 + */ + public function getRangeLabelAttribute(): string + { + $min = $this->min_value; + $max = $this->max_value; + + if (is_null($min) && is_null($max)) { + return '전체'; + } + + if (is_null($min)) { + return "~ {$max}"; + } + + if (is_null($max)) { + return "{$min} ~"; + } + + return "{$min} ~ {$max}"; + } +} diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php new file mode 100644 index 00000000..88181aaf --- /dev/null +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -0,0 +1,354 @@ + false, 'errors' => ['수식이 비어있습니다.']]; + } + + // 괄호 매칭 검증 + if (! $this->validateParentheses($formula)) { + $errors[] = '괄호가 올바르게 닫히지 않았습니다.'; + } + + // 변수 추출 및 검증 + $variables = $this->extractVariables($formula); + + // 지원 함수 검증 + $functions = $this->extractFunctions($formula); + $supportedFunctions = ['SUM', 'ROUND', 'CEIL', 'FLOOR', 'ABS', 'MIN', 'MAX', 'IF', 'AND', 'OR', 'NOT']; + + foreach ($functions as $func) { + if (! in_array(strtoupper($func), $supportedFunctions)) { + $errors[] = "지원하지 않는 함수입니다: {$func}"; + } + } + + return [ + 'success' => empty($errors), + 'errors' => $errors, + 'variables' => $variables, + 'functions' => $functions, + ]; + } + + /** + * 수식 평가 + */ + public function evaluate(string $formula, array $variables = []): mixed + { + $this->variables = array_merge($this->variables, $variables); + $this->errors = []; + + try { + // 변수 치환 + $expression = $this->substituteVariables($formula); + + // 함수 처리 + $expression = $this->processFunctions($expression); + + // 최종 계산 + $result = $this->calculateExpression($expression); + + return $result; + } catch (\Exception $e) { + $this->errors[] = $e->getMessage(); + + return null; + } + } + + /** + * 범위별 수식 평가 + */ + public function evaluateRange(QuoteFormula $formula, array $variables = []): mixed + { + $conditionVar = $formula->ranges->first()?->condition_variable; + $value = $variables[$conditionVar] ?? 0; + + foreach ($formula->ranges as $range) { + if ($range->isInRange($value)) { + if ($range->result_type === 'formula') { + return $this->evaluate($range->result_value, $variables); + } + + return $range->result_value; + } + } + + return null; + } + + /** + * 매핑 수식 평가 + */ + public function evaluateMapping(QuoteFormula $formula, array $variables = []): mixed + { + foreach ($formula->mappings as $mapping) { + $sourceValue = $variables[$mapping->source_variable] ?? null; + + if ($sourceValue == $mapping->source_value) { + if ($mapping->result_type === 'formula') { + return $this->evaluate($mapping->result_value, $variables); + } + + return $mapping->result_value; + } + } + + return null; + } + + /** + * 전체 수식 실행 (카테고리 순서대로) + */ + public function executeAll(Collection $formulasByCategory, array $inputVariables = []): array + { + $this->variables = $inputVariables; + $results = []; + $items = []; + + foreach ($formulasByCategory as $categoryCode => $formulas) { + foreach ($formulas as $formula) { + $result = $this->executeFormula($formula); + + if ($formula->output_type === QuoteFormula::OUTPUT_VARIABLE) { + $this->variables[$formula->variable] = $result; + $results[$formula->variable] = [ + 'name' => $formula->name, + 'value' => $result, + 'category' => $formula->category->name, + 'type' => $formula->type, + ]; + } else { + // 품목 출력 + foreach ($formula->items as $item) { + $quantity = $this->evaluate($item->quantity_formula); + $unitPrice = $item->unit_price_formula + ? $this->evaluate($item->unit_price_formula) + : $this->getItemPrice($item->item_code); + + $items[] = [ + 'item_code' => $item->item_code, + 'item_name' => $item->item_name, + 'specification' => $item->specification, + 'unit' => $item->unit, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'total_price' => $quantity * $unitPrice, + 'formula_variable' => $formula->variable, + ]; + } + } + } + } + + return [ + 'variables' => $results, + 'items' => $items, + 'errors' => $this->errors, + ]; + } + + /** + * 단일 수식 실행 + */ + private function executeFormula(QuoteFormula $formula): mixed + { + return match ($formula->type) { + QuoteFormula::TYPE_INPUT => $this->variables[$formula->variable] ?? + ($formula->formula ? $this->evaluate($formula->formula) : null), + QuoteFormula::TYPE_CALCULATION => $this->evaluate($formula->formula, $this->variables), + QuoteFormula::TYPE_RANGE => $this->evaluateRange($formula, $this->variables), + QuoteFormula::TYPE_MAPPING => $this->evaluateMapping($formula, $this->variables), + default => null, + }; + } + + // ========================================================================= + // Private Helper Methods + // ========================================================================= + + private function validateParentheses(string $formula): bool + { + $count = 0; + foreach (str_split($formula) as $char) { + if ($char === '(') { + $count++; + } + if ($char === ')') { + $count--; + } + if ($count < 0) { + return false; + } + } + + return $count === 0; + } + + private function extractVariables(string $formula): array + { + preg_match_all('/\b([A-Z][A-Z0-9_]*)\b/', $formula, $matches); + $variables = array_unique($matches[1] ?? []); + + // 함수명 제외 + $functions = ['SUM', 'ROUND', 'CEIL', 'FLOOR', 'ABS', 'MIN', 'MAX', 'IF', 'AND', 'OR', 'NOT']; + + return array_values(array_diff($variables, $functions)); + } + + private function extractFunctions(string $formula): array + { + preg_match_all('/\b([A-Za-z_]+)\s*\(/', $formula, $matches); + + return array_unique($matches[1] ?? []); + } + + private function substituteVariables(string $formula): string + { + foreach ($this->variables as $var => $value) { + $formula = preg_replace('/\b'.preg_quote($var, '/').'\\b/', (string) $value, $formula); + } + + return $formula; + } + + private function processFunctions(string $expression): string + { + // ROUND(value, decimals) + $expression = preg_replace_callback( + '/ROUND\s*\(\s*([^,]+)\s*,\s*(\d+)\s*\)/i', + fn ($m) => round((float) $this->calculateExpression($m[1]), (int) $m[2]), + $expression + ); + + // SUM(a, b, c, ...) + $expression = preg_replace_callback( + '/SUM\s*\(([^)]+)\)/i', + fn ($m) => array_sum(array_map('floatval', explode(',', $m[1]))), + $expression + ); + + // MIN, MAX + $expression = preg_replace_callback( + '/MIN\s*\(([^)]+)\)/i', + fn ($m) => min(array_map('floatval', explode(',', $m[1]))), + $expression + ); + + $expression = preg_replace_callback( + '/MAX\s*\(([^)]+)\)/i', + fn ($m) => max(array_map('floatval', explode(',', $m[1]))), + $expression + ); + + // IF(condition, true_val, false_val) + $expression = preg_replace_callback( + '/IF\s*\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^)]+)\s*\)/i', + function ($m) { + $condition = $this->evaluateCondition($m[1]); + + return $condition ? $this->calculateExpression($m[2]) : $this->calculateExpression($m[3]); + }, + $expression + ); + + // ABS, CEIL, FLOOR + $expression = preg_replace_callback('/ABS\s*\(([^)]+)\)/i', fn ($m) => abs((float) $this->calculateExpression($m[1])), $expression); + $expression = preg_replace_callback('/CEIL\s*\(([^)]+)\)/i', fn ($m) => ceil((float) $this->calculateExpression($m[1])), $expression); + $expression = preg_replace_callback('/FLOOR\s*\(([^)]+)\)/i', fn ($m) => floor((float) $this->calculateExpression($m[1])), $expression); + + return $expression; + } + + private function calculateExpression(string $expression): float + { + // 안전한 수식 평가 (숫자, 연산자, 괄호만 허용) + $expression = preg_replace('/[^0-9+\-*\/().%\s]/', '', $expression); + + if (empty(trim($expression))) { + return 0; + } + + try { + // eval 대신 안전한 계산 라이브러리 사용 권장 + // 여기서는 간단히 eval 사용 (프로덕션에서는 symfony/expression-language 등 사용) + return (float) eval("return {$expression};"); + } catch (\Throwable $e) { + $this->errors[] = "계산 오류: {$expression}"; + + return 0; + } + } + + private function evaluateCondition(string $condition): bool + { + // 비교 연산자 처리 + if (preg_match('/(.+)(>=|<=|>|<|==|!=)(.+)/', $condition, $m)) { + $left = (float) $this->calculateExpression(trim($m[1])); + $right = (float) $this->calculateExpression(trim($m[3])); + $op = $m[2]; + + return match ($op) { + '>=' => $left >= $right, + '<=' => $left <= $right, + '>' => $left > $right, + '<' => $left < $right, + '==' => $left == $right, + '!=' => $left != $right, + default => false, + }; + } + + return (bool) $this->calculateExpression($condition); + } + + private function getItemPrice(string $itemCode): float + { + // TODO: 품목 마스터에서 단가 조회 + return 0; + } + + /** + * 에러 목록 반환 + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * 현재 변수 상태 반환 + */ + public function getVariables(): array + { + return $this->variables; + } + + /** + * 변수 초기화 + */ + public function resetVariables(): void + { + $this->variables = []; + $this->errors = []; + } +} diff --git a/app/Services/Quote/QuoteFormulaCategoryService.php b/app/Services/Quote/QuoteFormulaCategoryService.php new file mode 100644 index 00000000..336a1218 --- /dev/null +++ b/app/Services/Quote/QuoteFormulaCategoryService.php @@ -0,0 +1,200 @@ +withCount('formulas') + ->withCount(['formulas as active_formulas_count' => function ($q) { + $q->where('is_active', true); + }]); + + // 테넌트 필터 + if ($tenantId && $tenantId !== 'all') { + $query->where('tenant_id', $tenantId); + } + + // 검색 + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('code', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + // 활성화 필터 + if (isset($filters['is_active']) && $filters['is_active'] !== '') { + $query->where('is_active', (bool) $filters['is_active']); + } + + // 정렬 + $sortBy = $filters['sort_by'] ?? 'sort_order'; + $sortDirection = $filters['sort_direction'] ?? 'asc'; + $query->orderBy($sortBy, $sortDirection); + + return $query->paginate($perPage); + } + + /** + * 전체 카테고리 목록 (선택용) + */ + public function getAllCategories(): Collection + { + $tenantId = session('selected_tenant_id'); + + return QuoteFormulaCategory::query() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->orderBy('sort_order') + ->get(); + } + + /** + * 활성 카테고리 목록 (드롭다운용) + */ + public function getActiveCategories(): Collection + { + return $this->getAllCategories(); + } + + /** + * 카테고리 상세 조회 + */ + public function getCategoryById(int $id): ?QuoteFormulaCategory + { + return QuoteFormulaCategory::with('formulas')->find($id); + } + + /** + * 카테고리 생성 + */ + public function createCategory(array $data): QuoteFormulaCategory + { + $tenantId = session('selected_tenant_id'); + + return DB::transaction(function () use ($data, $tenantId) { + // 자동 sort_order 할당 + if (! isset($data['sort_order'])) { + $maxOrder = QuoteFormulaCategory::where('tenant_id', $tenantId)->max('sort_order'); + $data['sort_order'] = ($maxOrder ?? 0) + 1; + } + + return QuoteFormulaCategory::create([ + 'tenant_id' => $tenantId, + 'code' => $data['code'], + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'sort_order' => $data['sort_order'], + 'is_active' => $data['is_active'] ?? true, + 'created_by' => auth()->id(), + ]); + }); + } + + /** + * 카테고리 수정 + */ + public function updateCategory(int $id, array $data): QuoteFormulaCategory + { + $category = QuoteFormulaCategory::findOrFail($id); + + $category->update([ + 'code' => $data['code'] ?? $category->code, + 'name' => $data['name'] ?? $category->name, + 'description' => array_key_exists('description', $data) ? $data['description'] : $category->description, + 'sort_order' => $data['sort_order'] ?? $category->sort_order, + 'is_active' => $data['is_active'] ?? $category->is_active, + 'updated_by' => auth()->id(), + ]); + + return $category->fresh(); + } + + /** + * 카테고리 삭제 + */ + public function deleteCategory(int $id): void + { + $category = QuoteFormulaCategory::findOrFail($id); + + // 연관 수식이 있으면 삭제 불가 + if ($category->formulas()->count() > 0) { + throw new \Exception('연관된 수식이 있어 삭제할 수 없습니다. 먼저 수식을 삭제해주세요.'); + } + + $category->delete(); + } + + /** + * 카테고리 순서 변경 + */ + public function reorderCategories(array $orderData): void + { + DB::transaction(function () use ($orderData) { + foreach ($orderData as $item) { + QuoteFormulaCategory::where('id', $item['id']) + ->update(['sort_order' => $item['sort_order']]); + } + }); + } + + /** + * 카테고리 활성/비활성 토글 + */ + public function toggleActive(int $id): QuoteFormulaCategory + { + $category = QuoteFormulaCategory::findOrFail($id); + + $category->is_active = ! $category->is_active; + $category->updated_by = auth()->id(); + $category->save(); + + return $category; + } + + /** + * 코드 중복 체크 + */ + public function isCodeExists(string $code, ?int $excludeId = null): bool + { + $tenantId = session('selected_tenant_id'); + + $query = QuoteFormulaCategory::where('tenant_id', $tenantId) + ->where('code', $code); + + if ($excludeId) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } + + /** + * 카테고리 통계 + */ + public function getCategoryStats(): array + { + $tenantId = session('selected_tenant_id'); + + return [ + 'total' => QuoteFormulaCategory::where('tenant_id', $tenantId)->count(), + 'active' => QuoteFormulaCategory::where('tenant_id', $tenantId)->where('is_active', true)->count(), + 'inactive' => QuoteFormulaCategory::where('tenant_id', $tenantId)->where('is_active', false)->count(), + ]; + } +} diff --git a/app/Services/Quote/QuoteFormulaService.php b/app/Services/Quote/QuoteFormulaService.php new file mode 100644 index 00000000..1513ed91 --- /dev/null +++ b/app/Services/Quote/QuoteFormulaService.php @@ -0,0 +1,504 @@ +with(['category']) + ->withCount(['ranges', 'mappings', 'items']); + + // 테넌트 필터 + if ($tenantId && $tenantId !== 'all') { + $query->where('tenant_id', $tenantId); + } + + // 카테고리 필터 + if (! empty($filters['category_id'])) { + $query->where('category_id', $filters['category_id']); + } + + // 제품 필터 (공통/특정) + if (isset($filters['product_id'])) { + if ($filters['product_id'] === 'common' || $filters['product_id'] === '') { + $query->whereNull('product_id'); + } else { + $query->where('product_id', $filters['product_id']); + } + } + + // 유형 필터 + if (! empty($filters['type'])) { + $query->where('type', $filters['type']); + } + + // 검색 + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('variable', 'like', "%{$search}%") + ->orWhere('formula', 'like', "%{$search}%"); + }); + } + + // 활성화 필터 + if (isset($filters['is_active']) && $filters['is_active'] !== '') { + $query->where('is_active', (bool) $filters['is_active']); + } + + // 정렬 + $sortBy = $filters['sort_by'] ?? 'sort_order'; + $sortDirection = $filters['sort_direction'] ?? 'asc'; + + if ($sortBy === 'category') { + $query->join('quote_formula_categories', 'quote_formulas.category_id', '=', 'quote_formula_categories.id') + ->orderBy('quote_formula_categories.sort_order', $sortDirection) + ->orderBy('quote_formulas.sort_order', $sortDirection) + ->select('quote_formulas.*'); + } else { + $query->orderBy($sortBy, $sortDirection); + } + + return $query->paginate($perPage); + } + + /** + * 카테고리별 수식 목록 (실행 순서용) + */ + public function getFormulasByCategory(?int $productId = null): Collection + { + $tenantId = session('selected_tenant_id'); + + return QuoteFormula::query() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->where(function ($q) use ($productId) { + $q->whereNull('product_id'); + if ($productId) { + $q->orWhere('product_id', $productId); + } + }) + ->with(['category', 'ranges', 'mappings', 'items']) + ->get() + ->groupBy('category.code') + ->sortBy(fn ($formulas) => $formulas->first()->category->sort_order); + } + + /** + * 수식 상세 조회 + */ + public function getFormulaById(int $id, bool $withRelations = false): ?QuoteFormula + { + $query = QuoteFormula::query(); + + if ($withRelations) { + $query->with(['category', 'ranges', 'mappings', 'items']); + } + + return $query->find($id); + } + + /** + * 수식 생성 + */ + public function createFormula(array $data): QuoteFormula + { + $tenantId = session('selected_tenant_id'); + + return DB::transaction(function () use ($data, $tenantId) { + // 자동 sort_order + if (! isset($data['sort_order'])) { + $maxOrder = QuoteFormula::where('tenant_id', $tenantId) + ->where('category_id', $data['category_id']) + ->max('sort_order'); + $data['sort_order'] = ($maxOrder ?? 0) + 1; + } + + $formula = QuoteFormula::create([ + 'tenant_id' => $tenantId, + 'category_id' => $data['category_id'], + 'product_id' => $data['product_id'] ?? null, + 'name' => $data['name'], + 'variable' => $data['variable'], + 'type' => $data['type'], + 'formula' => $data['formula'] ?? null, + 'output_type' => $data['output_type'] ?? 'variable', + 'description' => $data['description'] ?? null, + 'sort_order' => $data['sort_order'], + 'is_active' => $data['is_active'] ?? true, + 'created_by' => auth()->id(), + ]); + + // 범위별 규칙 저장 + if ($data['type'] === QuoteFormula::TYPE_RANGE && ! empty($data['ranges'])) { + $this->saveRanges($formula, $data['ranges']); + } + + // 매핑 규칙 저장 + if ($data['type'] === QuoteFormula::TYPE_MAPPING && ! empty($data['mappings'])) { + $this->saveMappings($formula, $data['mappings']); + } + + // 품목 출력 저장 + if (($data['output_type'] ?? 'variable') === QuoteFormula::OUTPUT_ITEM && ! empty($data['items'])) { + $this->saveItems($formula, $data['items']); + } + + return $formula->load(['ranges', 'mappings', 'items']); + }); + } + + /** + * 수식 수정 + */ + public function updateFormula(int $id, array $data): QuoteFormula + { + $formula = QuoteFormula::findOrFail($id); + + return DB::transaction(function () use ($formula, $data) { + $formula->update([ + 'category_id' => $data['category_id'] ?? $formula->category_id, + 'product_id' => array_key_exists('product_id', $data) ? $data['product_id'] : $formula->product_id, + 'name' => $data['name'] ?? $formula->name, + 'variable' => $data['variable'] ?? $formula->variable, + 'type' => $data['type'] ?? $formula->type, + 'formula' => array_key_exists('formula', $data) ? $data['formula'] : $formula->formula, + 'output_type' => $data['output_type'] ?? $formula->output_type, + 'description' => array_key_exists('description', $data) ? $data['description'] : $formula->description, + 'sort_order' => $data['sort_order'] ?? $formula->sort_order, + 'is_active' => $data['is_active'] ?? $formula->is_active, + 'updated_by' => auth()->id(), + ]); + + // 범위 규칙 업데이트 + if (isset($data['ranges'])) { + $formula->ranges()->delete(); + if ($formula->type === QuoteFormula::TYPE_RANGE) { + $this->saveRanges($formula, $data['ranges']); + } + } + + // 매핑 규칙 업데이트 + if (isset($data['mappings'])) { + $formula->mappings()->delete(); + if ($formula->type === QuoteFormula::TYPE_MAPPING) { + $this->saveMappings($formula, $data['mappings']); + } + } + + // 품목 출력 업데이트 + if (isset($data['items'])) { + $formula->items()->delete(); + if ($formula->output_type === QuoteFormula::OUTPUT_ITEM) { + $this->saveItems($formula, $data['items']); + } + } + + return $formula->fresh(['category', 'ranges', 'mappings', 'items']); + }); + } + + /** + * 수식 삭제 + */ + public function deleteFormula(int $id): void + { + $formula = QuoteFormula::findOrFail($id); + $formula->delete(); + } + + /** + * 수식 활성/비활성 토글 + */ + public function toggleActive(int $id): QuoteFormula + { + $formula = QuoteFormula::findOrFail($id); + + $formula->is_active = ! $formula->is_active; + $formula->updated_by = auth()->id(); + $formula->save(); + + return $formula; + } + + /** + * 수식 복원 + */ + public function restoreFormula(int $id): void + { + $formula = QuoteFormula::withTrashed()->findOrFail($id); + $formula->restore(); + } + + /** + * 수식 영구 삭제 + */ + public function forceDeleteFormula(int $id): void + { + $formula = QuoteFormula::withTrashed()->findOrFail($id); + + DB::transaction(function () use ($formula) { + $formula->ranges()->delete(); + $formula->mappings()->delete(); + $formula->items()->delete(); + $formula->forceDelete(); + }); + } + + /** + * 변수명 중복 체크 (테넌트 전체 범위) + */ + public function isVariableExists(string $variable, ?int $excludeId = null): bool + { + $tenantId = session('selected_tenant_id'); + + $query = QuoteFormula::where('tenant_id', $tenantId) + ->where('variable', $variable); + + if ($excludeId) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } + + /** + * 수식 순서 변경 + */ + public function reorder(array $formulaIds): void + { + foreach ($formulaIds as $index => $id) { + QuoteFormula::where('id', $id)->update([ + 'sort_order' => $index + 1, + 'updated_by' => auth()->id(), + ]); + } + } + + /** + * 모든 활성 수식 조회 + */ + public function getAllActiveFormulas(): Collection + { + $tenantId = session('selected_tenant_id'); + + return QuoteFormula::query() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->with(['category', 'ranges', 'mappings', 'items']) + ->orderBy('category_id') + ->orderBy('sort_order') + ->get(); + } + + /** + * 수식 복제 + */ + public function duplicateFormula(int $id): QuoteFormula + { + $original = QuoteFormula::with(['ranges', 'mappings', 'items'])->findOrFail($id); + $tenantId = session('selected_tenant_id'); + + return DB::transaction(function () use ($original, $tenantId) { + // 새로운 변수명 생성 + $newVariable = $this->generateUniqueVariable($original->variable); + + // 수식 복제 + $formula = QuoteFormula::create([ + 'tenant_id' => $tenantId, + 'category_id' => $original->category_id, + 'product_id' => $original->product_id, + 'name' => $original->name.' (복제)', + 'variable' => $newVariable, + 'type' => $original->type, + 'formula' => $original->formula, + 'output_type' => $original->output_type, + 'description' => $original->description, + 'sort_order' => $original->sort_order + 1, + 'is_active' => false, // 복제본은 비활성 상태로 생성 + 'created_by' => auth()->id(), + ]); + + // 범위 규칙 복제 + foreach ($original->ranges as $range) { + QuoteFormulaRange::create([ + 'formula_id' => $formula->id, + 'min_value' => $range->min_value, + 'max_value' => $range->max_value, + 'condition_variable' => $range->condition_variable, + 'result_value' => $range->result_value, + 'result_type' => $range->result_type, + 'sort_order' => $range->sort_order, + ]); + } + + // 매핑 규칙 복제 + foreach ($original->mappings as $mapping) { + QuoteFormulaMapping::create([ + 'formula_id' => $formula->id, + 'source_variable' => $mapping->source_variable, + 'source_value' => $mapping->source_value, + 'result_value' => $mapping->result_value, + 'result_type' => $mapping->result_type, + 'sort_order' => $mapping->sort_order, + ]); + } + + // 품목 출력 복제 + foreach ($original->items as $item) { + QuoteFormulaItem::create([ + 'formula_id' => $formula->id, + 'item_code' => $item->item_code, + 'item_name' => $item->item_name, + 'specification' => $item->specification, + 'unit' => $item->unit, + 'quantity_formula' => $item->quantity_formula, + 'unit_price_formula' => $item->unit_price_formula, + 'sort_order' => $item->sort_order, + ]); + } + + return $formula->load(['category', 'ranges', 'mappings', 'items']); + }); + } + + /** + * 고유한 변수명 생성 + */ + private function generateUniqueVariable(string $baseVariable): string + { + $tenantId = session('selected_tenant_id'); + $suffix = 1; + $newVariable = $baseVariable.'_COPY'; + + while (QuoteFormula::where('tenant_id', $tenantId)->where('variable', $newVariable)->exists()) { + $suffix++; + $newVariable = $baseVariable.'_COPY'.$suffix; + } + + return $newVariable; + } + + /** + * 변수 목록 조회 (수식 입력 시 참조용) + */ + public function getAvailableVariables(?int $productId = null): array + { + $tenantId = session('selected_tenant_id'); + + $formulas = QuoteFormula::query() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->where('output_type', 'variable') + ->where(function ($q) use ($productId) { + $q->whereNull('product_id'); + if ($productId) { + $q->orWhere('product_id', $productId); + } + }) + ->with('category') + ->orderBy('category_id') + ->orderBy('sort_order') + ->get(); + + return $formulas->map(fn ($f) => [ + 'variable' => $f->variable, + 'name' => $f->name, + 'category' => $f->category->name, + 'type' => $f->type, + ])->toArray(); + } + + /** + * 수식 통계 + */ + public function getFormulaStats(): array + { + $tenantId = session('selected_tenant_id'); + + return [ + 'total' => QuoteFormula::where('tenant_id', $tenantId)->count(), + 'active' => QuoteFormula::where('tenant_id', $tenantId)->where('is_active', true)->count(), + 'by_type' => [ + 'input' => QuoteFormula::where('tenant_id', $tenantId)->where('type', 'input')->count(), + 'calculation' => QuoteFormula::where('tenant_id', $tenantId)->where('type', 'calculation')->count(), + 'range' => QuoteFormula::where('tenant_id', $tenantId)->where('type', 'range')->count(), + 'mapping' => QuoteFormula::where('tenant_id', $tenantId)->where('type', 'mapping')->count(), + ], + ]; + } + + // ========================================================================= + // Private Methods + // ========================================================================= + + /** + * 범위 규칙 저장 + */ + private function saveRanges(QuoteFormula $formula, array $ranges): void + { + foreach ($ranges as $index => $range) { + QuoteFormulaRange::create([ + 'formula_id' => $formula->id, + 'min_value' => $range['min_value'] ?? null, + 'max_value' => $range['max_value'] ?? null, + 'condition_variable' => $range['condition_variable'], + 'result_value' => $range['result_value'], + 'result_type' => $range['result_type'] ?? 'fixed', + 'sort_order' => $index + 1, + ]); + } + } + + /** + * 매핑 규칙 저장 + */ + private function saveMappings(QuoteFormula $formula, array $mappings): void + { + foreach ($mappings as $index => $mapping) { + QuoteFormulaMapping::create([ + 'formula_id' => $formula->id, + 'source_variable' => $mapping['source_variable'], + 'source_value' => $mapping['source_value'], + 'result_value' => $mapping['result_value'], + 'result_type' => $mapping['result_type'] ?? 'fixed', + 'sort_order' => $index + 1, + ]); + } + } + + /** + * 품목 출력 저장 + */ + private function saveItems(QuoteFormula $formula, array $items): void + { + foreach ($items as $index => $item) { + QuoteFormulaItem::create([ + 'formula_id' => $formula->id, + 'item_code' => $item['item_code'], + 'item_name' => $item['item_name'], + 'specification' => $item['specification'] ?? null, + 'unit' => $item['unit'], + 'quantity_formula' => $item['quantity_formula'], + 'unit_price_formula' => $item['unit_price_formula'] ?? null, + 'sort_order' => $index + 1, + ]); + } + } +} diff --git a/docs/QUOTE_FORMULA_DEVELOPMENT_PLAN.md b/docs/QUOTE_FORMULA_DEVELOPMENT_PLAN.md new file mode 100644 index 00000000..48724710 --- /dev/null +++ b/docs/QUOTE_FORMULA_DEVELOPMENT_PLAN.md @@ -0,0 +1,1985 @@ +# 견적 수식 관리 기능 개발 플랜 + +> **목적**: mng에서 견적 산출식을 작성/관리하고, 완료 후 SAM API에서 견적 산출 화면에 활용 +> +> **작성일**: 2025-12-04 +> **버전**: 1.0 + +--- + +## 1. 개요 + +### 1.1 기능 범위 + +견적 수식 관리 기능은 다음을 포함합니다: + +1. **수식 카테고리 관리**: 수식을 그룹화하는 카테고리 CRUD +2. **수식 관리**: 변수, 계산식, 범위별, 매핑 등 다양한 수식 유형 CRUD +3. **수식 검증**: 수식 유효성 검사 및 테스트 +4. **수식 실행 순서**: 카테고리 순서대로 수식 실행 + +### 1.2 수식 유형 + +| 유형 | 설명 | 예시 | +|------|------|------| +| `input` | 사용자 입력값 | W0, H0, QTY | +| `calculation` | 계산식 | W1 = W0 + 140 | +| `range` | 범위별 값 | 면적 0~5: 용량A, 5~10: 용량B | +| `mapping` | 1:1 매핑 | GT='벽부': 브라켓A | + +### 1.3 지원 함수 + +- **수학**: `SUM()`, `ROUND()`, `CEIL()`, `FLOOR()`, `ABS()`, `MIN()`, `MAX()` +- **조건**: `IF(조건, 참값, 거짓값)` +- **논리**: `AND()`, `OR()`, `NOT()` + +--- + +## 2. 데이터베이스 스키마 + +### 2.1 테이블 구조 + +#### `quote_formula_categories` (수식 카테고리) + +```sql +CREATE TABLE quote_formula_categories ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + + -- 기본 정보 + code VARCHAR(50) NOT NULL COMMENT '카테고리 코드 (예: BASIC_INFO, PRODUCTION_SIZE)', + name VARCHAR(100) NOT NULL COMMENT '카테고리 이름 (예: 기본정보, 제작사이즈)', + description TEXT NULL COMMENT '카테고리 설명', + + -- 정렬 및 상태 + sort_order INT UNSIGNED DEFAULT 0 COMMENT '실행 순서', + is_active BOOLEAN DEFAULT TRUE COMMENT '활성화 여부', + + -- 메타 + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, + + -- 인덱스 + INDEX idx_tenant_sort (tenant_id, sort_order), + INDEX idx_code (code), + UNIQUE INDEX uq_tenant_code (tenant_id, code, deleted_at) +) ENGINE=InnoDB COMMENT='견적 수식 카테고리'; +``` + +#### `quote_formulas` (수식) + +```sql +CREATE TABLE quote_formulas ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + category_id BIGINT UNSIGNED NOT NULL COMMENT '카테고리 ID', + product_id BIGINT UNSIGNED NULL COMMENT '특정 제품용 수식 (NULL = 공통)', + + -- 수식 정보 + name VARCHAR(200) NOT NULL COMMENT '수식 이름', + variable VARCHAR(50) NOT NULL COMMENT '변수명 (예: W1, H1, M)', + type ENUM('input', 'calculation', 'range', 'mapping') NOT NULL DEFAULT 'calculation', + + -- 수식 내용 + formula TEXT NULL COMMENT '계산식 (type=calculation일 때)', + output_type ENUM('variable', 'item') DEFAULT 'variable' COMMENT '결과 타입', + + -- 메타 데이터 + description TEXT NULL COMMENT '수식 설명', + sort_order INT UNSIGNED DEFAULT 0 COMMENT '카테고리 내 순서', + is_active BOOLEAN DEFAULT TRUE COMMENT '활성화 여부', + + -- 감사 + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, + + -- 인덱스 + INDEX idx_tenant (tenant_id), + INDEX idx_category_sort (category_id, sort_order), + INDEX idx_variable (variable), + INDEX idx_product (product_id), + FOREIGN KEY (category_id) REFERENCES quote_formula_categories(id) ON DELETE CASCADE +) ENGINE=InnoDB COMMENT='견적 수식'; +``` + +#### `quote_formula_ranges` (범위별 값) + +```sql +CREATE TABLE quote_formula_ranges ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + formula_id BIGINT UNSIGNED NOT NULL, + + -- 범위 조건 + min_value DECIMAL(15,4) NULL COMMENT '최소값 (NULL = 제한없음)', + max_value DECIMAL(15,4) NULL COMMENT '최대값 (NULL = 제한없음)', + condition_variable VARCHAR(50) NOT NULL COMMENT '조건 변수 (예: M, K)', + + -- 결과 + result_value VARCHAR(500) NOT NULL COMMENT '결과값 (수식 또는 고정값)', + result_type ENUM('fixed', 'formula') DEFAULT 'fixed' COMMENT '결과 유형', + + -- 정렬 + sort_order INT UNSIGNED DEFAULT 0, + + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + + INDEX idx_formula (formula_id), + FOREIGN KEY (formula_id) REFERENCES quote_formulas(id) ON DELETE CASCADE +) ENGINE=InnoDB COMMENT='수식 범위별 값'; +``` + +#### `quote_formula_mappings` (매핑 값) + +```sql +CREATE TABLE quote_formula_mappings ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + formula_id BIGINT UNSIGNED NOT NULL, + + -- 매핑 조건 + source_variable VARCHAR(50) NOT NULL COMMENT '소스 변수 (예: GT)', + source_value VARCHAR(200) NOT NULL COMMENT '소스 값 (예: 벽부, 노출)', + + -- 결과 + result_value VARCHAR(500) NOT NULL COMMENT '결과값', + result_type ENUM('fixed', 'formula') DEFAULT 'fixed', + + -- 정렬 + sort_order INT UNSIGNED DEFAULT 0, + + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + + INDEX idx_formula (formula_id), + INDEX idx_source (source_variable, source_value), + FOREIGN KEY (formula_id) REFERENCES quote_formulas(id) ON DELETE CASCADE +) ENGINE=InnoDB COMMENT='수식 매핑 값'; +``` + +#### `quote_formula_items` (품목 출력) + +```sql +CREATE TABLE quote_formula_items ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + formula_id BIGINT UNSIGNED NOT NULL, + + -- 품목 정보 + item_code VARCHAR(50) NOT NULL COMMENT '품목 코드', + item_name VARCHAR(200) NOT NULL COMMENT '품목명', + specification VARCHAR(100) NULL COMMENT '규격', + unit VARCHAR(20) NOT NULL COMMENT '단위 (M, M2, EA 등)', + + -- 수량 계산 + quantity_formula VARCHAR(500) NOT NULL COMMENT '수량 계산식', + + -- 단가 연결 + unit_price_formula VARCHAR(500) NULL COMMENT '단가 계산식 (NULL = 품목마스터 참조)', + + -- 정렬 + sort_order INT UNSIGNED DEFAULT 0, + + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + + INDEX idx_formula (formula_id), + INDEX idx_item_code (item_code), + FOREIGN KEY (formula_id) REFERENCES quote_formulas(id) ON DELETE CASCADE +) ENGINE=InnoDB COMMENT='수식 품목 출력'; +``` + +### 2.2 기본 데이터 (Seeder) + +```php +// 카테고리 기본 데이터 +$categories = [ + ['code' => 'BASIC_INFO', 'name' => '기본정보', 'sort_order' => 1], + ['code' => 'PRODUCTION_SIZE', 'name' => '제작사이즈', 'sort_order' => 2], + ['code' => 'AREA', 'name' => '면적', 'sort_order' => 3], + ['code' => 'MOTOR_CAPACITY', 'name' => '모터용량산출', 'sort_order' => 4], + ['code' => 'WINDING_SHAFT', 'name' => '감기샤프트', 'sort_order' => 5], + ['code' => 'BRACKET_SUPPORT', 'name' => '브라켓&받침용영역', 'sort_order' => 6], + ['code' => 'GUIDE_RAIL', 'name' => '가이드레일', 'sort_order' => 7], + ['code' => 'GUIDE_RAIL_TYPE', 'name' => '가이드레일설치유형', 'sort_order' => 8], + ['code' => 'SHUTTER_BOX', 'name' => '셔터박스', 'sort_order' => 9], + ['code' => 'BOTTOM_FINISH', 'name' => '하단마감재', 'sort_order' => 10], +]; + +// 기본정보 수식 예시 +$basicFormulas = [ + ['variable' => 'PC', 'name' => '제품 카테고리', 'type' => 'input'], + ['variable' => 'W0', 'name' => '오픈사이즈 가로', 'type' => 'input'], + ['variable' => 'H0', 'name' => '오픈사이즈 세로', 'type' => 'input'], + ['variable' => 'GT', 'name' => '가이드레일 유형', 'type' => 'input'], + ['variable' => 'MP', 'name' => '모터 전원', 'type' => 'input'], + ['variable' => 'CT', 'name' => '연동제어기', 'type' => 'input'], + ['variable' => 'QTY', 'name' => '수량', 'type' => 'input'], + ['variable' => 'WS', 'name' => '마구리 날개치수', 'type' => 'input', 'formula' => '50'], + ['variable' => 'INSP', 'name' => '검사비', 'type' => 'input', 'formula' => '50000'], +]; + +// 제작사이즈 수식 예시 +$productionFormulas = [ + ['variable' => 'W1', 'name' => '제작폭', 'type' => 'calculation', 'formula' => 'W0 + 140'], + ['variable' => 'H1', 'name' => '제작높이', 'type' => 'calculation', 'formula' => 'H0 + 350'], +]; + +// 면적 수식 예시 +$areaFormulas = [ + ['variable' => 'M', 'name' => '면적', 'type' => 'calculation', 'formula' => 'ROUND(W1 * H1 / 1000000, 4)'], + ['variable' => 'K', 'name' => '중량', 'type' => 'calculation', 'formula' => 'ROUND(M * 2.5, 2)'], +]; +``` + +--- + +## 3. 모델 정의 + +### 3.1 QuoteFormulaCategory.php + +```php + 'boolean', + 'sort_order' => 'integer', + ]; + + /** + * 카테고리에 속한 수식들 + */ + public function formulas(): HasMany + { + return $this->hasMany(QuoteFormula::class, 'category_id') + ->orderBy('sort_order'); + } + + /** + * 활성화된 수식만 + */ + public function activeFormulas(): HasMany + { + return $this->formulas()->where('is_active', true); + } + + /** + * Scope: 활성화된 카테고리 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope: 정렬 순서 + */ + public function scopeOrdered($query) + { + return $query->orderBy('sort_order'); + } +} +``` + +### 3.2 QuoteFormula.php + +```php + 'boolean', + 'sort_order' => 'integer', + ]; + + // 수식 유형 상수 + const TYPE_INPUT = 'input'; + const TYPE_CALCULATION = 'calculation'; + const TYPE_RANGE = 'range'; + const TYPE_MAPPING = 'mapping'; + + // 출력 유형 상수 + const OUTPUT_VARIABLE = 'variable'; + const OUTPUT_ITEM = 'item'; + + /** + * 카테고리 관계 + */ + public function category(): BelongsTo + { + return $this->belongsTo(QuoteFormulaCategory::class, 'category_id'); + } + + /** + * 제품 관계 (특정 제품용 수식) + */ + public function product(): BelongsTo + { + return $this->belongsTo(\App\Models\Product::class, 'product_id'); + } + + /** + * 범위 규칙 + */ + public function ranges(): HasMany + { + return $this->hasMany(QuoteFormulaRange::class, 'formula_id') + ->orderBy('sort_order'); + } + + /** + * 매핑 규칙 + */ + public function mappings(): HasMany + { + return $this->hasMany(QuoteFormulaMapping::class, 'formula_id') + ->orderBy('sort_order'); + } + + /** + * 품목 출력 + */ + public function items(): HasMany + { + return $this->hasMany(QuoteFormulaItem::class, 'formula_id') + ->orderBy('sort_order'); + } + + /** + * 수식이 공통 수식인지 확인 + */ + public function isCommon(): bool + { + return is_null($this->product_id); + } + + /** + * Scope: 공통 수식 + */ + public function scopeCommon($query) + { + return $query->whereNull('product_id'); + } + + /** + * Scope: 특정 제품 수식 + */ + public function scopeForProduct($query, $productId) + { + return $query->where(function ($q) use ($productId) { + $q->whereNull('product_id') + ->orWhere('product_id', $productId); + }); + } +} +``` + +### 3.3 QuoteFormulaRange.php + +```php + 'decimal:4', + 'max_value' => 'decimal:4', + 'sort_order' => 'integer', + ]; + + const RESULT_FIXED = 'fixed'; + const RESULT_FORMULA = 'formula'; + + public function formula(): BelongsTo + { + return $this->belongsTo(QuoteFormula::class, 'formula_id'); + } + + /** + * 값이 범위 내에 있는지 확인 + */ + public function isInRange($value): bool + { + $min = $this->min_value; + $max = $this->max_value; + + if (is_null($min) && is_null($max)) { + return true; + } + + if (is_null($min)) { + return $value <= $max; + } + + if (is_null($max)) { + return $value >= $min; + } + + return $value >= $min && $value <= $max; + } +} +``` + +### 3.4 QuoteFormulaMapping.php + +```php + 'integer', + ]; + + const RESULT_FIXED = 'fixed'; + const RESULT_FORMULA = 'formula'; + + public function formula(): BelongsTo + { + return $this->belongsTo(QuoteFormula::class, 'formula_id'); + } +} +``` + +### 3.5 QuoteFormulaItem.php + +```php + 'integer', + ]; + + public function formula(): BelongsTo + { + return $this->belongsTo(QuoteFormula::class, 'formula_id'); + } +} +``` + +--- + +## 4. Service 계층 + +### 4.1 QuoteFormulaCategoryService.php + +```php +withCount('formulas') + ->withCount(['formulas as active_formulas_count' => function ($q) { + $q->where('is_active', true); + }]); + + // 테넌트 필터 + if ($tenantId && $tenantId !== 'all') { + $query->where('tenant_id', $tenantId); + } + + // 검색 + if (!empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('code', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + // 활성화 필터 + if (isset($filters['is_active'])) { + $query->where('is_active', $filters['is_active']); + } + + // 정렬 + $sortBy = $filters['sort_by'] ?? 'sort_order'; + $sortDirection = $filters['sort_direction'] ?? 'asc'; + $query->orderBy($sortBy, $sortDirection); + + return $query->paginate($perPage); + } + + /** + * 전체 카테고리 목록 (선택용) + */ + public function getAllCategories(): \Illuminate\Database\Eloquent\Collection + { + $tenantId = session('selected_tenant_id'); + + return QuoteFormulaCategory::query() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->orderBy('sort_order') + ->get(); + } + + /** + * 카테고리 생성 + */ + public function createCategory(array $data): QuoteFormulaCategory + { + $tenantId = session('selected_tenant_id'); + + return DB::transaction(function () use ($data, $tenantId) { + // 자동 sort_order 할당 + if (!isset($data['sort_order'])) { + $maxOrder = QuoteFormulaCategory::where('tenant_id', $tenantId)->max('sort_order'); + $data['sort_order'] = ($maxOrder ?? 0) + 1; + } + + return QuoteFormulaCategory::create([ + 'tenant_id' => $tenantId, + 'code' => $data['code'], + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'sort_order' => $data['sort_order'], + 'is_active' => $data['is_active'] ?? true, + 'created_by' => auth()->id(), + ]); + }); + } + + /** + * 카테고리 수정 + */ + public function updateCategory(int $id, array $data): QuoteFormulaCategory + { + $category = QuoteFormulaCategory::findOrFail($id); + + $category->update([ + 'code' => $data['code'] ?? $category->code, + 'name' => $data['name'] ?? $category->name, + 'description' => $data['description'] ?? $category->description, + 'sort_order' => $data['sort_order'] ?? $category->sort_order, + 'is_active' => $data['is_active'] ?? $category->is_active, + 'updated_by' => auth()->id(), + ]); + + return $category->fresh(); + } + + /** + * 카테고리 삭제 + */ + public function deleteCategory(int $id): void + { + $category = QuoteFormulaCategory::findOrFail($id); + + // 연관 수식이 있으면 삭제 불가 + if ($category->formulas()->count() > 0) { + throw new \Exception('연관된 수식이 있어 삭제할 수 없습니다.'); + } + + $category->delete(); + } + + /** + * 카테고리 순서 변경 + */ + public function reorderCategories(array $orderData): void + { + DB::transaction(function () use ($orderData) { + foreach ($orderData as $item) { + QuoteFormulaCategory::where('id', $item['id']) + ->update(['sort_order' => $item['sort_order']]); + } + }); + } +} +``` + +### 4.2 QuoteFormulaService.php + +```php +with(['category', 'product']) + ->withCount(['ranges', 'mappings', 'items']); + + // 테넌트 필터 + if ($tenantId && $tenantId !== 'all') { + $query->where('tenant_id', $tenantId); + } + + // 카테고리 필터 + if (!empty($filters['category_id'])) { + $query->where('category_id', $filters['category_id']); + } + + // 제품 필터 (공통/특정) + if (isset($filters['product_id'])) { + if ($filters['product_id'] === 'common') { + $query->whereNull('product_id'); + } else { + $query->where('product_id', $filters['product_id']); + } + } + + // 유형 필터 + if (!empty($filters['type'])) { + $query->where('type', $filters['type']); + } + + // 검색 + if (!empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('variable', 'like', "%{$search}%") + ->orWhere('formula', 'like', "%{$search}%"); + }); + } + + // 활성화 필터 + if (isset($filters['is_active'])) { + $query->where('is_active', $filters['is_active']); + } + + // 정렬 + $sortBy = $filters['sort_by'] ?? 'sort_order'; + $sortDirection = $filters['sort_direction'] ?? 'asc'; + + if ($sortBy === 'category') { + $query->join('quote_formula_categories', 'quote_formulas.category_id', '=', 'quote_formula_categories.id') + ->orderBy('quote_formula_categories.sort_order', $sortDirection) + ->orderBy('quote_formulas.sort_order', $sortDirection) + ->select('quote_formulas.*'); + } else { + $query->orderBy($sortBy, $sortDirection); + } + + return $query->paginate($perPage); + } + + /** + * 카테고리별 수식 목록 (실행 순서용) + */ + public function getFormulasByCategory(?int $productId = null): \Illuminate\Support\Collection + { + $tenantId = session('selected_tenant_id'); + + return QuoteFormula::query() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->where(function ($q) use ($productId) { + $q->whereNull('product_id'); + if ($productId) { + $q->orWhere('product_id', $productId); + } + }) + ->with(['category', 'ranges', 'mappings', 'items']) + ->get() + ->groupBy('category.code') + ->sortBy(fn($formulas) => $formulas->first()->category->sort_order); + } + + /** + * 수식 상세 조회 + */ + public function getFormulaById(int $id): QuoteFormula + { + return QuoteFormula::with(['category', 'product', 'ranges', 'mappings', 'items']) + ->findOrFail($id); + } + + /** + * 수식 생성 + */ + public function createFormula(array $data): QuoteFormula + { + $tenantId = session('selected_tenant_id'); + + return DB::transaction(function () use ($data, $tenantId) { + // 자동 sort_order + if (!isset($data['sort_order'])) { + $maxOrder = QuoteFormula::where('tenant_id', $tenantId) + ->where('category_id', $data['category_id']) + ->max('sort_order'); + $data['sort_order'] = ($maxOrder ?? 0) + 1; + } + + $formula = QuoteFormula::create([ + 'tenant_id' => $tenantId, + 'category_id' => $data['category_id'], + 'product_id' => $data['product_id'] ?? null, + 'name' => $data['name'], + 'variable' => $data['variable'], + 'type' => $data['type'], + 'formula' => $data['formula'] ?? null, + 'output_type' => $data['output_type'] ?? 'variable', + 'description' => $data['description'] ?? null, + 'sort_order' => $data['sort_order'], + 'is_active' => $data['is_active'] ?? true, + 'created_by' => auth()->id(), + ]); + + // 범위별 규칙 저장 + if ($data['type'] === QuoteFormula::TYPE_RANGE && !empty($data['ranges'])) { + $this->saveRanges($formula, $data['ranges']); + } + + // 매핑 규칙 저장 + if ($data['type'] === QuoteFormula::TYPE_MAPPING && !empty($data['mappings'])) { + $this->saveMappings($formula, $data['mappings']); + } + + // 품목 출력 저장 + if ($data['output_type'] === QuoteFormula::OUTPUT_ITEM && !empty($data['items'])) { + $this->saveItems($formula, $data['items']); + } + + return $formula->load(['ranges', 'mappings', 'items']); + }); + } + + /** + * 수식 수정 + */ + public function updateFormula(int $id, array $data): QuoteFormula + { + $formula = QuoteFormula::findOrFail($id); + + return DB::transaction(function () use ($formula, $data) { + $formula->update([ + 'category_id' => $data['category_id'] ?? $formula->category_id, + 'product_id' => array_key_exists('product_id', $data) ? $data['product_id'] : $formula->product_id, + 'name' => $data['name'] ?? $formula->name, + 'variable' => $data['variable'] ?? $formula->variable, + 'type' => $data['type'] ?? $formula->type, + 'formula' => $data['formula'] ?? $formula->formula, + 'output_type' => $data['output_type'] ?? $formula->output_type, + 'description' => $data['description'] ?? $formula->description, + 'sort_order' => $data['sort_order'] ?? $formula->sort_order, + 'is_active' => $data['is_active'] ?? $formula->is_active, + 'updated_by' => auth()->id(), + ]); + + // 범위 규칙 업데이트 + if (isset($data['ranges'])) { + $formula->ranges()->delete(); + if ($formula->type === QuoteFormula::TYPE_RANGE) { + $this->saveRanges($formula, $data['ranges']); + } + } + + // 매핑 규칙 업데이트 + if (isset($data['mappings'])) { + $formula->mappings()->delete(); + if ($formula->type === QuoteFormula::TYPE_MAPPING) { + $this->saveMappings($formula, $data['mappings']); + } + } + + // 품목 출력 업데이트 + if (isset($data['items'])) { + $formula->items()->delete(); + if ($formula->output_type === QuoteFormula::OUTPUT_ITEM) { + $this->saveItems($formula, $data['items']); + } + } + + return $formula->fresh(['category', 'product', 'ranges', 'mappings', 'items']); + }); + } + + /** + * 수식 삭제 + */ + public function deleteFormula(int $id): void + { + $formula = QuoteFormula::findOrFail($id); + $formula->delete(); + } + + /** + * 범위 규칙 저장 + */ + private function saveRanges(QuoteFormula $formula, array $ranges): void + { + foreach ($ranges as $index => $range) { + QuoteFormulaRange::create([ + 'formula_id' => $formula->id, + 'min_value' => $range['min_value'] ?? null, + 'max_value' => $range['max_value'] ?? null, + 'condition_variable' => $range['condition_variable'], + 'result_value' => $range['result_value'], + 'result_type' => $range['result_type'] ?? 'fixed', + 'sort_order' => $index + 1, + ]); + } + } + + /** + * 매핑 규칙 저장 + */ + private function saveMappings(QuoteFormula $formula, array $mappings): void + { + foreach ($mappings as $index => $mapping) { + QuoteFormulaMapping::create([ + 'formula_id' => $formula->id, + 'source_variable' => $mapping['source_variable'], + 'source_value' => $mapping['source_value'], + 'result_value' => $mapping['result_value'], + 'result_type' => $mapping['result_type'] ?? 'fixed', + 'sort_order' => $index + 1, + ]); + } + } + + /** + * 품목 출력 저장 + */ + private function saveItems(QuoteFormula $formula, array $items): void + { + foreach ($items as $index => $item) { + QuoteFormulaItem::create([ + 'formula_id' => $formula->id, + 'item_code' => $item['item_code'], + 'item_name' => $item['item_name'], + 'specification' => $item['specification'] ?? null, + 'unit' => $item['unit'], + 'quantity_formula' => $item['quantity_formula'], + 'unit_price_formula' => $item['unit_price_formula'] ?? null, + 'sort_order' => $index + 1, + ]); + } + } + + /** + * 변수 목록 조회 (수식 입력 시 참조용) + */ + public function getAvailableVariables(?int $productId = null): array + { + $tenantId = session('selected_tenant_id'); + + $formulas = QuoteFormula::query() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->where('output_type', 'variable') + ->where(function ($q) use ($productId) { + $q->whereNull('product_id'); + if ($productId) { + $q->orWhere('product_id', $productId); + } + }) + ->with('category') + ->orderBy('category_id') + ->orderBy('sort_order') + ->get(); + + return $formulas->map(fn($f) => [ + 'variable' => $f->variable, + 'name' => $f->name, + 'category' => $f->category->name, + 'type' => $f->type, + ])->toArray(); + } +} +``` + +### 4.3 FormulaEvaluatorService.php (수식 평가) + +```php + false, 'errors' => ['수식이 비어있습니다.']]; + } + + // 괄호 매칭 검증 + if (!$this->validateParentheses($formula)) { + $errors[] = '괄호가 올바르게 닫히지 않았습니다.'; + } + + // 변수 추출 및 검증 + $variables = $this->extractVariables($formula); + + // 지원 함수 검증 + $functions = $this->extractFunctions($formula); + $supportedFunctions = ['SUM', 'ROUND', 'CEIL', 'FLOOR', 'ABS', 'MIN', 'MAX', 'IF', 'AND', 'OR', 'NOT']; + + foreach ($functions as $func) { + if (!in_array(strtoupper($func), $supportedFunctions)) { + $errors[] = "지원하지 않는 함수입니다: {$func}"; + } + } + + return [ + 'success' => empty($errors), + 'errors' => $errors, + 'variables' => $variables, + 'functions' => $functions, + ]; + } + + /** + * 수식 평가 + */ + public function evaluate(string $formula, array $variables = []): mixed + { + $this->variables = array_merge($this->variables, $variables); + $this->errors = []; + + try { + // 변수 치환 + $expression = $this->substituteVariables($formula); + + // 함수 처리 + $expression = $this->processFunctions($expression); + + // 최종 계산 + $result = $this->calculateExpression($expression); + + return $result; + } catch (\Exception $e) { + $this->errors[] = $e->getMessage(); + return null; + } + } + + /** + * 범위별 수식 평가 + */ + public function evaluateRange(QuoteFormula $formula, array $variables = []): mixed + { + $conditionVar = $formula->ranges->first()?->condition_variable; + $value = $variables[$conditionVar] ?? 0; + + foreach ($formula->ranges as $range) { + if ($range->isInRange($value)) { + if ($range->result_type === 'formula') { + return $this->evaluate($range->result_value, $variables); + } + return $range->result_value; + } + } + + return null; + } + + /** + * 매핑 수식 평가 + */ + public function evaluateMapping(QuoteFormula $formula, array $variables = []): mixed + { + foreach ($formula->mappings as $mapping) { + $sourceValue = $variables[$mapping->source_variable] ?? null; + + if ($sourceValue == $mapping->source_value) { + if ($mapping->result_type === 'formula') { + return $this->evaluate($mapping->result_value, $variables); + } + return $mapping->result_value; + } + } + + return null; + } + + /** + * 전체 수식 실행 (카테고리 순서대로) + */ + public function executeAll( + \Illuminate\Support\Collection $formulasByCategory, + array $inputVariables = [] + ): array { + $this->variables = $inputVariables; + $results = []; + $items = []; + + foreach ($formulasByCategory as $categoryCode => $formulas) { + foreach ($formulas as $formula) { + $result = $this->executeFormula($formula); + + if ($formula->output_type === QuoteFormula::OUTPUT_VARIABLE) { + $this->variables[$formula->variable] = $result; + $results[$formula->variable] = [ + 'name' => $formula->name, + 'value' => $result, + 'category' => $formula->category->name, + ]; + } else { + // 품목 출력 + foreach ($formula->items as $item) { + $quantity = $this->evaluate($item->quantity_formula); + $unitPrice = $item->unit_price_formula + ? $this->evaluate($item->unit_price_formula) + : $this->getItemPrice($item->item_code); + + $items[] = [ + 'item_code' => $item->item_code, + 'item_name' => $item->item_name, + 'specification' => $item->specification, + 'unit' => $item->unit, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'total_price' => $quantity * $unitPrice, + 'formula_variable' => $formula->variable, + ]; + } + } + } + } + + return [ + 'variables' => $results, + 'items' => $items, + 'errors' => $this->errors, + ]; + } + + /** + * 단일 수식 실행 + */ + private function executeFormula(QuoteFormula $formula): mixed + { + return match ($formula->type) { + QuoteFormula::TYPE_INPUT => $this->variables[$formula->variable] ?? + ($formula->formula ? $this->evaluate($formula->formula) : null), + QuoteFormula::TYPE_CALCULATION => $this->evaluate($formula->formula, $this->variables), + QuoteFormula::TYPE_RANGE => $this->evaluateRange($formula, $this->variables), + QuoteFormula::TYPE_MAPPING => $this->evaluateMapping($formula, $this->variables), + default => null, + }; + } + + // === Private Helper Methods === + + private function validateParentheses(string $formula): bool + { + $count = 0; + foreach (str_split($formula) as $char) { + if ($char === '(') $count++; + if ($char === ')') $count--; + if ($count < 0) return false; + } + return $count === 0; + } + + private function extractVariables(string $formula): array + { + preg_match_all('/\b([A-Z][A-Z0-9_]*)\b/', $formula, $matches); + $variables = array_unique($matches[1] ?? []); + + // 함수명 제외 + $functions = ['SUM', 'ROUND', 'CEIL', 'FLOOR', 'ABS', 'MIN', 'MAX', 'IF', 'AND', 'OR', 'NOT']; + return array_diff($variables, $functions); + } + + private function extractFunctions(string $formula): array + { + preg_match_all('/\b([A-Za-z_]+)\s*\(/', $formula, $matches); + return array_unique($matches[1] ?? []); + } + + private function substituteVariables(string $formula): string + { + foreach ($this->variables as $var => $value) { + $formula = preg_replace('/\b' . preg_quote($var, '/') . '\b/', (string)$value, $formula); + } + return $formula; + } + + private function processFunctions(string $expression): string + { + // ROUND(value, decimals) + $expression = preg_replace_callback( + '/ROUND\s*\(\s*([^,]+)\s*,\s*(\d+)\s*\)/i', + fn($m) => round((float)$this->calculateExpression($m[1]), (int)$m[2]), + $expression + ); + + // SUM(a, b, c, ...) + $expression = preg_replace_callback( + '/SUM\s*\(([^)]+)\)/i', + fn($m) => array_sum(array_map('floatval', explode(',', $m[1]))), + $expression + ); + + // MIN, MAX + $expression = preg_replace_callback( + '/MIN\s*\(([^)]+)\)/i', + fn($m) => min(array_map('floatval', explode(',', $m[1]))), + $expression + ); + + $expression = preg_replace_callback( + '/MAX\s*\(([^)]+)\)/i', + fn($m) => max(array_map('floatval', explode(',', $m[1]))), + $expression + ); + + // IF(condition, true_val, false_val) + $expression = preg_replace_callback( + '/IF\s*\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^)]+)\s*\)/i', + function ($m) { + $condition = $this->evaluateCondition($m[1]); + return $condition ? $this->calculateExpression($m[2]) : $this->calculateExpression($m[3]); + }, + $expression + ); + + // ABS, CEIL, FLOOR + $expression = preg_replace_callback('/ABS\s*\(([^)]+)\)/i', fn($m) => abs((float)$this->calculateExpression($m[1])), $expression); + $expression = preg_replace_callback('/CEIL\s*\(([^)]+)\)/i', fn($m) => ceil((float)$this->calculateExpression($m[1])), $expression); + $expression = preg_replace_callback('/FLOOR\s*\(([^)]+)\)/i', fn($m) => floor((float)$this->calculateExpression($m[1])), $expression); + + return $expression; + } + + private function calculateExpression(string $expression): float + { + // 안전한 수식 평가 (숫자, 연산자, 괄호만 허용) + $expression = preg_replace('/[^0-9+\-*\/().%\s]/', '', $expression); + + if (empty(trim($expression))) { + return 0; + } + + try { + // eval 대신 안전한 계산 라이브러리 사용 권장 + // 여기서는 간단히 eval 사용 (프로덕션에서는 symfony/expression-language 등 사용) + return (float)eval("return {$expression};"); + } catch (\Throwable $e) { + $this->errors[] = "계산 오류: {$expression}"; + return 0; + } + } + + private function evaluateCondition(string $condition): bool + { + // 비교 연산자 처리 + if (preg_match('/(.+)(>=|<=|>|<|==|!=)(.+)/', $condition, $m)) { + $left = (float)$this->calculateExpression(trim($m[1])); + $right = (float)$this->calculateExpression(trim($m[3])); + $op = $m[2]; + + return match ($op) { + '>=' => $left >= $right, + '<=' => $left <= $right, + '>' => $left > $right, + '<' => $left < $right, + '==' => $left == $right, + '!=' => $left != $right, + default => false, + }; + } + + return (bool)$this->calculateExpression($condition); + } + + private function getItemPrice(string $itemCode): float + { + // TODO: 품목 마스터에서 단가 조회 + return 0; + } + + /** + * 에러 목록 반환 + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * 현재 변수 상태 반환 + */ + public function getVariables(): array + { + return $this->variables; + } +} +``` + +--- + +## 5. API 컨트롤러 + +### 5.1 QuoteFormulaCategoryController.php + +```php +service->getCategories( + $request->all(), + $request->integer('per_page', 15) + ); + + if ($request->header('HX-Request')) { + $html = view('quote-formulas.partials.category-table', compact('categories'))->render(); + return response()->json(['html' => $html]); + } + + return response()->json([ + 'success' => true, + 'data' => $categories->items(), + 'meta' => [ + 'current_page' => $categories->currentPage(), + 'last_page' => $categories->lastPage(), + 'per_page' => $categories->perPage(), + 'total' => $categories->total(), + ], + ]); + } + + /** + * 전체 카테고리 (선택용) + */ + public function all(Request $request): JsonResponse + { + $categories = $this->service->getAllCategories(); + return response()->json(['success' => true, 'data' => $categories]); + } + + /** + * 카테고리 상세 + */ + public function show(int $id): JsonResponse + { + $category = \App\Models\Quote\QuoteFormulaCategory::with('formulas')->findOrFail($id); + return response()->json(['success' => true, 'data' => $category]); + } + + /** + * 카테고리 생성 + */ + public function store(StoreQuoteFormulaCategoryRequest $request): JsonResponse + { + $category = $this->service->createCategory($request->validated()); + + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => true, + 'message' => '카테고리가 생성되었습니다.', + 'redirect' => route('quote-formulas.categories.index'), + ]); + } + + return response()->json(['success' => true, 'data' => $category], 201); + } + + /** + * 카테고리 수정 + */ + public function update(int $id, UpdateQuoteFormulaCategoryRequest $request): JsonResponse + { + $category = $this->service->updateCategory($id, $request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 수정되었습니다.', + 'data' => $category, + ]); + } + + /** + * 카테고리 삭제 + */ + public function destroy(int $id): JsonResponse + { + try { + $this->service->deleteCategory($id); + return response()->json(['success' => true, 'message' => '카테고리가 삭제되었습니다.']); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => $e->getMessage()], 400); + } + } + + /** + * 카테고리 순서 변경 + */ + public function reorder(Request $request): JsonResponse + { + $request->validate([ + 'order' => 'required|array', + 'order.*.id' => 'required|integer|exists:quote_formula_categories,id', + 'order.*.sort_order' => 'required|integer|min:1', + ]); + + $this->service->reorderCategories($request->input('order')); + + return response()->json(['success' => true, 'message' => '순서가 변경되었습니다.']); + } +} +``` + +### 5.2 QuoteFormulaController.php + +```php +service->getFormulas( + $request->all(), + $request->integer('per_page', 15) + ); + + if ($request->header('HX-Request')) { + $html = view('quote-formulas.partials.formula-table', compact('formulas'))->render(); + return response()->json(['html' => $html]); + } + + return response()->json([ + 'success' => true, + 'data' => $formulas->items(), + 'meta' => [ + 'current_page' => $formulas->currentPage(), + 'last_page' => $formulas->lastPage(), + 'per_page' => $formulas->perPage(), + 'total' => $formulas->total(), + ], + ]); + } + + /** + * 수식 상세 + */ + public function show(int $id): JsonResponse + { + $formula = $this->service->getFormulaById($id); + return response()->json(['success' => true, 'data' => $formula]); + } + + /** + * 수식 생성 + */ + public function store(StoreQuoteFormulaRequest $request): JsonResponse + { + $formula = $this->service->createFormula($request->validated()); + + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => true, + 'message' => '수식이 생성되었습니다.', + 'redirect' => route('quote-formulas.edit', $formula->id), + ]); + } + + return response()->json(['success' => true, 'data' => $formula], 201); + } + + /** + * 수식 수정 + */ + public function update(int $id, UpdateQuoteFormulaRequest $request): JsonResponse + { + $formula = $this->service->updateFormula($id, $request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '수식이 수정되었습니다.', + 'data' => $formula, + ]); + } + + /** + * 수식 삭제 + */ + public function destroy(int $id): JsonResponse + { + $this->service->deleteFormula($id); + return response()->json(['success' => true, 'message' => '수식이 삭제되었습니다.']); + } + + /** + * 사용 가능한 변수 목록 + */ + public function variables(Request $request): JsonResponse + { + $productId = $request->input('product_id'); + $variables = $this->service->getAvailableVariables($productId); + + return response()->json(['success' => true, 'data' => $variables]); + } + + /** + * 수식 검증 + */ + public function validate(Request $request): JsonResponse + { + $request->validate(['formula' => 'required|string']); + + $result = $this->evaluator->validateFormula($request->input('formula')); + + return response()->json([ + 'success' => $result['success'], + 'errors' => $result['errors'] ?? [], + 'variables' => $result['variables'] ?? [], + 'functions' => $result['functions'] ?? [], + ]); + } + + /** + * 수식 테스트 실행 + */ + public function test(Request $request): JsonResponse + { + $request->validate([ + 'formula' => 'required|string', + 'variables' => 'array', + ]); + + $result = $this->evaluator->evaluate( + $request->input('formula'), + $request->input('variables', []) + ); + + return response()->json([ + 'success' => empty($this->evaluator->getErrors()), + 'result' => $result, + 'errors' => $this->evaluator->getErrors(), + ]); + } + + /** + * 전체 수식 시뮬레이션 + */ + public function simulate(Request $request): JsonResponse + { + $request->validate([ + 'product_id' => 'nullable|integer', + 'input_variables' => 'required|array', + ]); + + $productId = $request->input('product_id'); + $inputVariables = $request->input('input_variables'); + + $formulasByCategory = $this->service->getFormulasByCategory($productId); + $result = $this->evaluator->executeAll($formulasByCategory, $inputVariables); + + return response()->json([ + 'success' => empty($result['errors']), + 'variables' => $result['variables'], + 'items' => $result['items'], + 'errors' => $result['errors'], + ]); + } +} +``` + +--- + +## 6. FormRequest 검증 + +### 6.1 StoreQuoteFormulaCategoryRequest.php + +```php +check(); + } + + public function rules(): array + { + return [ + 'code' => 'required|string|max:50|regex:/^[A-Z_]+$/', + 'name' => 'required|string|max:100', + 'description' => 'nullable|string|max:500', + 'sort_order' => 'nullable|integer|min:1', + 'is_active' => 'boolean', + ]; + } + + public function messages(): array + { + return [ + 'code.required' => '카테고리 코드는 필수입니다.', + 'code.regex' => '카테고리 코드는 대문자와 언더스코어만 사용할 수 있습니다.', + 'name.required' => '카테고리 이름은 필수입니다.', + ]; + } +} +``` + +### 6.2 StoreQuoteFormulaRequest.php + +```php +check(); + } + + public function rules(): array + { + return [ + 'category_id' => 'required|integer|exists:quote_formula_categories,id', + 'product_id' => 'nullable|integer|exists:products,id', + 'name' => 'required|string|max:200', + 'variable' => 'required|string|max:50|regex:/^[A-Z][A-Z0-9_]*$/', + 'type' => 'required|in:input,calculation,range,mapping', + 'formula' => 'nullable|string|max:2000', + 'output_type' => 'in:variable,item', + 'description' => 'nullable|string|max:500', + 'sort_order' => 'nullable|integer|min:1', + 'is_active' => 'boolean', + + // 범위 규칙 + 'ranges' => 'array', + 'ranges.*.min_value' => 'nullable|numeric', + 'ranges.*.max_value' => 'nullable|numeric', + 'ranges.*.condition_variable' => 'required_with:ranges|string|max:50', + 'ranges.*.result_value' => 'required_with:ranges|string|max:500', + 'ranges.*.result_type' => 'in:fixed,formula', + + // 매핑 규칙 + 'mappings' => 'array', + 'mappings.*.source_variable' => 'required_with:mappings|string|max:50', + 'mappings.*.source_value' => 'required_with:mappings|string|max:200', + 'mappings.*.result_value' => 'required_with:mappings|string|max:500', + 'mappings.*.result_type' => 'in:fixed,formula', + + // 품목 출력 + 'items' => 'array', + 'items.*.item_code' => 'required_with:items|string|max:50', + 'items.*.item_name' => 'required_with:items|string|max:200', + 'items.*.specification' => 'nullable|string|max:100', + 'items.*.unit' => 'required_with:items|string|max:20', + 'items.*.quantity_formula' => 'required_with:items|string|max:500', + 'items.*.unit_price_formula' => 'nullable|string|max:500', + ]; + } + + public function messages(): array + { + return [ + 'category_id.required' => '카테고리를 선택해주세요.', + 'name.required' => '수식 이름은 필수입니다.', + 'variable.required' => '변수명은 필수입니다.', + 'variable.regex' => '변수명은 대문자로 시작하고 대문자, 숫자, 언더스코어만 사용할 수 있습니다.', + 'type.required' => '수식 유형을 선택해주세요.', + ]; + } +} +``` + +--- + +## 7. 라우트 설정 + +### 7.1 routes/web.php (Blade 화면) + +```php +// 견적 수식 관리 +Route::prefix('quote-formulas')->name('quote-formulas.')->group(function () { + // 카테고리 관리 화면 + Route::get('/categories', [QuoteFormulaCategoryController::class, 'index'])->name('categories.index'); + Route::get('/categories/create', [QuoteFormulaCategoryController::class, 'create'])->name('categories.create'); + Route::get('/categories/{id}/edit', [QuoteFormulaCategoryController::class, 'edit'])->name('categories.edit'); + + // 수식 관리 화면 + Route::get('/', [QuoteFormulaController::class, 'index'])->name('index'); + Route::get('/create', [QuoteFormulaController::class, 'create'])->name('create'); + Route::get('/{id}/edit', [QuoteFormulaController::class, 'edit'])->name('edit'); + + // 수식 시뮬레이터 + Route::get('/simulator', [QuoteFormulaController::class, 'simulator'])->name('simulator'); +}); +``` + +### 7.2 routes/api.php (API 엔드포인트) + +```php +Route::prefix('admin')->middleware(['web', 'auth', 'hq.member'])->group(function () { + + // 견적 수식 카테고리 API + Route::prefix('quote-formula-categories')->group(function () { + Route::get('/', [Api\Admin\QuoteFormulaCategoryController::class, 'index']); + Route::get('/all', [Api\Admin\QuoteFormulaCategoryController::class, 'all']); + Route::post('/', [Api\Admin\QuoteFormulaCategoryController::class, 'store']); + Route::get('/{id}', [Api\Admin\QuoteFormulaCategoryController::class, 'show']); + Route::put('/{id}', [Api\Admin\QuoteFormulaCategoryController::class, 'update']); + Route::delete('/{id}', [Api\Admin\QuoteFormulaCategoryController::class, 'destroy']); + Route::post('/reorder', [Api\Admin\QuoteFormulaCategoryController::class, 'reorder']); + }); + + // 견적 수식 API + Route::prefix('quote-formulas')->group(function () { + Route::get('/', [Api\Admin\QuoteFormulaController::class, 'index']); + Route::post('/', [Api\Admin\QuoteFormulaController::class, 'store']); + Route::get('/variables', [Api\Admin\QuoteFormulaController::class, 'variables']); + Route::post('/validate', [Api\Admin\QuoteFormulaController::class, 'validate']); + Route::post('/test', [Api\Admin\QuoteFormulaController::class, 'test']); + Route::post('/simulate', [Api\Admin\QuoteFormulaController::class, 'simulate']); + Route::get('/{id}', [Api\Admin\QuoteFormulaController::class, 'show']); + Route::put('/{id}', [Api\Admin\QuoteFormulaController::class, 'update']); + Route::delete('/{id}', [Api\Admin\QuoteFormulaController::class, 'destroy']); + }); + +}); +``` + +--- + +## 8. Blade 템플릿 구조 + +### 8.1 디렉토리 구조 + +``` +resources/views/quote-formulas/ +├── categories/ +│ ├── index.blade.php # 카테고리 목록 +│ ├── create.blade.php # 카테고리 생성 +│ ├── edit.blade.php # 카테고리 수정 +│ └── partials/ +│ ├── table.blade.php # 카테고리 테이블 +│ └── form.blade.php # 카테고리 폼 +│ +├── index.blade.php # 수식 목록 +├── create.blade.php # 수식 생성 +├── edit.blade.php # 수식 수정 +├── simulator.blade.php # 수식 시뮬레이터 +│ +└── partials/ + ├── formula-table.blade.php # 수식 테이블 + ├── formula-form.blade.php # 수식 폼 (공통) + ├── type-calculation.blade.php # 계산식 유형 폼 + ├── type-range.blade.php # 범위별 유형 폼 + ├── type-mapping.blade.php # 매핑 유형 폼 + ├── type-input.blade.php # 입력값 유형 폼 + ├── items-form.blade.php # 품목 출력 폼 + ├── variable-search.blade.php # 변수 검색 모달 + └── function-help.blade.php # 함수 도움말 +``` + +### 8.2 주요 화면 설명 + +| 화면 | 설명 | +|------|------| +| 카테고리 목록 | 카테고리 CRUD, 드래그&드롭 순서 변경 | +| 수식 목록 | 카테고리별 필터, 유형별 필터, 수식 CRUD | +| 수식 생성/수정 | 유형별 폼 동적 전환, 변수 검색, 수식 검증 | +| 수식 시뮬레이터 | 입력값 입력 → 전체 수식 실행 → 결과 확인 | + +--- + +## 9. 개발 순서 + +### Phase 1: 기반 작업 (1-2일) + +| 순서 | 작업 | 상태 | 비고 | +|------|------|------|------| +| 1.1 | 마이그레이션 생성 | ⬜ | 5개 테이블 | +| 1.2 | 모델 생성 | ⬜ | 5개 모델 + 관계 | +| 1.3 | Seeder 생성 | ⬜ | 기본 카테고리 및 수식 | +| 1.4 | 마이그레이션 실행 | ⬜ | `php artisan migrate` | + +### Phase 2: Service 계층 (2-3일) + +| 순서 | 작업 | 상태 | 비고 | +|------|------|------|------| +| 2.1 | QuoteFormulaCategoryService | ⬜ | CRUD + 순서 변경 | +| 2.2 | QuoteFormulaService | ⬜ | CRUD + 범위/매핑/품목 | +| 2.3 | FormulaEvaluatorService | ⬜ | 수식 평가 엔진 | +| 2.4 | 유닛 테스트 | ⬜ | Service 테스트 | + +### Phase 3: API 및 FormRequest (1-2일) + +| 순서 | 작업 | 상태 | 비고 | +|------|------|------|------| +| 3.1 | FormRequest 생성 | ⬜ | 4개 Request 클래스 | +| 3.2 | 카테고리 API 컨트롤러 | ⬜ | CRUD + reorder | +| 3.3 | 수식 API 컨트롤러 | ⬜ | CRUD + validate/test/simulate | +| 3.4 | 라우트 등록 | ⬜ | web.php + api.php | + +### Phase 4: Blade 화면 (3-4일) + +| 순서 | 작업 | 상태 | 비고 | +|------|------|------|------| +| 4.1 | 카테고리 목록/생성/수정 | ⬜ | HTMX 적용 | +| 4.2 | 수식 목록 화면 | ⬜ | 필터, 테이블 | +| 4.3 | 수식 생성/수정 화면 | ⬜ | 유형별 동적 폼 | +| 4.4 | 변수 검색 모달 | ⬜ | 수식 작성 보조 | +| 4.5 | 함수 도움말 | ⬜ | 지원 함수 안내 | +| 4.6 | 수식 시뮬레이터 | ⬜ | 전체 실행 테스트 | + +### Phase 5: 테스트 및 마무리 (1-2일) + +| 순서 | 작업 | 상태 | 비고 | +|------|------|------|------| +| 5.1 | 통합 테스트 | ⬜ | API + 화면 테스트 | +| 5.2 | 기본 데이터 입력 | ⬜ | 실제 견적 수식 입력 | +| 5.3 | 시뮬레이션 검증 | ⬜ | 실제 견적 산출 결과 비교 | +| 5.4 | 문서 업데이트 | ⬜ | API 문서, 사용 가이드 | + +--- + +## 10. 진행상황 추적 + +### 현재 상태 + +- **전체 진행률**: 0% +- **현재 단계**: Phase 1 - 기반 작업 +- **마지막 작업**: 개발 플랜 문서 작성 + +### 작업 로그 + +| 날짜 | 작업 내용 | 결과 | +|------|----------|------| +| 2025-12-04 | 개발 플랜 문서 작성 | ✅ 완료 | + +### 다음 작업 + +1. **마이그레이션 생성**: `php artisan make:migration create_quote_formula_tables` +2. **모델 생성**: QuoteFormulaCategory, QuoteFormula, QuoteFormulaRange, QuoteFormulaMapping, QuoteFormulaItem + +--- + +## 11. 참고 자료 + +### 11.1 관련 문서 + +- `docs/data/견적/견적시스템_분석문서.md` - 견적 시스템 전체 분석 +- `design/src/components/FormulaManagement2.tsx` - 프론트엔드 수식 관리 참조 +- `SAM_QUICK_REFERENCE.md` - SAM 개발 규칙 + +### 11.2 기존 패턴 참조 + +- `mng/app/Services/RoleService.php` - Service 패턴 +- `mng/app/Http/Controllers/Api/Admin/RoleController.php` - API 컨트롤러 패턴 +- `mng/resources/views/roles/` - Blade 템플릿 패턴 + +### 11.3 수식 예시 + +``` +# 기본정보 (입력값) +PC = 제품 카테고리 (선택) +W0 = 오픈사이즈 가로 (mm) +H0 = 오픈사이즈 세로 (mm) +GT = 가이드레일 유형 (선택) +MP = 모터 전원 (선택) +CT = 연동제어기 (선택) +QTY = 수량 +WS = 마구리 날개치수 (기본값: 50) +INSP = 검사비 (기본값: 50000) + +# 제작사이즈 (계산식) +W1 = W0 + 140 +H1 = H0 + 350 + +# 면적 (계산식) +M = ROUND(W1 * H1 / 1000000, 4) # m² +K = ROUND(M * 2.5, 2) # kg + +# 모터용량 (범위별) +용량 = IF(M <= 5, '0.4kW', IF(M <= 10, '0.75kW', IF(M <= 15, '1.5kW', '2.2kW'))) + +# 브라켓 (매핑) +브라켓 = MAP(GT, '벽부' → 'BR-W01', '노출' → 'BR-E01', '앙카' → 'BR-A01') +``` + +--- + +*문서 버전: 1.0* +*최종 수정: 2025-12-04* diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 86811b5a..c8941ee4 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -153,6 +153,16 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:
새로운 수식 카테고리를 추가합니다.
+카테고리 정보를 수정합니다.
+데이터를 불러오는 중...
+수식을 그룹화하는 카테고리를 관리합니다.
+| 순서 | +코드 | +카테고리명 | +설명 | +수식 수 | +상태 | +액션 | +
|---|---|---|---|---|---|---|
| + + {{ $category->sort_order }} + + | +
+ {{ $category->code }}
+ |
+
+ {{ $category->name }}
+ |
+
+ @if($category->description)
+ {{ $category->description }}
+ @else
+ -
+ @endif
+ |
+ + + {{ $category->formulas_count ?? 0 }}개 + + | ++ @if($category->trashed()) + + 삭제됨 + + @elseif($category->is_active) + + @else + + @endif + | +
+
+ @if($category->trashed())
+
+ @if(auth()->user()?->is_super_admin)
+
+ @endif
+ @else
+
+
+
+
+ @endif
+
+ |
+
|
+
+ 등록된 카테고리가 없습니다. +새 카테고리를 추가해주세요. + |
+ ||||||
새로운 견적 수식을 추가합니다.
+수식 정보를 수정합니다.
+데이터를 불러오는 중...
+견적 산출에 사용되는 수식을 관리합니다.
+| 카테고리 | +수식명 | +변수명 | +유형 | +수식/규칙 | +상태 | +순서 | +액션 | +
|---|---|---|---|---|---|---|---|
| + + {{ $formula->category->name ?? '-' }} + + | +
+ {{ $formula->name }}
+ @if($formula->description)
+ {{ $formula->description }}
+ @endif
+ |
+
+ {{ $formula->variable }}
+ |
+ + @switch($formula->type) + @case('input') + + + 입력값 + + @break + @case('calculation') + + + 계산식 + + @break + @case('range') + + + 범위별 + + @break + @case('mapping') + + + 매핑 + + @break + @endswitch + | +
+ @if($formula->type === 'calculation' && $formula->formula)
+
+ {{ Str::limit($formula->formula, 40) }}
+
+ @elseif($formula->type === 'range')
+ {{ $formula->ranges_count ?? 0 }}개 범위 규칙
+ @elseif($formula->type === 'mapping')
+ {{ $formula->mappings_count ?? 0 }}개 매핑 규칙
+ @else
+ -
+ @endif
+ |
+ + @if($formula->trashed()) + + 삭제됨 + + @elseif($formula->is_active) + + @else + + @endif + | ++ {{ $formula->sort_order }} + | +
+
+ @if($formula->trashed())
+
+ @if(auth()->user()?->is_super_admin)
+
+ @endif
+ @else
+
+
+
+
+
+ @endif
+
+ |
+
|
+
+ 등록된 수식이 없습니다. +새 수식을 추가해주세요. + |
+ |||||||
입력값을 넣어 전체 수식 실행 결과를 테스트합니다.
+입력값을 넣고 수식을 실행하세요
+수식을 실행하는 중...
+