feat(quote-formulas): 견적수식 관리 기능 구현

## 구현 내용

### 모델 (5개)
- QuoteFormulaCategory: 수식 카테고리
- QuoteFormula: 수식 정의 (input/calculation/range/mapping)
- QuoteFormulaRange: 범위별 값 정의
- QuoteFormulaMapping: 매핑 테이블
- QuoteFormulaItem: 수식-품목 연결

### 서비스 (3개)
- QuoteFormulaCategoryService: 카테고리 CRUD
- QuoteFormulaService: 수식 CRUD, 복제, 재정렬
- FormulaEvaluatorService: 수식 계산 엔진
  - 지원 함수: SUM, ROUND, CEIL, FLOOR, ABS, MIN, MAX, IF, AND, OR, NOT

### API Controller (2개)
- QuoteFormulaCategoryController: 카테고리 API (11개 엔드포인트)
- QuoteFormulaController: 수식 API (16개 엔드포인트)

### FormRequest (4개)
- Store/Update QuoteFormulaCategoryRequest
- Store/Update QuoteFormulaRequest

### Blade Views (8개)
- 수식 목록/추가/수정/시뮬레이터
- 카테고리 목록/추가/수정
- HTMX 테이블 partial

### 라우트
- API: 27개 엔드포인트
- Web: 7개 라우트
This commit is contained in:
2025-12-04 14:00:24 +09:00
parent 477779f3ac
commit dac02f120b
28 changed files with 6489 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
<?php
namespace App\Http\Controllers\Api\Admin\Quote;
use App\Http\Controllers\Controller;
use App\Http\Requests\Quote\StoreQuoteFormulaCategoryRequest;
use App\Http\Requests\Quote\UpdateQuoteFormulaCategoryRequest;
use App\Services\Quote\QuoteFormulaCategoryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class QuoteFormulaCategoryController extends Controller
{
public function __construct(
private readonly QuoteFormulaCategoryService $categoryService
) {}
/**
* 카테고리 목록 (HTMX용)
*/
public function index(Request $request): View|JsonResponse
{
$filters = $request->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,
]);
}
}

View File

@@ -0,0 +1,311 @@
<?php
namespace App\Http\Controllers\Api\Admin\Quote;
use App\Http\Controllers\Controller;
use App\Http\Requests\Quote\StoreQuoteFormulaRequest;
use App\Http\Requests\Quote\UpdateQuoteFormulaRequest;
use App\Services\Quote\FormulaEvaluatorService;
use App\Services\Quote\QuoteFormulaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class QuoteFormulaController extends Controller
{
public function __construct(
private readonly QuoteFormulaService $formulaService,
private readonly FormulaEvaluatorService $evaluatorService
) {}
/**
* 수식 목록 (HTMX용)
*/
public function index(Request $request): View|JsonResponse
{
$filters = $request->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,
]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers;
use Illuminate\View\View;
/**
* 견적수식 관리 컨트롤러 (Blade 화면용)
*
* 실제 CRUD 작업은 Api\Admin\QuoteFormulaController에서 처리
*/
class QuoteFormulaController extends Controller
{
/**
* 수식 목록 화면
*/
public function index(): View
{
return view('quote-formulas.index');
}
/**
* 수식 생성 화면
*/
public function create(): View
{
return view('quote-formulas.create');
}
/**
* 수식 수정 화면
*/
public function edit(int $id): View
{
return view('quote-formulas.edit', compact('id'));
}
/**
* 카테고리 목록 화면
*/
public function categories(): View
{
return view('quote-formulas.categories.index');
}
/**
* 카테고리 생성 화면
*/
public function createCategory(): View
{
return view('quote-formulas.categories.create');
}
/**
* 카테고리 수정 화면
*/
public function editCategory(int $id): View
{
return view('quote-formulas.categories.edit', compact('id'));
}
/**
* 수식 시뮬레이터 화면
*/
public function simulator(): View
{
return view('quote-formulas.simulator');
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Quote;
use Illuminate\Foundation\Http\FormRequest;
class StoreQuoteFormulaCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->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 이상이어야 합니다.',
];
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Requests\Quote;
use Illuminate\Foundation\Http\FormRequest;
class StoreQuoteFormulaRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->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' => '유효하지 않은 출력 유형입니다.',
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Quote;
use Illuminate\Foundation\Http\FormRequest;
class UpdateQuoteFormulaCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->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 이상이어야 합니다.',
];
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Requests\Quote;
use Illuminate\Foundation\Http\FormRequest;
class UpdateQuoteFormulaRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->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' => '유효하지 않은 출력 유형입니다.',
];
}
}

