- 레거시 PRODUCT/MATERIAL 값을 실제 item_type(FG, PT 등)으로 마이그레이션 - Price 모델에서 하드코딩된 ITEM_TYPE_* 상수 제거 - PricingService.getCost()에서 하드코딩된 자재 유형 배열을 common_codes.attributes.is_material 플래그 조회로 변경 - common_codes item_type 그룹에 is_material 플래그 추가 - FG, PT: is_material = false (제품) - SM, RM, CS: is_material = true (자재) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
313 lines
10 KiB
PHP
313 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Models\Products;
|
|
|
|
use App\Models\Orders\ClientGroup;
|
|
use App\Traits\BelongsToTenant;
|
|
use App\Traits\ModelTrait;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
|
|
class Price extends Model
|
|
{
|
|
use BelongsToTenant, ModelTrait, SoftDeletes;
|
|
|
|
protected $table = 'prices';
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Constants
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
// 상태
|
|
public const STATUS_DRAFT = 'draft';
|
|
|
|
public const STATUS_ACTIVE = 'active';
|
|
|
|
public const STATUS_INACTIVE = 'inactive';
|
|
|
|
public const STATUS_FINALIZED = 'finalized';
|
|
|
|
// 품목 유형은 common_codes 테이블의 code_group='item_type'에서 관리
|
|
// FG(완제품), PT(부품), RM(원자재), SM(반제품), CS(소모품)
|
|
|
|
protected $fillable = [
|
|
'tenant_id',
|
|
'item_type_code',
|
|
'item_id',
|
|
'client_group_id',
|
|
'purchase_price',
|
|
'processing_cost',
|
|
'loss_rate',
|
|
'margin_rate',
|
|
'sales_price',
|
|
'rounding_rule',
|
|
'rounding_unit',
|
|
'supplier',
|
|
'effective_from',
|
|
'effective_to',
|
|
'note',
|
|
'status',
|
|
'is_final',
|
|
'finalized_at',
|
|
'finalized_by',
|
|
'created_by',
|
|
'updated_by',
|
|
'deleted_by',
|
|
];
|
|
|
|
protected $casts = [
|
|
'item_id' => '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);
|
|
}
|
|
}
|