feat: 견적 시스템 API
- 5130의 71개 하드코딩 컬럼을 동적 카테고리 필드 시스템으로 전환 - 모터 브라켓 계산 등 핵심 비즈니스 로직 FormulaParser에 통합 - 파라미터 기반 동적 견적 폼 시스템 구축 - 견적 상태 워크플로 (DRAFT → SENT → APPROVED/REJECTED/EXPIRED) - 모델셋 관리 API: 카테고리+제품+BOM 통합 관리 - 견적 관리 API: 생성/수정/복제/상태변경/미리보기 기능 주요 구현 사항: - EstimateController/EstimateService: 견적 비즈니스 로직 - ModelSetController/ModelSetService: 모델셋 관리 로직 - Estimate/EstimateItem 모델: 견적 데이터 구조 - 동적 견적 필드 마이그레이션: 스크린/철재 제품 구조 - API 라우트 17개 엔드포인트 추가 - 다국어 메시지 지원 (성공/에러 메시지) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// 5130 시스템의 71개 컬럼을 동적 카테고리 필드로 전환
|
||||
|
||||
// 1. 견적 루트 카테고리 생성
|
||||
$rootCategoryId = DB::table('categories')->insertGetId([
|
||||
'tenant_id' => 1,
|
||||
'parent_id' => null,
|
||||
'code_group' => 'estimate',
|
||||
'code' => 'fire_shutter_estimate',
|
||||
'name' => '방화셔터 견적',
|
||||
'description' => '방화셔터 견적 루트 카테고리',
|
||||
'level' => 1,
|
||||
'sort_order' => 1,
|
||||
'profile_code' => 'estimate_root',
|
||||
'is_active' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// 2. 스크린 카테고리
|
||||
$screenCategoryId = DB::table('categories')->insertGetId([
|
||||
'tenant_id' => 1,
|
||||
'parent_id' => $rootCategoryId,
|
||||
'code_group' => 'estimate',
|
||||
'code' => 'screen_product',
|
||||
'name' => '스크린 제품',
|
||||
'description' => '실리카/와이어 스크린 제품 카테고리',
|
||||
'level' => 2,
|
||||
'sort_order' => 1,
|
||||
'profile_code' => 'screen_category',
|
||||
'is_active' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// 3. 철재 카테고리
|
||||
$steelCategoryId = DB::table('categories')->insertGetId([
|
||||
'tenant_id' => 1,
|
||||
'parent_id' => $rootCategoryId,
|
||||
'code_group' => 'estimate',
|
||||
'code' => 'steel_product',
|
||||
'name' => '철재 제품',
|
||||
'description' => '철재스라트 제품 카테고리',
|
||||
'level' => 2,
|
||||
'sort_order' => 2,
|
||||
'profile_code' => 'steel_category',
|
||||
'is_active' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// 4. 스크린 카테고리의 동적 필드들 (5130의 핵심 컬럼들)
|
||||
$screenFields = [
|
||||
// 기본 정보
|
||||
['key' => 'model_name', 'name' => '모델명', 'type' => 'select', 'required' => true, 'order' => 1,
|
||||
'options' => ['KSS01', 'KSS02', 'KSE01', 'KWE01', 'KDSS01', '스크린비인정'],
|
||||
'desc' => '스크린 제품 모델 선택 (col4)'],
|
||||
|
||||
['key' => 'sequence', 'name' => '순번', 'type' => 'text', 'required' => false, 'order' => 2,
|
||||
'desc' => '견적 항목 순번 (col1)'],
|
||||
|
||||
['key' => 'product_category', 'name' => '대분류', 'type' => 'text', 'required' => true, 'order' => 3,
|
||||
'default' => '스크린', 'desc' => '제품 대분류 (col2)'],
|
||||
|
||||
['key' => 'sub_category', 'name' => '중분류', 'type' => 'text', 'required' => false, 'order' => 4,
|
||||
'desc' => '제품 중분류 (col3)'],
|
||||
|
||||
// 사이즈 관련
|
||||
['key' => 'open_width', 'name' => '오픈사이즈 가로(mm)', 'type' => 'number', 'required' => true, 'order' => 10,
|
||||
'desc' => '개구부 가로 사이즈 W0'],
|
||||
|
||||
['key' => 'open_height', 'name' => '오픈사이즈 세로(mm)', 'type' => 'number', 'required' => true, 'order' => 11,
|
||||
'desc' => '개구부 세로 사이즈 H0'],
|
||||
|
||||
['key' => 'make_width', 'name' => '제작사이즈 가로(mm)', 'type' => 'number', 'required' => false, 'order' => 12,
|
||||
'desc' => '제작 가로 사이즈 W1 (자동계산, col10)'],
|
||||
|
||||
['key' => 'make_height', 'name' => '제작사이즈 세로(mm)', 'type' => 'number', 'required' => false, 'order' => 13,
|
||||
'desc' => '제작 세로 사이즈 H1 (자동계산, col11)'],
|
||||
|
||||
['key' => 'quantity', 'name' => '수량', 'type' => 'number', 'required' => true, 'order' => 14,
|
||||
'default' => '1', 'desc' => '제품 수량 (col14)'],
|
||||
|
||||
// 부품 관련
|
||||
['key' => 'guide_rail_type', 'name' => '가이드레일 유형', 'type' => 'select', 'required' => true, 'order' => 20,
|
||||
'options' => ['벽면형', '측면형', '혼합형'],
|
||||
'desc' => '가이드레일 설치 방식 (col6)'],
|
||||
|
||||
['key' => 'shutter_box', 'name' => '셔터박스', 'type' => 'select', 'required' => false, 'order' => 30,
|
||||
'options' => ['', '500*380', '500*350', 'custom'],
|
||||
'desc' => '셔터박스 사이즈 선택 (col36)'],
|
||||
|
||||
['key' => 'shutter_box_custom', 'name' => '셔터박스 직접입력', 'type' => 'text', 'required' => false, 'order' => 31,
|
||||
'desc' => '셔터박스 직접입력 시 사이즈'],
|
||||
|
||||
['key' => 'front_bottom', 'name' => '전면밑', 'type' => 'number', 'required' => false, 'order' => 32,
|
||||
'default' => '50', 'desc' => '전면밑 치수 (mm)'],
|
||||
|
||||
['key' => 'rail_width', 'name' => '레일폭', 'type' => 'number', 'required' => false, 'order' => 33,
|
||||
'default' => '70', 'desc' => '레일 폭 치수 (mm)'],
|
||||
|
||||
['key' => 'box_direction', 'name' => '박스방향', 'type' => 'select', 'required' => false, 'order' => 34,
|
||||
'options' => ['양면', '밑면', '후면'],
|
||||
'default' => '양면', 'desc' => '셔터박스 설치 방향'],
|
||||
|
||||
// 모터 관련
|
||||
['key' => 'motor_bracket_size', 'name' => '모터브라켓 사이즈', 'type' => 'text', 'required' => false, 'order' => 40,
|
||||
'desc' => '중량+인치 기반 자동계산 브라켓 사이즈'],
|
||||
|
||||
['key' => 'motor_capacity', 'name' => '모터 용량', 'type' => 'text', 'required' => false, 'order' => 41,
|
||||
'desc' => '계산된 모터 용량'],
|
||||
|
||||
['key' => 'shaft_inch', 'name' => '샤프트 인치', 'type' => 'select', 'required' => false, 'order' => 42,
|
||||
'options' => ['4', '5', '6', '8'],
|
||||
'desc' => '샤프트 사이즈 (인치)'],
|
||||
|
||||
// 마구리 관련
|
||||
['key' => 'maguri_length', 'name' => '마구리 길이', 'type' => 'number', 'required' => false, 'order' => 50,
|
||||
'desc' => '마구리 길이 치수 (col45)'],
|
||||
|
||||
['key' => 'maguri_wing', 'name' => '마구리 윙', 'type' => 'number', 'required' => false, 'order' => 51,
|
||||
'desc' => '마구리 윙 길이 치수'],
|
||||
|
||||
// 계산 결과
|
||||
['key' => 'calculated_weight', 'name' => '계산 중량', 'type' => 'number', 'required' => false, 'order' => 60,
|
||||
'desc' => '자동 계산된 중량 (kg)'],
|
||||
|
||||
['key' => 'calculated_area', 'name' => '계산 면적', 'type' => 'number', 'required' => false, 'order' => 61,
|
||||
'desc' => '자동 계산된 면적 (㎡)'],
|
||||
|
||||
['key' => 'unit_price', 'name' => '단가', 'type' => 'number', 'required' => false, 'order' => 70,
|
||||
'desc' => '제품 단가 (원)'],
|
||||
|
||||
['key' => 'total_price', 'name' => '금액', 'type' => 'number', 'required' => false, 'order' => 71,
|
||||
'desc' => '총 금액 (원)'],
|
||||
];
|
||||
|
||||
// 5. 철재 카테고리의 동적 필드들
|
||||
$steelFields = [
|
||||
// 기본 정보
|
||||
['key' => 'model_name', 'name' => '모델명', 'type' => 'select', 'required' => true, 'order' => 1,
|
||||
'options' => ['KQTS01', 'KTE01', '철재비인정'],
|
||||
'desc' => '철재 제품 모델 선택'],
|
||||
|
||||
['key' => 'sequence', 'name' => '순번', 'type' => 'text', 'required' => false, 'order' => 2,
|
||||
'desc' => '견적 항목 순번'],
|
||||
|
||||
['key' => 'product_category', 'name' => '대분류', 'type' => 'text', 'required' => true, 'order' => 3,
|
||||
'default' => '철재', 'desc' => '제품 대분류'],
|
||||
|
||||
// 사이즈 관련 (철재는 다른 계산식)
|
||||
['key' => 'open_width', 'name' => '오픈사이즈 가로(mm)', 'type' => 'number', 'required' => true, 'order' => 10,
|
||||
'desc' => '개구부 가로 사이즈 W0'],
|
||||
|
||||
['key' => 'open_height', 'name' => '오픈사이즈 세로(mm)', 'type' => 'number', 'required' => true, 'order' => 11,
|
||||
'desc' => '개구부 세로 사이즈 H0'],
|
||||
|
||||
['key' => 'make_width', 'name' => '제작사이즈 가로(mm)', 'type' => 'number', 'required' => false, 'order' => 12,
|
||||
'desc' => '제작 가로 사이즈 W1 (W0+110)'],
|
||||
|
||||
['key' => 'make_height', 'name' => '제작사이즈 세로(mm)', 'type' => 'number', 'required' => false, 'order' => 13,
|
||||
'desc' => '제작 세로 사이즈 H1 (H0+350)'],
|
||||
|
||||
['key' => 'quantity', 'name' => '수량', 'type' => 'number', 'required' => true, 'order' => 14,
|
||||
'default' => '1', 'desc' => '제품 수량'],
|
||||
|
||||
// 철재 특화 필드
|
||||
['key' => 'slat_thickness', 'name' => '스라트 두께', 'type' => 'select', 'required' => true, 'order' => 20,
|
||||
'options' => ['0.8mm', '1.0mm', '1.2mm', '1.5mm', '2.0mm'],
|
||||
'desc' => '철재 스라트 두께'],
|
||||
|
||||
['key' => 'bending_work', 'name' => '절곡 가공', 'type' => 'checkbox', 'required' => false, 'order' => 21,
|
||||
'desc' => '절곡 가공 여부'],
|
||||
|
||||
['key' => 'welding_work', 'name' => '용접 가공', 'type' => 'checkbox', 'required' => false, 'order' => 22,
|
||||
'desc' => '용접 가공 여부'],
|
||||
|
||||
// 환봉, 각파이프 등
|
||||
['key' => 'round_bar_quantity', 'name' => '환봉 수량', 'type' => 'number', 'required' => false, 'order' => 30,
|
||||
'desc' => '환봉 필요 수량 (자동계산)'],
|
||||
|
||||
['key' => 'square_pipe', 'name' => '각파이프', 'type' => 'text', 'required' => false, 'order' => 31,
|
||||
'desc' => '각파이프 사양'],
|
||||
|
||||
// 모터 관련 (철재용)
|
||||
['key' => 'motor_bracket_size', 'name' => '모터브라켓 사이즈', 'type' => 'text', 'required' => false, 'order' => 40,
|
||||
'desc' => '중량+인치 기반 브라켓 사이즈'],
|
||||
|
||||
['key' => 'shaft_inch', 'name' => '샤프트 인치', 'type' => 'select', 'required' => false, 'order' => 41,
|
||||
'options' => ['4', '5', '6', '8'],
|
||||
'desc' => '샤프트 사이즈 (인치)'],
|
||||
|
||||
// 계산 결과
|
||||
['key' => 'calculated_weight', 'name' => '계산 중량', 'type' => 'number', 'required' => false, 'order' => 60,
|
||||
'desc' => '자동 계산된 중량 (kg)'],
|
||||
|
||||
['key' => 'unit_price', 'name' => '단가', 'type' => 'number', 'required' => false, 'order' => 70,
|
||||
'desc' => '제품 단가 (원)'],
|
||||
|
||||
['key' => 'total_price', 'name' => '금액', 'type' => 'number', 'required' => false, 'order' => 71,
|
||||
'desc' => '총 금액 (원)'],
|
||||
];
|
||||
|
||||
// 6. 스크린 카테고리 필드 생성
|
||||
foreach ($screenFields as $field) {
|
||||
DB::table('category_fields')->insert([
|
||||
'tenant_id' => 1,
|
||||
'category_id' => $screenCategoryId,
|
||||
'field_key' => $field['key'],
|
||||
'field_name' => $field['name'],
|
||||
'field_type' => $field['type'],
|
||||
'is_required' => $field['required'],
|
||||
'sort_order' => $field['order'],
|
||||
'default_value' => $field['default'] ?? null,
|
||||
'options' => isset($field['options']) ? json_encode($field['options']) : null,
|
||||
'description' => $field['desc'],
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 7. 철재 카테고리 필드 생성
|
||||
foreach ($steelFields as $field) {
|
||||
DB::table('category_fields')->insert([
|
||||
'tenant_id' => 1,
|
||||
'category_id' => $steelCategoryId,
|
||||
'field_key' => $field['key'],
|
||||
'field_name' => $field['name'],
|
||||
'field_type' => $field['type'],
|
||||
'is_required' => $field['required'],
|
||||
'sort_order' => $field['order'],
|
||||
'default_value' => $field['default'] ?? null,
|
||||
'options' => isset($field['options']) ? json_encode($field['options']) : null,
|
||||
'description' => $field['desc'],
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 생성된 견적 관련 카테고리와 필드들 삭제
|
||||
$categoryIds = DB::table('categories')
|
||||
->where('tenant_id', 1)
|
||||
->where('code_group', 'estimate')
|
||||
->pluck('id');
|
||||
|
||||
DB::table('category_fields')->whereIn('category_id', $categoryIds)->delete();
|
||||
DB::table('categories')->whereIn('id', $categoryIds)->delete();
|
||||
}
|
||||
};
|
||||
103
database/migrations/2025_09_24_000003_create_estimates_table.php
Normal file
103
database/migrations/2025_09_24_000003_create_estimates_table.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('estimates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||
$table->unsignedBigInteger('model_set_id')->comment('모델셋(카테고리) ID');
|
||||
$table->string('estimate_no', 50)->comment('견적번호 (자동생성)');
|
||||
$table->string('estimate_name')->comment('견적명');
|
||||
$table->string('customer_name')->nullable()->comment('고객명');
|
||||
$table->string('project_name')->nullable()->comment('프로젝트명');
|
||||
|
||||
// 견적 파라미터 (사용자 입력값들)
|
||||
$table->json('parameters')->comment('견적 파라미터 (W0, H0, 수량 등)');
|
||||
|
||||
// 계산 결과 (W1, H1, 중량, 면적 등)
|
||||
$table->json('calculated_results')->nullable()->comment('계산 결과값들');
|
||||
|
||||
// BOM 데이터 (계산된 BOM 정보)
|
||||
$table->json('bom_data')->nullable()->comment('BOM 계산 결과');
|
||||
|
||||
$table->decimal('total_amount', 15, 2)->nullable()->comment('총 견적금액');
|
||||
|
||||
$table->enum('status', ['DRAFT', 'SENT', 'APPROVED', 'REJECTED', 'EXPIRED'])
|
||||
->default('DRAFT')->comment('견적 상태');
|
||||
|
||||
$table->text('notes')->nullable()->comment('비고');
|
||||
$table->date('valid_until')->nullable()->comment('견적 유효기간');
|
||||
|
||||
// 공통 감사 필드
|
||||
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자 ID');
|
||||
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID');
|
||||
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자 ID');
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
// 인덱스
|
||||
$table->index(['tenant_id', 'status']);
|
||||
$table->index(['tenant_id', 'created_at']);
|
||||
$table->index(['tenant_id', 'model_set_id']);
|
||||
$table->unique(['tenant_id', 'estimate_no']);
|
||||
|
||||
// 외래키
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
|
||||
$table->foreign('model_set_id')->references('id')->on('categories')->onDelete('restrict');
|
||||
});
|
||||
|
||||
Schema::create('estimate_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||
$table->unsignedBigInteger('estimate_id')->comment('견적 ID');
|
||||
|
||||
$table->integer('sequence')->default(1)->comment('항목 순번');
|
||||
$table->string('item_name')->comment('항목명');
|
||||
$table->text('item_description')->nullable()->comment('항목 설명');
|
||||
|
||||
// 항목별 파라미터 (개별 제품 파라미터)
|
||||
$table->json('parameters')->comment('항목별 파라미터');
|
||||
|
||||
// 항목별 계산 결과
|
||||
$table->json('calculated_values')->nullable()->comment('항목별 계산값');
|
||||
|
||||
$table->decimal('unit_price', 12, 2)->default(0)->comment('단가');
|
||||
$table->decimal('quantity', 8, 2)->default(1)->comment('수량');
|
||||
$table->decimal('total_price', 15, 2)->default(0)->comment('총 가격 (단가 × 수량)');
|
||||
|
||||
// BOM 구성품 정보
|
||||
$table->json('bom_components')->nullable()->comment('BOM 구성품 목록');
|
||||
|
||||
$table->text('notes')->nullable()->comment('항목별 비고');
|
||||
|
||||
// 공통 감사 필드
|
||||
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자 ID');
|
||||
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID');
|
||||
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자 ID');
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
// 인덱스
|
||||
$table->index(['tenant_id', 'estimate_id']);
|
||||
$table->index(['estimate_id', 'sequence']);
|
||||
|
||||
// 외래키
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
|
||||
$table->foreign('estimate_id')->references('id')->on('estimates')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('estimate_items');
|
||||
Schema::dropIfExists('estimates');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user