'integer', 'client_group_id' => 'integer', 'purchase_price' => 'decimal:4', 'processing_cost' => 'decimal:4', 'loss_rate' => 'decimal:2', 'margin_rate' => 'decimal:2', 'sales_price' => 'decimal:4', 'rounding_unit' => 'integer', 'effective_from' => 'date', 'effective_to' => 'date', 'is_final' => 'boolean', 'finalized_at' => 'datetime', 'finalized_by' => 'integer', 'created_by' => 'integer', 'updated_by' => 'integer', 'deleted_by' => 'integer', ]; // ───────────────────────────────────────────────────────────── // Relations // ───────────────────────────────────────────────────────────── /** * 고객 그룹 관계 */ public function clientGroup(): BelongsTo { return $this->belongsTo(ClientGroup::class, 'client_group_id'); } /** * 리비전 이력 */ public function revisions(): HasMany { return $this->hasMany(PriceRevision::class, 'price_id'); } // ───────────────────────────────────────────────────────────── // Scopes // ───────────────────────────────────────────────────────────── /** * 특정 품목 필터 */ public function scopeForItem($query, string $itemType, int $itemId) { return $query->where('item_type_code', $itemType) ->where('item_id', $itemId); } /** * 특정 고객그룹 필터 */ public function scopeForClientGroup($query, int $clientGroupId) { return $query->where('client_group_id', $clientGroupId); } /** * 특정 일자에 유효한 단가 필터 */ public function scopeValidAt($query, string $date) { return $query->where('effective_from', '<=', $date) ->where(function ($q) use ($date) { $q->whereNull('effective_to') ->orWhere('effective_to', '>=', $date); }); } /** * 활성 상태 단가 필터 */ public function scopeActive($query) { return $query->whereIn('status', ['active', 'finalized']); } // ───────────────────────────────────────────────────────────── // Business Logic // ───────────────────────────────────────────────────────────── /** * 수정 가능 여부 확인 */ public function canEdit(): bool { return ! $this->is_final; } /** * 확정 가능 여부 확인 */ public function canFinalize(): bool { if ($this->is_final) { return false; } // 필수 필드 확인 if (empty($this->sales_price) && empty($this->purchase_price)) { return false; } return true; } /** * 판매단가 자동 계산 * 공식: (매입단가 + 가공비) * (1 + LOSS율) * (1 + 마진율) */ public function calculateSalesPrice(): ?float { $purchasePrice = (float) ($this->purchase_price ?? 0); $processingCost = (float) ($this->processing_cost ?? 0); $lossRate = (float) ($this->loss_rate ?? 0) / 100; $marginRate = (float) ($this->margin_rate ?? 0) / 100; if ($purchasePrice <= 0) { return null; } $baseCost = $purchasePrice + $processingCost; $withLoss = $baseCost * (1 + $lossRate); $withMargin = $withLoss * (1 + $marginRate); // 반올림 적용 return $this->applyRounding($withMargin); } /** * 반올림 규칙 적용 */ private function applyRounding(float $value): float { $unit = $this->rounding_unit ?? 1; $rule = $this->rounding_rule ?? 'round'; if ($unit <= 0) { $unit = 1; } $result = match ($rule) { 'ceil' => ceil($value / $unit) * $unit, 'floor' => floor($value / $unit) * $unit, default => round($value / $unit) * $unit, }; return (float) $result; } /** * 스냅샷 데이터 생성 (리비전용) */ public function toSnapshot(): array { return [ 'item_type_code' => $this->item_type_code, 'item_id' => $this->item_id, 'client_group_id' => $this->client_group_id, 'purchase_price' => $this->purchase_price, 'processing_cost' => $this->processing_cost, 'loss_rate' => $this->loss_rate, 'margin_rate' => $this->margin_rate, 'sales_price' => $this->sales_price, 'rounding_rule' => $this->rounding_rule, 'rounding_unit' => $this->rounding_unit, 'supplier' => $this->supplier, 'effective_from' => $this->effective_from?->toDateString(), 'effective_to' => $this->effective_to?->toDateString(), 'note' => $this->note, 'status' => $this->status, 'is_final' => $this->is_final, ]; } // ───────────────────────────────────────────────────────────── // Static Query Methods (견적 산출용) // ───────────────────────────────────────────────────────────── /** * 특정 품목의 현재 유효 단가 조회 * * @param int $tenantId 테넌트 ID * @param string $itemTypeCode 품목 유형 (PRODUCT/MATERIAL) * @param int $itemId 품목 ID * @param int|null $clientGroupId 고객 그룹 ID (NULL = 기본가) */ public static function getCurrentPrice( int $tenantId, string $itemTypeCode, int $itemId, ?int $clientGroupId = null ): ?self { $today = now()->toDateString(); $query = static::query() ->where('tenant_id', $tenantId) ->where('item_type_code', $itemTypeCode) ->where('item_id', $itemId) ->whereIn('status', [self::STATUS_ACTIVE, self::STATUS_FINALIZED]) ->where('effective_from', '<=', $today) ->where(function ($q) use ($today) { $q->whereNull('effective_to') ->orWhere('effective_to', '>=', $today); }); // 고객그룹 지정된 가격 우선, 없으면 기본가 if ($clientGroupId) { $groupPrice = (clone $query) ->where('client_group_id', $clientGroupId) ->orderByDesc('effective_from') ->first(); if ($groupPrice) { return $groupPrice; } } // 기본가 (client_group_id = NULL) return $query ->whereNull('client_group_id') ->orderByDesc('effective_from') ->first(); } /** * 품목 코드로 현재 유효 판매단가 조회 * * @param int $tenantId 테넌트 ID * @param string $itemCode 품목 코드 * @return float 판매단가 (없으면 0) */ public static function getSalesPriceByItemCode(int $tenantId, string $itemCode): float { // items 테이블에서 품목 코드로 검색 $item = \Illuminate\Support\Facades\DB::table('items') ->where('tenant_id', $tenantId) ->where('code', $itemCode) ->whereNull('deleted_at') ->first(); if (! $item) { return 0; } $price = static::getCurrentPrice($tenantId, $item->item_type, $item->id); return (float) ($price?->sales_price ?? 0); } }