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:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
311
app/Http/Controllers/Api/Admin/Quote/QuoteFormulaController.php
Normal file
311
app/Http/Controllers/Api/Admin/Quote/QuoteFormulaController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/QuoteFormulaController.php
Normal file
69
app/Http/Controllers/QuoteFormulaController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
38
app/Http/Requests/Quote/StoreQuoteFormulaCategoryRequest.php
Normal file
38
app/Http/Requests/Quote/StoreQuoteFormulaCategoryRequest.php
Normal 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 이상이어야 합니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
70
app/Http/Requests/Quote/StoreQuoteFormulaRequest.php
Normal file
70
app/Http/Requests/Quote/StoreQuoteFormulaRequest.php
Normal 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' => '유효하지 않은 출력 유형입니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 이상이어야 합니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
70
app/Http/Requests/Quote/UpdateQuoteFormulaRequest.php
Normal file
70
app/Http/Requests/Quote/UpdateQuoteFormulaRequest.php
Normal 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' => '유효하지 않은 출력 유형입니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
218
app/Models/Quote/QuoteFormula.php
Normal file
218
app/Models/Quote/QuoteFormula.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
109
app/Models/Quote/QuoteFormulaCategory.php
Normal file
109
app/Models/Quote/QuoteFormulaCategory.php
Normal 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');
|
||||
}
|
||||
}
|
||||
70
app/Models/Quote/QuoteFormulaItem.php
Normal file
70
app/Models/Quote/QuoteFormulaItem.php
Normal 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;
|
||||
}
|
||||
}
|
||||
65
app/Models/Quote/QuoteFormulaMapping.php
Normal file
65
app/Models/Quote/QuoteFormulaMapping.php
Normal 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}'";
|
||||
}
|
||||
}
|
||||
107
app/Models/Quote/QuoteFormulaRange.php
Normal file
107
app/Models/Quote/QuoteFormulaRange.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
354
app/Services/Quote/FormulaEvaluatorService.php
Normal file
354
app/Services/Quote/FormulaEvaluatorService.php
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
200
app/Services/Quote/QuoteFormulaCategoryService.php
Normal file
200
app/Services/Quote/QuoteFormulaCategoryService.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
504
app/Services/Quote/QuoteFormulaService.php
Normal file
504
app/Services/Quote/QuoteFormulaService.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user