diff --git a/app/Models/Quote/Quote.php b/app/Models/Quote/Quote.php new file mode 100644 index 0000000..4090dc7 --- /dev/null +++ b/app/Models/Quote/Quote.php @@ -0,0 +1,274 @@ + 'date', + 'receipt_date' => 'date', + 'completion_date' => 'date', + 'finalized_at' => 'datetime', + 'is_final' => 'boolean', + 'calculation_inputs' => 'array', + 'material_cost' => 'decimal:2', + 'labor_cost' => 'decimal:2', + 'install_cost' => 'decimal:2', + 'subtotal' => 'decimal:2', + 'discount_rate' => 'decimal:2', + 'discount_amount' => 'decimal:2', + 'total_amount' => 'decimal:2', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + /** + * 제품 카테고리 상수 + */ + public const CATEGORY_SCREEN = 'SCREEN'; + + public const CATEGORY_STEEL = 'STEEL'; + + /** + * 상태 상수 + */ + public const STATUS_DRAFT = 'draft'; + + public const STATUS_SENT = 'sent'; + + public const STATUS_APPROVED = 'approved'; + + public const STATUS_REJECTED = 'rejected'; + + public const STATUS_FINALIZED = 'finalized'; + + public const STATUS_CONVERTED = 'converted'; + + /** + * 견적 품목들 + */ + public function items(): HasMany + { + return $this->hasMany(QuoteItem::class)->orderBy('sort_order'); + } + + /** + * 수정 이력들 + */ + public function revisions(): HasMany + { + return $this->hasMany(QuoteRevision::class)->orderByDesc('revision_number'); + } + + /** + * 거래처 + */ + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + /** + * 확정자 + */ + public function finalizer(): BelongsTo + { + return $this->belongsTo(User::class, 'finalized_by'); + } + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + /** + * 상태별 스코프 + */ + public function scopeDraft($query) + { + return $query->where('status', self::STATUS_DRAFT); + } + + public function scopeSent($query) + { + return $query->where('status', self::STATUS_SENT); + } + + public function scopeApproved($query) + { + return $query->where('status', self::STATUS_APPROVED); + } + + public function scopeFinalized($query) + { + return $query->where('status', self::STATUS_FINALIZED); + } + + public function scopeConverted($query) + { + return $query->where('status', self::STATUS_CONVERTED); + } + + /** + * 확정된 견적 스코프 + */ + public function scopeIsFinal($query) + { + return $query->where('is_final', true); + } + + /** + * 제품 카테고리별 스코프 + */ + public function scopeScreen($query) + { + return $query->where('product_category', self::CATEGORY_SCREEN); + } + + public function scopeSteel($query) + { + return $query->where('product_category', self::CATEGORY_STEEL); + } + + /** + * 날짜 범위 스코프 + */ + public function scopeDateRange($query, ?string $from, ?string $to) + { + if ($from) { + $query->where('registration_date', '>=', $from); + } + if ($to) { + $query->where('registration_date', '<=', $to); + } + + return $query; + } + + /** + * 검색 스코프 + */ + public function scopeSearch($query, ?string $keyword) + { + if (! $keyword) { + return $query; + } + + return $query->where(function ($q) use ($keyword) { + $q->where('quote_number', 'like', "%{$keyword}%") + ->orWhere('client_name', 'like', "%{$keyword}%") + ->orWhere('manager', 'like', "%{$keyword}%") + ->orWhere('site_name', 'like', "%{$keyword}%") + ->orWhere('product_name', 'like', "%{$keyword}%"); + }); + } + + /** + * 수정 가능 여부 확인 + */ + public function isEditable(): bool + { + return ! in_array($this->status, [self::STATUS_FINALIZED, self::STATUS_CONVERTED]); + } + + /** + * 삭제 가능 여부 확인 + */ + public function isDeletable(): bool + { + return ! in_array($this->status, [self::STATUS_FINALIZED, self::STATUS_CONVERTED]); + } + + /** + * 확정 가능 여부 확인 + */ + public function isFinalizable(): bool + { + return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_SENT, self::STATUS_APPROVED]) + && ! $this->is_final; + } + + /** + * 수주 전환 가능 여부 확인 + */ + public function isConvertible(): bool + { + return $this->status === self::STATUS_FINALIZED && $this->is_final; + } +} diff --git a/app/Models/Quote/QuoteItem.php b/app/Models/Quote/QuoteItem.php new file mode 100644 index 0000000..690816e --- /dev/null +++ b/app/Models/Quote/QuoteItem.php @@ -0,0 +1,75 @@ + 'decimal:4', + 'calculated_quantity' => 'decimal:4', + 'unit_price' => 'decimal:2', + 'total_price' => 'decimal:2', + 'delivery_date' => 'date', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * 견적 마스터 + */ + public function quote(): BelongsTo + { + return $this->belongsTo(Quote::class); + } + + /** + * 금액 계산 (수량 × 단가) + */ + public function calculateTotalPrice(): float + { + return round($this->calculated_quantity * $this->unit_price, 2); + } + + /** + * 금액 업데이트 후 저장 + */ + public function updateTotalPrice(): self + { + $this->total_price = $this->calculateTotalPrice(); + + return $this; + } +} diff --git a/app/Models/Quote/QuoteRevision.php b/app/Models/Quote/QuoteRevision.php new file mode 100644 index 0000000..988fbc3 --- /dev/null +++ b/app/Models/Quote/QuoteRevision.php @@ -0,0 +1,73 @@ + 'date', + 'previous_data' => 'array', + 'created_at' => 'datetime', + ]; + + /** + * 견적 마스터 + */ + public function quote(): BelongsTo + { + return $this->belongsTo(Quote::class); + } + + /** + * 수정자 + */ + public function reviser(): BelongsTo + { + return $this->belongsTo(User::class, 'revision_by'); + } + + /** + * 이전 데이터 스냅샷에서 특정 필드 가져오기 + */ + public function getPreviousValue(string $key, $default = null) + { + return data_get($this->previous_data, $key, $default); + } + + /** + * 이전 데이터 스냅샷에서 품목 목록 가져오기 + */ + public function getPreviousItems(): array + { + return $this->getPreviousValue('items', []); + } + + /** + * 이전 데이터 스냅샷에서 총액 가져오기 + */ + public function getPreviousTotalAmount(): float + { + return (float) $this->getPreviousValue('total_amount', 0); + } +} diff --git a/claudedocs/[PLAN-2025-12-04] quote-api-development-plan.md b/claudedocs/[PLAN-2025-12-04] quote-api-development-plan.md new file mode 100644 index 0000000..e83822f --- /dev/null +++ b/claudedocs/[PLAN-2025-12-04] quote-api-development-plan.md @@ -0,0 +1,467 @@ +# 견적관리 API 개발 계획서 + +> **작성일**: 2025-12-04 +> **요청서**: `docs/front/[API-2025-12-04] quote-api-request.md` +> **상태**: 🔄 Phase 1 진행 중 → 요청 변경으로 재계획 필요 + +--- + +## 🔧 작업 진행 현황 (2025-12-04) + +### ✅ Phase 1 완료 항목 + +#### 1. 마이그레이션 생성 및 실행 완료 +- **파일**: `database/migrations/2025_12_04_164542_create_quotes_table.php` +- **생성된 테이블**: `quotes`, `quote_items`, `quote_revisions` +- **실행 상태**: ✅ 마이그레이션 완료 + +#### 2. Model 생성 완료 +``` +app/Models/Quote/ +├── Quote.php ✅ 생성 완료 +├── QuoteItem.php ✅ 생성 완료 +└── QuoteRevision.php ✅ 생성 완료 +``` + +**적용된 패턴:** +- `BelongsToTenant` 트레이트 적용 +- `SoftDeletes` 적용 (Quote) +- 상태 상수 정의 (STATUS_DRAFT, STATUS_FINALIZED 등) +- 스코프 메서드 (draft, finalized, search, dateRange 등) +- 상태 검증 메서드 (isEditable, isDeletable, isFinalizable, isConvertible) + +### ⏸️ 일시 중단 +- **사유**: 요청서 변경으로 계획 재수립 필요 +- **다음 단계**: 변경된 요청서 확인 후 계획 비교 분석 + +--- + +## 1. 요구사항 분석 요약 + +### 1.1 핵심 엔티티 +| 엔티티 | 설명 | 비고 | +|--------|------|------| +| Quote | 견적 마스터 | 메인 테이블 | +| QuoteItem | 견적 품목 | BOM/수식 계산 결과 저장 | +| QuoteRevision | 수정 이력 | 버전 관리 | + +### 1.2 기능 우선순위 +| 우선순위 | 기능 | 설명 | +|----------|------|------| +| **P1** | 견적 CRUD | 기본 목록/등록/수정/삭제 | +| **P1** | 자동 산출 | 수식 계산 엔진 (핵심) | +| **P1** | 견적번호 생성 | 자동 채번 | +| **P2** | 상태 관리 | 확정/수주전환 | +| **P2** | 수정 이력 | 버전 관리 | +| **P3** | 문서 출력 | PDF 생성 | + +### 1.3 기존 인프라 활용 +- `quote_formulas` 테이블들 (수식 계산용) - **이미 존재** +- `FormulaEvaluatorService` (mng에서 사용 중) - **API로 이식 필요** +- `EstimateService` 패턴 참조 - **유사 구조** + +--- + +## 2. 데이터베이스 설계 + +### 2.1 신규 테이블: quotes (견적 마스터) + +```sql +CREATE TABLE quotes ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + + -- 기본 정보 + quote_number VARCHAR(50) NOT NULL COMMENT '견적번호 (예: KD-SC-251204-01)', + registration_date DATE NOT NULL COMMENT '등록일', + receipt_date DATE NULL COMMENT '접수일', + author VARCHAR(100) NULL COMMENT '작성자', + + -- 발주처 정보 + client_id BIGINT UNSIGNED NULL COMMENT '거래처 ID (FK)', + client_name VARCHAR(100) NULL COMMENT '거래처명 (직접입력 대응)', + manager VARCHAR(100) NULL COMMENT '담당자', + contact VARCHAR(50) NULL COMMENT '연락처', + + -- 현장 정보 + site_id BIGINT UNSIGNED NULL COMMENT '현장 ID', + site_name VARCHAR(200) NULL COMMENT '현장명', + site_code VARCHAR(50) NULL COMMENT '현장코드', + + -- 제품 정보 + product_category ENUM('SCREEN', 'STEEL') NOT NULL COMMENT '제품 카테고리', + product_id BIGINT UNSIGNED NULL COMMENT '선택된 제품 ID', + product_code VARCHAR(50) NULL COMMENT '제품코드', + product_name VARCHAR(200) NULL COMMENT '제품명', + + -- 규격 정보 + open_size_width INT UNSIGNED NULL COMMENT '오픈사이즈 폭 (mm)', + open_size_height INT UNSIGNED NULL COMMENT '오픈사이즈 높이 (mm)', + quantity INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '수량', + unit_symbol VARCHAR(10) NULL COMMENT '부호', + floors VARCHAR(20) NULL COMMENT '층수', + + -- 금액 정보 + material_cost DECIMAL(15,2) NOT NULL DEFAULT 0 COMMENT '재료비 합계', + labor_cost DECIMAL(15,2) NOT NULL DEFAULT 0 COMMENT '노무비', + install_cost DECIMAL(15,2) NOT NULL DEFAULT 0 COMMENT '설치비', + subtotal DECIMAL(15,2) NOT NULL DEFAULT 0 COMMENT '소계', + discount_rate DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '할인율 (%)', + discount_amount DECIMAL(15,2) NOT NULL DEFAULT 0 COMMENT '할인금액', + total_amount DECIMAL(15,2) NOT NULL DEFAULT 0 COMMENT '최종 금액', + + -- 상태 관리 + status ENUM('draft', 'sent', 'approved', 'rejected', 'finalized', 'converted') NOT NULL DEFAULT 'draft' COMMENT '상태', + current_revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '현재 수정 차수', + is_final TINYINT(1) NOT NULL DEFAULT 0 COMMENT '최종확정 여부', + finalized_at DATETIME NULL COMMENT '확정일시', + finalized_by BIGINT UNSIGNED NULL COMMENT '확정자 ID', + + -- 기타 정보 + completion_date DATE NULL COMMENT '납기일', + remarks TEXT NULL COMMENT '비고', + memo TEXT NULL COMMENT '메모', + notes TEXT NULL COMMENT '특이사항', + + -- 자동산출 입력값 저장 + calculation_inputs JSON NULL COMMENT '자동산출에 사용된 입력값', + + -- 감사 + created_by BIGINT UNSIGNED NULL COMMENT '생성자', + updated_by BIGINT UNSIGNED NULL COMMENT '수정자', + deleted_by BIGINT UNSIGNED NULL COMMENT '삭제자', + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, + + -- 인덱스 + INDEX idx_tenant_id (tenant_id), + INDEX idx_quote_number (quote_number), + INDEX idx_status (status), + INDEX idx_client_id (client_id), + INDEX idx_product_category (product_category), + INDEX idx_registration_date (registration_date), + UNIQUE INDEX uq_tenant_quote_number (tenant_id, quote_number, deleted_at), + + FOREIGN KEY (tenant_id) REFERENCES tenants(id), + FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE SET NULL +); +``` + +### 2.2 신규 테이블: quote_items (견적 품목) + +```sql +CREATE TABLE quote_items ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + quote_id BIGINT UNSIGNED NOT NULL COMMENT '견적 ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + + -- 품목 정보 + item_id BIGINT UNSIGNED NULL COMMENT '품목마스터 ID', + 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 '단위', + + -- 수량/금액 + base_quantity DECIMAL(15,4) NOT NULL DEFAULT 1 COMMENT '기본수량', + calculated_quantity DECIMAL(15,4) NOT NULL DEFAULT 1 COMMENT '계산된 수량', + unit_price DECIMAL(15,2) NOT NULL DEFAULT 0 COMMENT '단가', + total_price DECIMAL(15,2) NOT NULL DEFAULT 0 COMMENT '금액 (수량 × 단가)', + + -- 수식 정보 + formula VARCHAR(500) NULL COMMENT '수식', + formula_result VARCHAR(200) NULL COMMENT '수식 계산 결과 표시', + formula_source VARCHAR(100) NULL COMMENT '수식 출처', + formula_category VARCHAR(50) NULL COMMENT '수식 카테고리', + data_source VARCHAR(200) NULL COMMENT '데이터 출처', + + -- 기타 + delivery_date DATE NULL COMMENT '품목별 납기일', + note TEXT NULL COMMENT '비고', + sort_order INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '정렬순서', + + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + + -- 인덱스 + INDEX idx_quote_id (quote_id), + INDEX idx_tenant_id (tenant_id), + INDEX idx_item_code (item_code), + INDEX idx_sort_order (sort_order), + + FOREIGN KEY (quote_id) REFERENCES quotes(id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) +); +``` + +### 2.3 신규 테이블: quote_revisions (수정 이력) + +```sql +CREATE TABLE quote_revisions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + quote_id BIGINT UNSIGNED NOT NULL COMMENT '견적 ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + + revision_number INT UNSIGNED NOT NULL COMMENT '수정 차수', + revision_date DATE NOT NULL COMMENT '수정일', + revision_by BIGINT UNSIGNED NOT NULL COMMENT '수정자 ID', + revision_by_name VARCHAR(100) NULL COMMENT '수정자 이름', + revision_reason TEXT NULL COMMENT '수정 사유', + + -- 이전 버전 데이터 (JSON 스냅샷) + previous_data JSON NOT NULL COMMENT '수정 전 견적 전체 데이터', + + created_at TIMESTAMP NULL, + + -- 인덱스 + INDEX idx_quote_id (quote_id), + INDEX idx_tenant_id (tenant_id), + INDEX idx_revision_number (revision_number), + + FOREIGN KEY (quote_id) REFERENCES quotes(id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) +); +``` + +### 2.4 기존 테이블 활용 +- `quote_formula_categories` - 수식 카테고리 +- `quote_formulas` - 수식 정의 +- `quote_formula_ranges` - 범위별 값 +- `quote_formula_mappings` - 매핑 값 +- `quote_formula_items` - 수식 품목 출력 +- `clients` - 발주처 +- `item_masters` - 품목 마스터 (단가 연동) + +--- + +## 3. API 엔드포인트 설계 + +### 3.1 기본 CRUD + +| Method | Endpoint | 설명 | +|--------|----------|------| +| `GET` | `/api/v1/quotes` | 목록 조회 (페이징, 필터, 검색) | +| `GET` | `/api/v1/quotes/{id}` | 단건 조회 (items, revisions 포함) | +| `POST` | `/api/v1/quotes` | 생성 (품목 배열 포함) | +| `PUT` | `/api/v1/quotes/{id}` | 수정 (수정이력 자동 생성) | +| `DELETE` | `/api/v1/quotes/{id}` | 삭제 (Soft Delete) | +| `DELETE` | `/api/v1/quotes` | 일괄 삭제 (`ids[]` 파라미터) | + +### 3.2 상태 관리 + +| Method | Endpoint | 설명 | +|--------|----------|------| +| `PATCH` | `/api/v1/quotes/{id}/finalize` | 최종확정 | +| `PATCH` | `/api/v1/quotes/{id}/convert-to-order` | 수주전환 | +| `PATCH` | `/api/v1/quotes/{id}/cancel-finalize` | 확정취소 | + +### 3.3 자동 산출 + +| Method | Endpoint | 설명 | +|--------|----------|------| +| `POST` | `/api/v1/quotes/calculate` | 자동 산출 (수식 엔진) | +| `POST` | `/api/v1/quotes/{id}/recalculate` | 기존 견적 재계산 | +| `GET` | `/api/v1/quotes/generate-number` | 견적번호 생성 | + +### 3.4 문서 출력 (P3 - 후순위) + +| Method | Endpoint | 설명 | +|--------|----------|------| +| `GET` | `/api/v1/quotes/{id}/document/quote` | 견적서 PDF | +| `GET` | `/api/v1/quotes/{id}/document/calculation` | 산출내역서 PDF | +| `GET` | `/api/v1/quotes/{id}/document/purchase-order` | 발주서 PDF | + +--- + +## 4. 개발 Phase 계획 + +### Phase 1: 기반 구축 (DB + Model) + +**작업 내용:** +1. 마이그레이션 생성 (3개 테이블) +2. Model 생성 (Quote, QuoteItem, QuoteRevision) +3. Trait/Scope 적용 (BelongsToTenant, SoftDeletes, 감사 컬럼) + +**파일 목록:** +``` +database/migrations/ +├── 2025_12_XX_XXXXXX_create_quotes_table.php +├── 2025_12_XX_XXXXXX_create_quote_items_table.php +└── 2025_12_XX_XXXXXX_create_quote_revisions_table.php + +app/Models/Quote/ +├── Quote.php +├── QuoteItem.php +└── QuoteRevision.php +``` + +### Phase 2: 핵심 서비스 (Service Layer) + +**작업 내용:** +1. QuoteService - CRUD + 상태관리 +2. QuoteCalculationService - 자동산출 (FormulaEvaluator 연동) +3. QuoteNumberService - 번호채번 +4. FormulaEvaluatorService 이식 (mng → api) + +**파일 목록:** +``` +app/Services/Quote/ +├── QuoteService.php +├── QuoteCalculationService.php +├── QuoteNumberService.php +└── FormulaEvaluatorService.php (mng에서 이식) +``` + +**의존성:** +- PricingService (단가 조회) +- ClientService (발주처 연동) + +### Phase 3: API 구현 (Controller + Routes) + +**작업 내용:** +1. QuoteController 구현 +2. FormRequest 생성 (검증 규칙) +3. 라우트 추가 + +**파일 목록:** +``` +app/Http/Controllers/Api/V1/ +└── QuoteController.php + +app/Http/Requests/Quote/ +├── QuoteIndexRequest.php +├── QuoteStoreRequest.php +├── QuoteUpdateRequest.php +├── QuoteCalculateRequest.php +└── QuoteFinalizeRequest.php + +routes/ +└── api_v1.php (라우트 추가) +``` + +### Phase 4: 문서화 (Swagger + i18n) + +**작업 내용:** +1. Swagger 문서 작성 +2. i18n 메시지 키 추가 + +**파일 목록:** +``` +app/Swagger/v1/ +└── QuoteApi.php + +lang/ko/ +├── message.php (quote 키 추가) +└── error.php (quote 에러 키 추가) +``` + +--- + +## 5. 자동 산출 로직 상세 + +### 5.1 입력값 → 수식 변수 매핑 + +| 입력 필드 | 수식 변수 | 설명 | +|-----------|-----------|------| +| `open_size_width` | `W0` | 오픈사이즈 폭 (mm) | +| `open_size_height` | `H0` | 오픈사이즈 높이 (mm) | +| `quantity` | `Q` | 수량 | +| `floors` | `FLOORS` | 층수 | +| `options.guide_rail_install_type` | `GUIDE_TYPE` | 가이드레일 설치 유형 | +| `options.motor_power` | `MOTOR_POWER` | 모터 용량 | +| `options.controller` | `CONTROLLER_TYPE` | 제어기 유형 | +| `options.edge_wing_size` | `EDGE_SIZE` | 마구리 날개 사이즈 | +| `options.inspection_fee` | `INSP_FEE` | 검사비 | + +### 5.2 처리 흐름 + +``` +1. 입력값 수집 + ↓ +2. 수식 변수 초기화 (input 타입) + ↓ +3. 계산 수식 실행 (calculation 타입) + - 의존성 순서대로 실행 + - FormulaEvaluatorService 활용 + ↓ +4. 범위 판단 (range 타입) + - quote_formula_ranges 조회 + ↓ +5. 매핑 판단 (mapping 타입) + - quote_formula_mappings 조회 + ↓ +6. 품목 출력 생성 (quote_formula_items) + - 수량 계산식 적용 + - 단가 조회 (품목마스터 또는 고정값) + ↓ +7. 결과 반환 + - items 배열 + - summary (재료비, 노무비, 설치비 등) + - calculation_info (사용된 변수들) +``` + +### 5.3 FormulaEvaluatorService 이식 범위 + +mng의 `QuoteFormulaService`에서 다음 메서드 이식: +- `evaluateFormula()` - 수식 평가 +- `evaluateAllFormulas()` - 전체 수식 순차 평가 +- `resolveRangeValue()` - 범위 값 해석 +- `resolveMappingValue()` - 매핑 값 해석 + +--- + +## 6. 예상 산출물 요약 + +| 구분 | 수량 | 내용 | +|------|------|------| +| 마이그레이션 | 3개 | quotes, quote_items, quote_revisions | +| Model | 3개 | Quote, QuoteItem, QuoteRevision | +| Service | 4개 | QuoteService, QuoteCalculationService, QuoteNumberService, FormulaEvaluatorService | +| Controller | 1개 | QuoteController | +| FormRequest | 5개 | Index, Store, Update, Calculate, Finalize | +| Swagger | 1개 | QuoteApi.php | +| i18n | 2개 | message.php, error.php (키 추가) | + +--- + +## 7. 질문사항 답변 + +### Q1. 현장(Site) 테이블 +**답변:** `quotes` 테이블에 `site_*` 컬럼으로 직접 저장 (별도 테이블 불필요) +- `site_id`, `site_name`, `site_code` 컬럼 포함 +- 추후 별도 테이블 필요 시 마이그레이션으로 분리 가능 + +### Q2. 수식 계산 BOM 템플릿 테이블 구조 +**답변:** 기존 `quote_formulas` 테이블 구조 활용 +- `quote_formula_categories` - 수식 카테고리 +- `quote_formulas` - 수식 정의 (input/calculation/range/mapping) +- `quote_formula_ranges` - 범위별 값 +- `quote_formula_mappings` - 매핑 값 +- `quote_formula_items` - 품목 출력 정의 + +### Q3. 문서 출력 PDF 라이브러리 +**답변:** P3 (후순위) 작업으로 진행 +- 권장: **TCPDF** (한글 지원, Laravel 연동 용이) +- 대안: Dompdf (HTML → PDF 변환) + +### Q4. 알림 (이메일/카카오톡) +**답변:** 현재 요구사항에 없음, 추후 확장 가능 + +--- + +## 8. 승인 요청 + +**개발 범위:** +- Phase 1~4 전체 구현 (P3 문서 출력 제외) +- 예상 작업량: Phase별 순차 진행 + +**진행 조건:** +- 이 계획서 승인 후 Phase 1부터 순차 진행 +- 각 Phase 완료 시 검증 후 다음 Phase 진행 +- 커밋은 각 Phase 완료 시 승인 후 진행 + +--- + +**작성자:** Claude (AI Assistant) +**검토자:** (승인 대기) diff --git a/database/migrations/2025_12_04_164542_create_quotes_table.php b/database/migrations/2025_12_04_164542_create_quotes_table.php new file mode 100644 index 0000000..f0db2d7 --- /dev/null +++ b/database/migrations/2025_12_04_164542_create_quotes_table.php @@ -0,0 +1,164 @@ +id(); + $table->foreignId('tenant_id')->constrained()->comment('테넌트 ID'); + + // 기본 정보 + $table->string('quote_number', 50)->comment('견적번호 (예: KD-SC-251204-01)'); + $table->date('registration_date')->comment('등록일'); + $table->date('receipt_date')->nullable()->comment('접수일'); + $table->string('author', 100)->nullable()->comment('작성자'); + + // 발주처 정보 + $table->foreignId('client_id')->nullable()->comment('거래처 ID (FK)'); + $table->string('client_name', 100)->nullable()->comment('거래처명 (직접입력 대응)'); + $table->string('manager', 100)->nullable()->comment('담당자'); + $table->string('contact', 50)->nullable()->comment('연락처'); + + // 현장 정보 + $table->unsignedBigInteger('site_id')->nullable()->comment('현장 ID'); + $table->string('site_name', 200)->nullable()->comment('현장명'); + $table->string('site_code', 50)->nullable()->comment('현장코드'); + + // 제품 정보 + $table->enum('product_category', ['SCREEN', 'STEEL'])->comment('제품 카테고리'); + $table->unsignedBigInteger('product_id')->nullable()->comment('선택된 제품 ID'); + $table->string('product_code', 50)->nullable()->comment('제품코드'); + $table->string('product_name', 200)->nullable()->comment('제품명'); + + // 규격 정보 + $table->unsignedInteger('open_size_width')->nullable()->comment('오픈사이즈 폭 (mm)'); + $table->unsignedInteger('open_size_height')->nullable()->comment('오픈사이즈 높이 (mm)'); + $table->unsignedInteger('quantity')->default(1)->comment('수량'); + $table->string('unit_symbol', 10)->nullable()->comment('부호'); + $table->string('floors', 20)->nullable()->comment('층수'); + + // 금액 정보 + $table->decimal('material_cost', 15, 2)->default(0)->comment('재료비 합계'); + $table->decimal('labor_cost', 15, 2)->default(0)->comment('노무비'); + $table->decimal('install_cost', 15, 2)->default(0)->comment('설치비'); + $table->decimal('subtotal', 15, 2)->default(0)->comment('소계'); + $table->decimal('discount_rate', 5, 2)->default(0)->comment('할인율 (%)'); + $table->decimal('discount_amount', 15, 2)->default(0)->comment('할인금액'); + $table->decimal('total_amount', 15, 2)->default(0)->comment('최종 금액'); + + // 상태 관리 + $table->enum('status', ['draft', 'sent', 'approved', 'rejected', 'finalized', 'converted'])->default('draft')->comment('상태'); + $table->unsignedInteger('current_revision')->default(0)->comment('현재 수정 차수'); + $table->boolean('is_final')->default(false)->comment('최종확정 여부'); + $table->dateTime('finalized_at')->nullable()->comment('확정일시'); + $table->foreignId('finalized_by')->nullable()->comment('확정자 ID'); + + // 기타 정보 + $table->date('completion_date')->nullable()->comment('납기일'); + $table->text('remarks')->nullable()->comment('비고'); + $table->text('memo')->nullable()->comment('메모'); + $table->text('notes')->nullable()->comment('특이사항'); + + // 자동산출 입력값 저장 + $table->json('calculation_inputs')->nullable()->comment('자동산출에 사용된 입력값'); + + // 감사 + $table->foreignId('created_by')->nullable()->comment('생성자'); + $table->foreignId('updated_by')->nullable()->comment('수정자'); + $table->foreignId('deleted_by')->nullable()->comment('삭제자'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index('tenant_id', 'idx_quotes_tenant_id'); + $table->index('quote_number', 'idx_quotes_quote_number'); + $table->index('status', 'idx_quotes_status'); + $table->index('client_id', 'idx_quotes_client_id'); + $table->index('product_category', 'idx_quotes_product_category'); + $table->index('registration_date', 'idx_quotes_registration_date'); + $table->unique(['tenant_id', 'quote_number', 'deleted_at'], 'uq_tenant_quote_number'); + }); + + // 2. 견적 품목 테이블 + Schema::create('quote_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('quote_id')->constrained()->cascadeOnDelete()->comment('견적 ID'); + $table->foreignId('tenant_id')->constrained()->comment('테넌트 ID'); + + // 품목 정보 + $table->unsignedBigInteger('item_id')->nullable()->comment('품목마스터 ID'); + $table->string('item_code', 50)->comment('품목코드'); + $table->string('item_name', 200)->comment('품명'); + $table->string('specification', 100)->nullable()->comment('규격'); + $table->string('unit', 20)->comment('단위'); + + // 수량/금액 + $table->decimal('base_quantity', 15, 4)->default(1)->comment('기본수량'); + $table->decimal('calculated_quantity', 15, 4)->default(1)->comment('계산된 수량'); + $table->decimal('unit_price', 15, 2)->default(0)->comment('단가'); + $table->decimal('total_price', 15, 2)->default(0)->comment('금액 (수량 × 단가)'); + + // 수식 정보 + $table->string('formula', 500)->nullable()->comment('수식'); + $table->string('formula_result', 200)->nullable()->comment('수식 계산 결과 표시'); + $table->string('formula_source', 100)->nullable()->comment('수식 출처'); + $table->string('formula_category', 50)->nullable()->comment('수식 카테고리'); + $table->string('data_source', 200)->nullable()->comment('데이터 출처'); + + // 기타 + $table->date('delivery_date')->nullable()->comment('품목별 납기일'); + $table->text('note')->nullable()->comment('비고'); + $table->unsignedInteger('sort_order')->default(0)->comment('정렬순서'); + + $table->timestamps(); + + // 인덱스 + $table->index('quote_id', 'idx_quote_items_quote_id'); + $table->index('tenant_id', 'idx_quote_items_tenant_id'); + $table->index('item_code', 'idx_quote_items_item_code'); + $table->index('sort_order', 'idx_quote_items_sort_order'); + }); + + // 3. 견적 수정 이력 테이블 + Schema::create('quote_revisions', function (Blueprint $table) { + $table->id(); + $table->foreignId('quote_id')->constrained()->cascadeOnDelete()->comment('견적 ID'); + $table->foreignId('tenant_id')->constrained()->comment('테넌트 ID'); + + $table->unsignedInteger('revision_number')->comment('수정 차수'); + $table->date('revision_date')->comment('수정일'); + $table->foreignId('revision_by')->comment('수정자 ID'); + $table->string('revision_by_name', 100)->nullable()->comment('수정자 이름'); + $table->text('revision_reason')->nullable()->comment('수정 사유'); + + // 이전 버전 데이터 (JSON 스냅샷) + $table->json('previous_data')->comment('수정 전 견적 전체 데이터'); + + $table->timestamp('created_at')->nullable(); + + // 인덱스 + $table->index('quote_id', 'idx_quote_revisions_quote_id'); + $table->index('tenant_id', 'idx_quote_revisions_tenant_id'); + $table->index('revision_number', 'idx_quote_revisions_revision_number'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('quote_revisions'); + Schema::dropIfExists('quote_items'); + Schema::dropIfExists('quotes'); + } +};