Files
sam-api/app/Models/Products/Price.php
hskwon 8d3ea4bb39 feat: 단가 관리 API 구현 및 Flow Tester 호환성 개선
- Price, PriceRevision 모델 추가 (PriceHistory 대체)
- PricingService: CRUD, 원가 조회, 확정 기능
- PricingController: statusCode 파라미터로 201 반환 지원
- NotFoundHttpException(404) 적용 (존재하지 않는 리소스)
- FormRequest 분리 (Store, Update, Index, Cost, ByItems)
- Swagger 문서 업데이트
- ApiResponse::handle()에 statusCode 옵션 추가
- prices/price_revisions 마이그레이션 및 데이터 이관
2025-12-08 19:03:50 +09:00

259 lines
6.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Models\Products;
use App\Models\Materials\Material;
use App\Models\Orders\ClientGroup;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 단가 마스터 모델
*
* @property int $id
* @property int $tenant_id
* @property string $item_type_code
* @property int $item_id
* @property int|null $client_group_id
* @property float|null $purchase_price
* @property float|null $processing_cost
* @property float|null $loss_rate
* @property float|null $margin_rate
* @property float|null $sales_price
* @property string $rounding_rule
* @property int $rounding_unit
* @property string|null $supplier
* @property \Carbon\Carbon $effective_from
* @property \Carbon\Carbon|null $effective_to
* @property string|null $note
* @property string $status
* @property bool $is_final
* @property \Carbon\Carbon|null $finalized_at
* @property int|null $finalized_by
*/
class Price extends Model
{
use BelongsToTenant, SoftDeletes;
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 = [
'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',
];
/**
* 고객 그룹 관계
*/
public function clientGroup(): BelongsTo
{
return $this->belongsTo(ClientGroup::class, 'client_group_id');
}
/**
* 리비전 이력 관계
*/
public function revisions(): HasMany
{
return $this->hasMany(PriceRevision::class, 'price_id')->orderBy('revision_number', 'desc');
}
/**
* 품목 관계 (Polymorphic - item_type_code에 따라)
*/
public function item()
{
if ($this->item_type_code === 'PRODUCT') {
return $this->belongsTo(Product::class, 'item_id');
} elseif ($this->item_type_code === 'MATERIAL') {
return $this->belongsTo(Material::class, 'item_id');
}
return null;
}
/**
* 제품 관계 (item_type_code = PRODUCT인 경우)
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class, 'item_id');
}
/**
* 자재 관계 (item_type_code = MATERIAL인 경우)
*/
public function material(): BelongsTo
{
return $this->belongsTo(Material::class, 'item_id');
}
// ========== 스코프 ==========
/**
* 특정 품목 필터
*/
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, $date)
{
return $query->where('effective_from', '<=', $date)
->where(function ($q) use ($date) {
$q->whereNull('effective_to')
->orWhere('effective_to', '>=', $date);
});
}
/**
* 상태 필터
*/
public function scopeStatus($query, string $status)
{
return $query->where('status', $status);
}
/**
* 활성 단가만
*/
public function scopeActive($query)
{
return $query->where('status', 'active');
}
/**
* 확정된 단가만
*/
public function scopeFinalized($query)
{
return $query->where('is_final', true);
}
// ========== 계산 메서드 ==========
/**
* 총원가 계산
* 총원가 = (매입단가 + 가공비) × (1 + LOSS율/100)
*/
public function calculateTotalCost(): float
{
$baseCost = ($this->purchase_price ?? 0) + ($this->processing_cost ?? 0);
$lossMultiplier = 1 + (($this->loss_rate ?? 0) / 100);
return $baseCost * $lossMultiplier;
}
/**
* 판매단가 계산 (마진율 기반)
* 판매단가 = 반올림(총원가 × (1 + 마진율/100), 반올림단위, 반올림규칙)
*/
public function calculateSalesPrice(): float
{
$totalCost = $this->calculateTotalCost();
$marginMultiplier = 1 + (($this->margin_rate ?? 0) / 100);
$rawPrice = $totalCost * $marginMultiplier;
return $this->applyRounding($rawPrice);
}
/**
* 반올림 적용
*/
private function applyRounding(float $value): float
{
$unit = $this->rounding_unit ?: 1;
return match ($this->rounding_rule) {
'ceil' => ceil($value / $unit) * $unit,
'floor' => floor($value / $unit) * $unit,
default => round($value / $unit) * $unit, // 'round'
};
}
/**
* 확정 가능 여부
*/
public function canFinalize(): bool
{
return ! $this->is_final && in_array($this->status, ['draft', 'active']);
}
/**
* 수정 가능 여부
*/
public function canEdit(): bool
{
return ! $this->is_final;
}
/**
* 스냅샷 생성 (리비전용)
*/
public function toSnapshot(): array
{
return [
'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?->format('Y-m-d'),
'effective_to' => $this->effective_to?->format('Y-m-d'),
'status' => $this->status,
'is_final' => $this->is_final,
'note' => $this->note,
];
}
}