From 84ad9e1fc46465a24cd597ab3a7c481178b9cae2 Mon Sep 17 00:00:00 2001 From: kent Date: Tue, 13 Jan 2026 09:55:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(quote):=20quote=5Ftype=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B1=B4=EC=84=A4?= =?UTF-8?q?=20=EA=B2=AC=EC=A0=81=20=ED=95=84=ED=84=B0=EB=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - quotes 테이블에 quote_type 컬럼 추가 (manufacturing/construction) - Quote 모델에 TYPE 상수 및 스코프 메서드 추가 - QuoteService.index()에 quote_type 필터 적용 - QuoteService.upsertFromSiteBriefing()에 construction 타입 설정 - QuoteIndexRequest에 quote_type 유효성 검증 추가 - 기존 site_briefing_id 있는 데이터는 construction으로 자동 업데이트 Co-Authored-By: Claude --- app/Http/Requests/Quote/QuoteIndexRequest.php | 16 +++ app/Models/Quote/Quote.php | 58 ++++++++++ app/Services/Quote/QuoteService.php | 104 ++++++++++++++++++ ..._210406_add_quote_type_to_quotes_table.php | 40 +++++++ 4 files changed, 218 insertions(+) create mode 100644 database/migrations/2026_01_12_210406_add_quote_type_to_quotes_table.php diff --git a/app/Http/Requests/Quote/QuoteIndexRequest.php b/app/Http/Requests/Quote/QuoteIndexRequest.php index 4ffb5fe..401f8ac 100644 --- a/app/Http/Requests/Quote/QuoteIndexRequest.php +++ b/app/Http/Requests/Quote/QuoteIndexRequest.php @@ -12,13 +12,28 @@ public function authorize(): bool return true; } + /** + * 검증 전 데이터 전처리 + * - 쿼리 스트링의 "true"/"false" 문자열을 boolean으로 변환 + */ + protected function prepareForValidation(): void + { + if ($this->has('with_items')) { + $this->merge([ + 'with_items' => filter_var($this->with_items, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE), + ]); + } + } + public function rules(): array { return [ 'page' => 'nullable|integer|min:1', 'size' => 'nullable|integer|min:1|max:100', 'q' => 'nullable|string|max:100', + 'quote_type' => 'nullable|in:'.implode(',', Quote::TYPES), 'status' => 'nullable|in:'.implode(',', [ + Quote::STATUS_PENDING, Quote::STATUS_DRAFT, Quote::STATUS_SENT, Quote::STATUS_APPROVED, @@ -32,6 +47,7 @@ public function rules(): array 'date_to' => 'nullable|date|after_or_equal:date_from', 'sort_by' => 'nullable|in:registration_date,quote_number,client_name,total_amount,status,created_at', 'sort_order' => 'nullable|in:asc,desc', + 'with_items' => 'nullable|boolean', // 수주 전환용 품목 포함 여부 ]; } } diff --git a/app/Models/Quote/Quote.php b/app/Models/Quote/Quote.php index 3632b5d..66f274a 100644 --- a/app/Models/Quote/Quote.php +++ b/app/Models/Quote/Quote.php @@ -6,6 +6,7 @@ use App\Models\Members\User; use App\Models\Orders\Client; use App\Models\Orders\Order; +use App\Models\Tenants\SiteBriefing; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -19,7 +20,9 @@ class Quote extends Model protected $fillable = [ 'tenant_id', + 'quote_type', 'order_id', + 'site_briefing_id', 'quote_number', 'registration_date', 'receipt_date', @@ -90,6 +93,18 @@ class Quote extends Model 'deleted_at' => 'datetime', ]; + /** + * 견적 유형 상수 + */ + public const TYPE_MANUFACTURING = 'manufacturing'; + + public const TYPE_CONSTRUCTION = 'construction'; + + public const TYPES = [ + self::TYPE_MANUFACTURING, + self::TYPE_CONSTRUCTION, + ]; + /** * 제품 카테고리 상수 */ @@ -100,6 +115,8 @@ class Quote extends Model /** * 상태 상수 */ + public const STATUS_PENDING = 'pending'; // 견적대기 (현장설명회에서 자동생성) + public const STATUS_DRAFT = 'draft'; public const STATUS_SENT = 'sent'; @@ -112,6 +129,16 @@ class Quote extends Model public const STATUS_CONVERTED = 'converted'; + public const STATUSES = [ + self::STATUS_PENDING, + self::STATUS_DRAFT, + self::STATUS_SENT, + self::STATUS_APPROVED, + self::STATUS_REJECTED, + self::STATUS_FINALIZED, + self::STATUS_CONVERTED, + ]; + /** * 견적 품목들 */ @@ -152,6 +179,14 @@ public function order(): BelongsTo return $this->belongsTo(Order::class); } + /** + * 현장설명회 (자동생성 시 연결) + */ + public function siteBriefing(): BelongsTo + { + return $this->belongsTo(SiteBriefing::class); + } + /** * 확정자 */ @@ -179,6 +214,11 @@ public function updater(): BelongsTo /** * 상태별 스코프 */ + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + public function scopeDraft($query) { return $query->where('status', self::STATUS_DRAFT); @@ -225,6 +265,24 @@ public function scopeSteel($query) return $query->where('product_category', self::CATEGORY_STEEL); } + /** + * 견적 유형별 스코프 + */ + public function scopeManufacturing($query) + { + return $query->where('quote_type', self::TYPE_MANUFACTURING); + } + + public function scopeConstruction($query) + { + return $query->where('quote_type', self::TYPE_CONSTRUCTION); + } + + public function scopeOfType($query, string $type) + { + return $query->where('quote_type', $type); + } + /** * 날짜 범위 스코프 */ diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index 6d222b2..7afaa29 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -7,6 +7,7 @@ use App\Models\Quote\Quote; use App\Models\Quote\QuoteItem; use App\Models\Quote\QuoteRevision; +use App\Models\Tenants\SiteBriefing; use App\Services\Service; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; @@ -30,6 +31,7 @@ public function index(array $params): LengthAwarePaginator $page = (int) ($params['page'] ?? 1); $size = (int) ($params['size'] ?? 20); $q = trim((string) ($params['q'] ?? '')); + $quoteType = $params['quote_type'] ?? null; $status = $params['status'] ?? null; $productCategory = $params['product_category'] ?? null; $clientId = $params['client_id'] ?? null; @@ -37,14 +39,25 @@ public function index(array $params): LengthAwarePaginator $dateTo = $params['date_to'] ?? null; $sortBy = $params['sort_by'] ?? 'registration_date'; $sortOrder = $params['sort_order'] ?? 'desc'; + $withItems = filter_var($params['with_items'] ?? false, FILTER_VALIDATE_BOOLEAN); $query = Quote::query()->where('tenant_id', $tenantId); + // items 포함 (수주 전환용) + if ($withItems) { + $query->with(['items', 'client:id,name,contact_person,phone']); + } + // 검색어 if ($q !== '') { $query->search($q); } + // 견적 유형 필터 + if ($quoteType) { + $query->where('quote_type', $quoteType); + } + // 상태 필터 if ($status) { $query->where('status', $status); @@ -585,4 +598,95 @@ private function createRevision(Quote $quote, int $userId): QuoteRevision 'previous_data' => $previousData, ]); } + + /** + * 현장설명회에서 견적 Upsert (생성 또는 업데이트) + * + * 참석완료 상태일 때 견적을 자동 생성하거나 업데이트합니다. + * - 견적이 없으면: 견적대기(pending) 상태로 신규 생성 + * - 견적이 있으면: 현장설명회 정보로 업데이트 (거래처, 현장 등) + */ + public function upsertFromSiteBriefing(SiteBriefing $siteBriefing): Quote + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 기존 견적 조회 + $existingQuote = Quote::where('tenant_id', $tenantId) + ->where('site_briefing_id', $siteBriefing->id) + ->first(); + + return DB::transaction(function () use ($siteBriefing, $tenantId, $userId, $existingQuote) { + if ($existingQuote) { + // 기존 견적 업데이트 (현장설명회 정보 동기화) + $existingQuote->update([ + // 발주처 정보 동기화 + 'client_id' => $siteBriefing->partner_id, + 'client_name' => $siteBriefing->partner?->name, + // 현장 정보 동기화 + 'site_id' => $siteBriefing->site_id, + 'site_name' => $siteBriefing->title, + // 감사 + 'updated_by' => $userId, + ]); + + return $existingQuote->refresh(); + } + + // 신규 견적 생성 + $quoteNumber = $this->numberService->generate('SCREEN'); + + return Quote::create([ + 'tenant_id' => $tenantId, + 'quote_type' => Quote::TYPE_CONSTRUCTION, + 'site_briefing_id' => $siteBriefing->id, + 'quote_number' => $quoteNumber, + 'registration_date' => now()->toDateString(), + // 발주처 정보 (현장설명회에서 복사) + 'client_id' => $siteBriefing->partner_id, + 'client_name' => $siteBriefing->partner?->name, + // 현장 정보 (현장설명회에서 복사) + 'site_id' => $siteBriefing->site_id, + 'site_name' => $siteBriefing->title, + // 제품 카테고리 없음 (pending 상태이므로) + 'product_category' => null, + // 금액 정보 (빈 값) + 'material_cost' => 0, + 'labor_cost' => 0, + 'install_cost' => 0, + 'subtotal' => 0, + 'discount_rate' => 0, + 'discount_amount' => 0, + 'total_amount' => 0, + // 상태 관리 (견적대기) + 'status' => Quote::STATUS_PENDING, + 'current_revision' => 0, + 'is_final' => false, + // 비고 (현장설명회 정보 기록) + 'remarks' => "현장설명회 참석완료로 자동생성 (현설번호: {$siteBriefing->briefing_code})", + // 감사 + 'created_by' => $userId, + ]); + }); + } + + /** + * @deprecated Use upsertFromSiteBriefing() instead + */ + public function createFromSiteBriefing(SiteBriefing $siteBriefing): ?Quote + { + return $this->upsertFromSiteBriefing($siteBriefing); + } + + /** + * 현장설명회 ID로 연결된 견적 조회 + */ + public function findBySiteBriefingId(int $siteBriefingId): ?Quote + { + $tenantId = $this->tenantId(); + + return Quote::where('tenant_id', $tenantId) + ->where('site_briefing_id', $siteBriefingId) + ->first(); + } } diff --git a/database/migrations/2026_01_12_210406_add_quote_type_to_quotes_table.php b/database/migrations/2026_01_12_210406_add_quote_type_to_quotes_table.php new file mode 100644 index 0000000..cd7c773 --- /dev/null +++ b/database/migrations/2026_01_12_210406_add_quote_type_to_quotes_table.php @@ -0,0 +1,40 @@ +string('quote_type', 20) + ->default('manufacturing') + ->after('tenant_id') + ->comment('견적 유형: manufacturing(제조), construction(시공)'); + + $table->index('quote_type', 'idx_quotes_quote_type'); + }); + + // 기존 데이터: site_briefing_id가 있으면 construction으로 설정 + DB::table('quotes') + ->whereNotNull('site_briefing_id') + ->update(['quote_type' => 'construction']); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('quotes', function (Blueprint $table) { + $table->dropIndex('idx_quotes_quote_type'); + $table->dropColumn('quote_type'); + }); + } +};