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 마이그레이션 및 데이터 이관
This commit is contained in:
533
app/Services/PricingService.php
Normal file
533
app/Services/PricingService.php
Normal file
@@ -0,0 +1,533 @@
|
||||
<?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순위: 자재인 경우 수입검사 입고단가 조회
|
||||
if ($itemType === 'MATERIAL') {
|
||||
$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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user