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

60 KiB

견적 수식 관리 기능 개발 플랜

목적: 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 범위별 값 면적 05: 용량A, 510: 용량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 (수식 카테고리)

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 (수식)

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 (범위별 값)

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 (매핑 값)

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 (품목 출력)

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)

// 카테고리 기본 데이터
$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

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

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

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

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

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

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

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

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

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

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

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

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 화면)

// 견적 수식 관리
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 엔드포인트)

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