- GET /pricing/stats: 단가 통계 조회 (total, draft, finalized, expired) - DELETE /pricing/bulk: 단가 일괄 삭제 (확정된 단가 제외) - PriceBulkDeleteRequest FormRequest 추가 - PricingService.stats(), bulkDestroy() 메서드 구현 Co-Authored-By: Claude <noreply@anthropic.com>
602 lines
18 KiB
PHP
602 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Items\ItemReceipt;
|
|
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 = ItemReceipt::query()
|
|
->forItem($itemId)
|
|
->beforeDate($date)
|
|
->withPrice()
|
|
->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;
|
|
}
|
|
|
|
/**
|
|
* 단가 통계 조회
|
|
*/
|
|
public function stats(): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$baseQuery = Price::where('tenant_id', $tenantId);
|
|
|
|
$total = (clone $baseQuery)->count();
|
|
$draft = (clone $baseQuery)->where('status', 'draft')->count();
|
|
$finalized = (clone $baseQuery)->where('status', 'finalized')->count();
|
|
$expired = (clone $baseQuery)->where('status', 'expired')->count();
|
|
|
|
return [
|
|
'total' => $total,
|
|
'draft' => $draft,
|
|
'finalized' => $finalized,
|
|
'expired' => $expired,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 단가 일괄 삭제 (soft delete)
|
|
*/
|
|
public function bulkDestroy(array $ids): int
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($ids, $tenantId, $userId) {
|
|
$prices = Price::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('id', $ids)
|
|
->get();
|
|
|
|
if ($prices->isEmpty()) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$deletedCount = 0;
|
|
foreach ($prices as $price) {
|
|
// 확정된 단가는 삭제 불가
|
|
if ($price->is_final) {
|
|
continue;
|
|
}
|
|
|
|
// 삭제 전 스냅샷 저장
|
|
$beforeSnapshot = $price->toSnapshot();
|
|
$this->createRevision($price, $beforeSnapshot, $userId, __('message.pricing.deleted'));
|
|
|
|
$price->deleted_by = $userId;
|
|
$price->save();
|
|
$price->delete();
|
|
$deletedCount++;
|
|
}
|
|
|
|
return $deletedCount;
|
|
});
|
|
}
|
|
}
|