- Auditable 트레이트 신규 생성 (bootAuditable 패턴) - creating: created_by/updated_by 자동 채우기 - updating: updated_by 자동 채우기 - deleting: deleted_by 채우기 + saveQuietly() - created/updated/deleted: audit_logs 자동 기록 - 기존 AuditLogger 패턴과 동일한 try/catch 조용한 실패 - 변경된 필드만 before/after 기록 (updated 이벤트) - auditExclude 프로퍼티로 모델별 제외 필드 설정 가능 - 제외 대상: Attendance, StockTransaction, TodayIssue 등 고빈도/시스템 모델 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
365 lines
8.5 KiB
PHP
365 lines
8.5 KiB
PHP
<?php
|
|
|
|
namespace App\Models\Quote;
|
|
|
|
use App\Models\Items\Item;
|
|
use App\Models\Members\User;
|
|
use App\Models\Orders\Client;
|
|
use App\Models\Orders\Order;
|
|
use App\Models\Tenants\SiteBriefing;
|
|
use App\Traits\Auditable;
|
|
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 Auditable, BelongsToTenant, HasFactory, SoftDeletes;
|
|
|
|
protected $fillable = [
|
|
'tenant_id',
|
|
'quote_type',
|
|
'order_id',
|
|
'site_briefing_id',
|
|
'quote_number',
|
|
'registration_date',
|
|
'receipt_date',
|
|
'author',
|
|
// 발주처 정보
|
|
'client_id',
|
|
'client_name',
|
|
'manager',
|
|
'contact',
|
|
// 현장 정보
|
|
'site_id',
|
|
'site_name',
|
|
'site_code',
|
|
// 제품 정보
|
|
'product_category',
|
|
'item_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',
|
|
// 견적 옵션 (summary_items, expense_items, price_adjustments)
|
|
'options',
|
|
// 감사
|
|
'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',
|
|
'options' => '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 TYPE_MANUFACTURING = 'manufacturing';
|
|
|
|
public const TYPE_CONSTRUCTION = 'construction';
|
|
|
|
public const TYPES = [
|
|
self::TYPE_MANUFACTURING,
|
|
self::TYPE_CONSTRUCTION,
|
|
];
|
|
|
|
/**
|
|
* 제품 카테고리 상수
|
|
*/
|
|
public const CATEGORY_SCREEN = 'SCREEN';
|
|
|
|
public const CATEGORY_STEEL = 'STEEL';
|
|
|
|
/**
|
|
* 상태 상수
|
|
*/
|
|
public const STATUS_PENDING = 'pending'; // 견적대기 (현장설명회에서 자동생성)
|
|
|
|
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 const STATUSES = [
|
|
self::STATUS_PENDING,
|
|
self::STATUS_DRAFT,
|
|
self::STATUS_SENT,
|
|
self::STATUS_APPROVED,
|
|
self::STATUS_REJECTED,
|
|
self::STATUS_FINALIZED,
|
|
self::STATUS_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);
|
|
}
|
|
|
|
/**
|
|
* 품목 (통합 items 테이블)
|
|
*/
|
|
public function item(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Item::class, 'item_id');
|
|
}
|
|
|
|
/**
|
|
* 전환된 수주 (Quote.order_id 기준)
|
|
*/
|
|
public function order(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Order::class);
|
|
}
|
|
|
|
/**
|
|
* 이 견적을 참조하는 수주들 (Order.quote_id 기준)
|
|
* 수주 전환 여부 확인 시 사용
|
|
*/
|
|
public function orders(): HasMany
|
|
{
|
|
return $this->hasMany(Order::class, 'quote_id');
|
|
}
|
|
|
|
/**
|
|
* 현장설명회 (자동생성 시 연결)
|
|
*/
|
|
public function siteBriefing(): BelongsTo
|
|
{
|
|
return $this->belongsTo(SiteBriefing::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 scopePending($query)
|
|
{
|
|
return $query->where('status', self::STATUS_PENDING);
|
|
}
|
|
|
|
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 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);
|
|
}
|
|
|
|
/**
|
|
* 날짜 범위 스코프
|
|
*/
|
|
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;
|
|
}
|
|
}
|