feat(quote): quote_type 컬럼 추가 및 건설 견적 필터링 구현

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-01-13 09:55:22 +09:00
parent 3a40db9444
commit 84ad9e1fc4
4 changed files with 218 additions and 0 deletions

View File

@@ -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', // 수주 전환용 품목 포함 여부
];
}
}

View File

@@ -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);
}
/**
* 날짜 범위 스코프
*/

View File

@@ -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();
}
}