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