feat: [quote] 견적관리 API 기반 구축 (Phase 1)
- 마이그레이션 생성: quotes, quote_items, quote_revisions 테이블 - Model 생성: Quote, QuoteItem, QuoteRevision - BelongsToTenant, SoftDeletes 트레이트 적용 - 상태 관리 메서드 및 스코프 구현 - 개발 계획서 작성 및 진행 상황 문서화
This commit is contained in:
274
app/Models/Quote/Quote.php
Normal file
274
app/Models/Quote/Quote.php
Normal file
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Quote;
|
||||
|
||||
use App\Models\Members\User;
|
||||
use App\Models\Orders\Client;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Quote extends Model
|
||||
{
|
||||
use BelongsToTenant, HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'quote_number',
|
||||
'registration_date',
|
||||
'receipt_date',
|
||||
'author',
|
||||
// 발주처 정보
|
||||
'client_id',
|
||||
'client_name',
|
||||
'manager',
|
||||
'contact',
|
||||
// 현장 정보
|
||||
'site_id',
|
||||
'site_name',
|
||||
'site_code',
|
||||
// 제품 정보
|
||||
'product_category',
|
||||
'product_id',
|
||||
'product_code',
|
||||
'product_name',
|
||||
// 규격 정보
|
||||
'open_size_width',
|
||||
'open_size_height',
|
||||
'quantity',
|
||||
'unit_symbol',
|
||||
'floors',
|
||||
// 금액 정보
|
||||
'material_cost',
|
||||
'labor_cost',
|
||||
'install_cost',
|
||||
'subtotal',
|
||||
'discount_rate',
|
||||
'discount_amount',
|
||||
'total_amount',
|
||||
// 상태 관리
|
||||
'status',
|
||||
'current_revision',
|
||||
'is_final',
|
||||
'finalized_at',
|
||||
'finalized_by',
|
||||
// 기타 정보
|
||||
'completion_date',
|
||||
'remarks',
|
||||
'memo',
|
||||
'notes',
|
||||
// 자동산출 입력값
|
||||
'calculation_inputs',
|
||||
// 감사
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'registration_date' => '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;
|
||||
}
|
||||
}
|
||||
75
app/Models/Quote/QuoteItem.php
Normal file
75
app/Models/Quote/QuoteItem.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Quote;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class QuoteItem extends Model
|
||||
{
|
||||
use BelongsToTenant, HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'quote_id',
|
||||
'tenant_id',
|
||||
// 품목 정보
|
||||
'item_id',
|
||||
'item_code',
|
||||
'item_name',
|
||||
'specification',
|
||||
'unit',
|
||||
// 수량/금액
|
||||
'base_quantity',
|
||||
'calculated_quantity',
|
||||
'unit_price',
|
||||
'total_price',
|
||||
// 수식 정보
|
||||
'formula',
|
||||
'formula_result',
|
||||
'formula_source',
|
||||
'formula_category',
|
||||
'data_source',
|
||||
// 기타
|
||||
'delivery_date',
|
||||
'note',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'base_quantity' => '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;
|
||||
}
|
||||
}
|
||||
73
app/Models/Quote/QuoteRevision.php
Normal file
73
app/Models/Quote/QuoteRevision.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Quote;
|
||||
|
||||
use App\Models\Members\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class QuoteRevision extends Model
|
||||
{
|
||||
use BelongsToTenant, HasFactory;
|
||||
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'quote_id',
|
||||
'tenant_id',
|
||||
'revision_number',
|
||||
'revision_date',
|
||||
'revision_by',
|
||||
'revision_by_name',
|
||||
'revision_reason',
|
||||
'previous_data',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'revision_date' => '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);
|
||||
}
|
||||
}
|
||||
467
claudedocs/[PLAN-2025-12-04] quote-api-development-plan.md
Normal file
467
claudedocs/[PLAN-2025-12-04] quote-api-development-plan.md
Normal file
@@ -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)
|
||||
**검토자:** (승인 대기)
|
||||
164
database/migrations/2025_12_04_164542_create_quotes_table.php
Normal file
164
database/migrations/2025_12_04_164542_create_quotes_table.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. 견적 마스터 테이블
|
||||
Schema::create('quotes', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user