Files
sam-manage/docs/QUOTE_FORMULA_DEVELOPMENT_PLAN.md
hskwon dac02f120b 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개 라우트
2025-12-04 14:00:24 +09:00

1986 lines
60 KiB
Markdown

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