Files
sam-api/app/Services/PricingService.php
kent 8ab65e18d0 refactor: prices.item_type_code 통합 및 하드코딩 제거
- 레거시 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>
2025-12-21 17:22:51 +09:00

541 lines
16 KiB
PHP

<?php
namespace App\Services;
use App\Models\Materials\MaterialReceipt;
use App\Models\Products\Price;
use App\Models\Products\PriceRevision;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class PricingService extends Service
{
/**
* 단가 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$size = (int) ($params['size'] ?? 20);
$q = trim((string) ($params['q'] ?? ''));
$itemType = $params['item_type_code'] ?? null;
$itemId = $params['item_id'] ?? null;
$clientGroupId = $params['client_group_id'] ?? null;
$status = $params['status'] ?? null;
$validAt = $params['valid_at'] ?? null;
$query = Price::query()
->with(['clientGroup:id,name'])
->where('tenant_id', $tenantId);
// 검색어 필터
if ($q !== '') {
$query->where(function ($w) use ($q) {
$w->where('supplier', 'like', "%{$q}%")
->orWhere('note', 'like', "%{$q}%");
});
}
// 품목 유형 필터
if ($itemType) {
$query->where('item_type_code', $itemType);
}
// 품목 ID 필터
if ($itemId) {
$query->where('item_id', (int) $itemId);
}
// 고객그룹 필터
if ($clientGroupId !== null) {
if ($clientGroupId === 'null' || $clientGroupId === '') {
$query->whereNull('client_group_id');
} else {
$query->where('client_group_id', (int) $clientGroupId);
}
}
// 상태 필터
if ($status) {
$query->where('status', $status);
}
// 특정 일자에 유효한 단가 필터
if ($validAt) {
$query->validAt($validAt);
}
return $query->orderByDesc('id')->paginate($size);
}
/**
* 단가 상세 조회
*/
public function show(int $id): Price
{
$tenantId = $this->tenantId();
$price = Price::query()
->with(['clientGroup:id,name', 'revisions' => function ($q) {
$q->orderByDesc('revision_number')->limit(10);
}])
->where('tenant_id', $tenantId)
->find($id);
if (! $price) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $price;
}
/**
* 단가 등록
*/
public function store(array $data): Price
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
// 중복 체크 (동일 품목+고객그룹+시작일)
$this->checkDuplicate(
$tenantId,
$data['item_type_code'],
(int) $data['item_id'],
$data['client_group_id'] ?? null,
$data['effective_from']
);
// 기존 무기한 단가의 종료일 자동 설정
$this->autoCloseExistingPrice(
$tenantId,
$data['item_type_code'],
(int) $data['item_id'],
$data['client_group_id'] ?? null,
$data['effective_from']
);
$payload = array_merge($data, [
'tenant_id' => $tenantId,
'status' => $data['status'] ?? 'draft',
'is_final' => false,
'created_by' => $userId,
]);
// 판매단가 자동 계산 (sales_price가 없고 필요 데이터가 있을 때)
if (empty($payload['sales_price']) && isset($payload['purchase_price'])) {
$tempPrice = new Price($payload);
$payload['sales_price'] = $tempPrice->calculateSalesPrice();
}
$price = Price::create($payload);
// 최초 리비전 생성
$this->createRevision($price, null, $userId, __('message.pricing.created'));
return $price;
});
}
/**
* 단가 수정
*/
public function update(int $id, array $data): Price
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
$price = Price::query()
->where('tenant_id', $tenantId)
->find($id);
if (! $price) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 확정된 단가는 수정 불가
if (! $price->canEdit()) {
throw new BadRequestHttpException(__('error.pricing.finalized_cannot_edit'));
}
// 변경 전 스냅샷
$beforeSnapshot = $price->toSnapshot();
// 키 변경 시 중복 체크
$itemType = $data['item_type_code'] ?? $price->item_type_code;
$itemId = $data['item_id'] ?? $price->item_id;
$clientGroupId = $data['client_group_id'] ?? $price->client_group_id;
$effectiveFrom = $data['effective_from'] ?? $price->effective_from;
$keyChanged = (
$itemType !== $price->item_type_code ||
$itemId !== $price->item_id ||
$clientGroupId !== $price->client_group_id ||
$effectiveFrom != $price->effective_from
);
if ($keyChanged) {
$this->checkDuplicate($tenantId, $itemType, $itemId, $clientGroupId, $effectiveFrom, $id);
}
$data['updated_by'] = $userId;
$price->update($data);
// 판매단가 재계산 (관련 필드가 변경된 경우)
if ($this->shouldRecalculateSalesPrice($data)) {
$price->sales_price = $price->calculateSalesPrice();
$price->save();
}
$price->refresh();
// 리비전 생성
$changeReason = $data['change_reason'] ?? null;
$this->createRevision($price, $beforeSnapshot, $userId, $changeReason);
return $price;
});
}
/**
* 단가 삭제 (soft delete)
*/
public function destroy(int $id): void
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
DB::transaction(function () use ($id, $tenantId, $userId) {
$price = Price::query()
->where('tenant_id', $tenantId)
->find($id);
if (! $price) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 확정된 단가는 삭제 불가
if ($price->is_final) {
throw new BadRequestHttpException(__('error.pricing.finalized_cannot_delete'));
}
// 삭제 전 스냅샷 저장
$beforeSnapshot = $price->toSnapshot();
$this->createRevision($price, $beforeSnapshot, $userId, __('message.pricing.deleted'));
$price->deleted_by = $userId;
$price->save();
$price->delete();
});
}
/**
* 단가 확정
*/
public function finalize(int $id): Price
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $tenantId, $userId) {
$price = Price::query()
->where('tenant_id', $tenantId)
->find($id);
if (! $price) {
throw new NotFoundHttpException(__('error.not_found'));
}
if (! $price->canFinalize()) {
throw new BadRequestHttpException(__('error.pricing.cannot_finalize'));
}
$beforeSnapshot = $price->toSnapshot();
$price->update([
'is_final' => true,
'status' => 'finalized',
'finalized_at' => now(),
'finalized_by' => $userId,
'updated_by' => $userId,
]);
$price->refresh();
// 확정 리비전 생성
$this->createRevision($price, $beforeSnapshot, $userId, __('message.pricing.finalized'));
return $price;
});
}
/**
* 품목별 단가 현황 조회
* 여러 품목의 현재 유효한 단가를 한번에 조회
*/
public function byItems(array $params): Collection
{
$tenantId = $this->tenantId();
$items = $params['items'] ?? []; // [{item_type_code, item_id}, ...]
$clientGroupId = $params['client_group_id'] ?? null;
$date = $params['date'] ?? now()->toDateString();
if (empty($items)) {
return collect();
}
$results = collect();
foreach ($items as $item) {
$itemType = $item['item_type_code'] ?? null;
$itemId = $item['item_id'] ?? null;
if (! $itemType || ! $itemId) {
continue;
}
// 고객그룹별 단가 우선 조회
$price = null;
if ($clientGroupId) {
$price = Price::query()
->where('tenant_id', $tenantId)
->forItem($itemType, (int) $itemId)
->forClientGroup((int) $clientGroupId)
->validAt($date)
->active()
->orderByDesc('effective_from')
->first();
}
// 기본 단가 fallback
if (! $price) {
$price = Price::query()
->where('tenant_id', $tenantId)
->forItem($itemType, (int) $itemId)
->whereNull('client_group_id')
->validAt($date)
->active()
->orderByDesc('effective_from')
->first();
}
$results->push([
'item_type_code' => $itemType,
'item_id' => $itemId,
'price' => $price,
'has_price' => $price !== null,
]);
}
return $results;
}
/**
* 리비전 이력 조회
*/
public function revisions(int $priceId, array $params = []): LengthAwarePaginator
{
$tenantId = $this->tenantId();
// 단가 존재 확인
$price = Price::query()
->where('tenant_id', $tenantId)
->find($priceId);
if (! $price) {
throw new NotFoundHttpException(__('error.not_found'));
}
$size = (int) ($params['size'] ?? 20);
return PriceRevision::query()
->where('price_id', $priceId)
->with('changedByUser:id,name')
->orderByDesc('revision_number')
->paginate($size);
}
/**
* 원가 조회 (수입검사 > 표준원가 fallback)
*/
public function getCost(array $params): array
{
$tenantId = $this->tenantId();
$itemType = $params['item_type_code'];
$itemId = (int) $params['item_id'];
$date = $params['date'] ?? now()->toDateString();
$result = [
'item_type_code' => $itemType,
'item_id' => $itemId,
'date' => $date,
'cost_source' => 'not_found',
'purchase_price' => null,
'receipt_id' => null,
'receipt_date' => null,
'price_id' => null,
];
// 1순위: 자재(is_material = true)인 경우 수입검사 입고단가 조회
// common_codes의 attributes.is_material 플래그로 자재 여부 판단
$isMaterial = DB::table('common_codes')
->where('code_group', 'item_type')
->where('code', $itemType)
->whereJsonContains('attributes->is_material', true)
->exists();
if ($isMaterial) {
$receipt = MaterialReceipt::query()
->where('material_id', $itemId)
->where('receipt_date', '<=', $date)
->whereNotNull('purchase_price_excl_vat')
->orderByDesc('receipt_date')
->orderByDesc('id')
->first();
if ($receipt && $receipt->purchase_price_excl_vat > 0) {
$result['cost_source'] = 'receipt';
$result['purchase_price'] = (float) $receipt->purchase_price_excl_vat;
$result['receipt_id'] = $receipt->id;
$result['receipt_date'] = $receipt->receipt_date;
return $result;
}
}
// 2순위: 표준원가 (prices 테이블)
$price = Price::query()
->where('tenant_id', $tenantId)
->forItem($itemType, $itemId)
->whereNull('client_group_id')
->validAt($date)
->active()
->orderByDesc('effective_from')
->first();
if ($price && $price->purchase_price > 0) {
$result['cost_source'] = 'standard';
$result['purchase_price'] = (float) $price->purchase_price;
$result['price_id'] = $price->id;
return $result;
}
// 3순위: 미등록
return $result;
}
/**
* 중복 체크
*/
private function checkDuplicate(
int $tenantId,
string $itemType,
int $itemId,
?int $clientGroupId,
string $effectiveFrom,
?int $excludeId = null
): void {
$query = Price::query()
->where('tenant_id', $tenantId)
->where('item_type_code', $itemType)
->where('item_id', $itemId)
->where('effective_from', $effectiveFrom);
if ($clientGroupId) {
$query->where('client_group_id', $clientGroupId);
} else {
$query->whereNull('client_group_id');
}
if ($excludeId) {
$query->where('id', '!=', $excludeId);
}
if ($query->exists()) {
throw new BadRequestHttpException(__('error.duplicate_key'));
}
}
/**
* 기존 무기한 단가의 종료일 자동 설정
*/
private function autoCloseExistingPrice(
int $tenantId,
string $itemType,
int $itemId,
?int $clientGroupId,
string $newEffectiveFrom
): void {
$query = Price::query()
->where('tenant_id', $tenantId)
->where('item_type_code', $itemType)
->where('item_id', $itemId)
->whereNull('effective_to')
->where('effective_from', '<', $newEffectiveFrom);
if ($clientGroupId) {
$query->where('client_group_id', $clientGroupId);
} else {
$query->whereNull('client_group_id');
}
$existingPrice = $query->first();
if ($existingPrice && ! $existingPrice->is_final) {
$newEndDate = Carbon::parse($newEffectiveFrom)->subDay()->toDateString();
$existingPrice->update([
'effective_to' => $newEndDate,
'updated_by' => $this->apiUserId(),
]);
}
}
/**
* 리비전 생성
*/
private function createRevision(Price $price, ?array $beforeSnapshot, int $userId, ?string $reason = null): void
{
// 다음 리비전 번호 계산
$nextRevision = PriceRevision::query()
->where('price_id', $price->id)
->max('revision_number') + 1;
PriceRevision::create([
'tenant_id' => $price->tenant_id,
'price_id' => $price->id,
'revision_number' => $nextRevision,
'changed_at' => now(),
'changed_by' => $userId,
'change_reason' => $reason,
'before_snapshot' => $beforeSnapshot,
'after_snapshot' => $price->toSnapshot(),
]);
}
/**
* 판매단가 재계산이 필요한지 확인
*/
private function shouldRecalculateSalesPrice(array $data): bool
{
$recalcFields = ['purchase_price', 'processing_cost', 'loss_rate', 'margin_rate', 'rounding_rule', 'rounding_unit'];
foreach ($recalcFields as $field) {
if (array_key_exists($field, $data)) {
return true;
}
}
return false;
}
}