Files
sam-api/app/Models/Quote/Quote.php
권혁성 189b38c936 feat: Auditable 트레이트 구현 및 97개 모델 적용
- 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>
2026-01-29 15:33:54 +09:00

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;
}
}