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