View File

@@ -0,0 +1,218 @@
<?php
namespace App\Models\Quote;
use App\Models\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 견적 수식 모델
*
* @property int $id
* @property int $tenant_id
* @property int $category_id
* @property int|null $product_id
* @property string $name
* @property string $variable
* @property string $type
* @property string|null $formula
* @property string $output_type
* @property string|null $description
* @property int $sort_order
* @property bool $is_active
*/
class QuoteFormula extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'quote_formulas';
protected $fillable = [
'tenant_id',
'category_id',
'product_id',
'name',
'variable',
'type',
'formula',
'output_type',
'description',
'sort_order',
'is_active',
'created_by',
'updated_by',
];
protected $casts = [
'is_active' => '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,
};
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Models\Quote;
use App\Models\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 견적 수식 카테고리 모델
*
* @property int $id
* @property int $tenant_id
* @property string $code
* @property string $name
* @property string|null $description
* @property int $sort_order
* @property bool $is_active
* @property int|null $created_by
* @property int|null $updated_by
*/
class QuoteFormulaCategory extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'quote_formula_categories';
protected $fillable = [
'tenant_id',
'code',
'name',
'description',
'sort_order',
'is_active',
'created_by',
'updated_by',
];
protected $casts = [
'is_active' => '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');
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Models\Quote;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 수식 품목 출력 모델
*
* @property int $id
* @property int $formula_id
* @property string $item_code
* @property string $item_name
* @property string|null $specification
* @property string $unit
* @property string $quantity_formula
* @property string|null $unit_price_formula
* @property int $sort_order
*/
class QuoteFormulaItem extends Model
{
protected $table = 'quote_formula_items';
protected $fillable = [
'formula_id',
'item_code',
'item_name',
'specification',
'unit',
'quantity_formula',
'unit_price_formula',
'sort_order',
];
protected $casts = [
'sort_order' => '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;
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Models\Quote;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 수식 매핑 값 모델
*
* @property int $id
* @property int $formula_id
* @property string $source_variable
* @property string $source_value
* @property string $result_value
* @property string $result_type
* @property int $sort_order
*/
class QuoteFormulaMapping extends Model
{
protected $table = 'quote_formula_mappings';
protected $fillable = [
'formula_id',
'source_variable',
'source_value',
'result_value',
'result_type',
'sort_order',
];
protected $casts = [
'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 getConditionLabelAttribute(): string
{
return "{$this->source_variable} = '{$this->source_value}'";
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Models\Quote;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 수식 범위별 값 모델
*
* @property int $id
* @property int $formula_id
* @property float|null $min_value
* @property float|null $max_value
* @property string $condition_variable
* @property string $result_value
* @property string $result_type
* @property int $sort_order
*/
class QuoteFormulaRange extends Model
{
protected $table = 'quote_formula_ranges';
protected $fillable = [
'formula_id',
'min_value',
'max_value',
'condition_variable',
'result_value',
'result_type',
'sort_order',
];
protected $casts = [
'min_value' => '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}";
}
}

View File

@@ -0,0 +1,354 @@
<?php
namespace App\Services\Quote;
use App\Models\Quote\QuoteFormula;
use Illuminate\Support\Collection;
class FormulaEvaluatorService
{
private array $variables = [];
private array $errors = [];
/**
* 수식 검증
*/
public function validateFormula(string $formula): array
{
$errors = [];
// 기본 문법 검증
if (empty(trim($formula))) {
return ['success' => 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 = [];
}
}

View File

@@ -0,0 +1,200 @@
<?php
namespace App\Services\Quote;
use App\Models\Quote\QuoteFormulaCategory;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class QuoteFormulaCategoryService
{
/**
* 카테고리 목록 조회 (페이지네이션)
*/
public function getCategories(array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$tenantId = session('selected_tenant_id');
$query = QuoteFormulaCategory::query()
->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(),
];
}
}

View File

@@ -0,0 +1,504 @@
<?php
namespace App\Services\Quote;
use App\Models\Quote\QuoteFormula;
use App\Models\Quote\QuoteFormulaItem;
use App\Models\Quote\QuoteFormulaMapping;
use App\Models\Quote\QuoteFormulaRange;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class QuoteFormulaService
{
/**
* 수식 목록 조회 (페이지네이션)
*/
public function getFormulas(array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$tenantId = session('selected_tenant_id');
$query = QuoteFormula::query()
->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,
]);
}
}
}