Files
sam-api/app/Models/Products/Price.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

314 lines
10 KiB
PHP

<?php
namespace App\Models\Products;
use App\Models\Orders\ClientGroup;
use App\Traits\Auditable;
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 Auditable, 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);
}
}