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:
@@ -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', // 수주 전환용 품목 포함 여부
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 범위 스코프
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user