From a1980adb2009ab3585a4eab166e10a5bbc11ddf4 Mon Sep 17 00:00:00 2001 From: hskwon Date: Wed, 17 Dec 2025 20:57:38 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Price,=20PriceRevision=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Price 모델 생성 (prices 테이블) - PriceRevision 모델 생성 (price_revisions 테이블) - 레거시 Pricing/PricingService 삭제 (PriceHistory 사용) - pricing 에러/메시지 추가 --- app/Models/Products/Price.php | 221 ++++++++++++++++++ app/Models/Products/PriceRevision.php | 60 +++++ app/Services/Pricing/PricingService.php | 285 ------------------------ lang/ko/error.php | 7 + lang/ko/message.php | 8 + 5 files changed, 296 insertions(+), 285 deletions(-) create mode 100644 app/Models/Products/Price.php create mode 100644 app/Models/Products/PriceRevision.php delete mode 100644 app/Services/Pricing/PricingService.php diff --git a/app/Models/Products/Price.php b/app/Models/Products/Price.php new file mode 100644 index 0000000..b1fd4a8 --- /dev/null +++ b/app/Models/Products/Price.php @@ -0,0 +1,221 @@ + '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, + ]; + } +} diff --git a/app/Models/Products/PriceRevision.php b/app/Models/Products/PriceRevision.php new file mode 100644 index 0000000..6740cf7 --- /dev/null +++ b/app/Models/Products/PriceRevision.php @@ -0,0 +1,60 @@ + 'integer', + 'revision_number' => 'integer', + 'changed_at' => 'datetime', + 'changed_by' => 'integer', + 'before_snapshot' => 'array', + 'after_snapshot' => 'array', + ]; + + // ───────────────────────────────────────────────────────────── + // Relations + // ───────────────────────────────────────────────────────────── + + /** + * 단가 관계 + */ + public function price(): BelongsTo + { + return $this->belongsTo(Price::class, 'price_id'); + } + + /** + * 변경자 관계 + */ + public function changedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'changed_by'); + } +} diff --git a/app/Services/Pricing/PricingService.php b/app/Services/Pricing/PricingService.php deleted file mode 100644 index 6168e9b..0000000 --- a/app/Services/Pricing/PricingService.php +++ /dev/null @@ -1,285 +0,0 @@ - float|null, 'price_history_id' => int|null, 'client_group_id' => int|null, 'warning' => string|null] - */ - public function getItemPrice(string $itemType, int $itemId, ?int $clientId = null, ?string $date = null): array - { - $date = $date ?? Carbon::today()->format('Y-m-d'); - $clientGroupId = null; - - // 1. 고객의 그룹 ID 확인 - if ($clientId) { - $client = Client::where('tenant_id', $this->tenantId()) - ->where('id', $clientId) - ->first(); - - if ($client) { - $clientGroupId = $client->client_group_id; - } - } - - // 2. 가격 조회 (우선순위대로) - $priceHistory = null; - - // 1순위: 고객 그룹별 매출단가 - if ($clientGroupId) { - $priceHistory = $this->findPrice($itemType, $itemId, $clientGroupId, $date); - } - - // 2순위: 기본 매출단가 (client_group_id = NULL) - if (! $priceHistory) { - $priceHistory = $this->findPrice($itemType, $itemId, null, $date); - } - - // 3순위: NULL (경고) - if (! $priceHistory) { - return [ - 'price' => null, - 'price_history_id' => null, - 'client_group_id' => null, - 'warning' => __('error.price_not_found', [ - 'item_type' => $itemType, - 'item_id' => $itemId, - 'date' => $date, - ]), - ]; - } - - return [ - 'price' => (float) $priceHistory->price, - 'price_history_id' => $priceHistory->id, - 'client_group_id' => $priceHistory->client_group_id, - 'warning' => null, - ]; - } - - /** - * 특정 항목의 가격 타입별 단가 조회 - * - * @param string $itemType 'PRODUCT' | 'MATERIAL' - * @param int $itemId 제품/자재 ID - * @param string $priceType 'SALE' | 'PURCHASE' - * @param int|null $clientId 고객 ID (NULL이면 기본 가격) - * @param string|null $date 기준일 (NULL이면 오늘) - * @return array ['price' => float|null, 'price_history_id' => int|null, 'client_group_id' => int|null, 'warning' => string|null] - */ - public function getPriceByType( - string $itemType, - int $itemId, - string $priceType, - ?int $clientId = null, - ?string $date = null - ): array { - $date = $date ?? Carbon::today()->format('Y-m-d'); - $clientGroupId = null; - - // 1. 고객의 그룹 ID 확인 - if ($clientId) { - $client = Client::where('tenant_id', $this->tenantId()) - ->where('id', $clientId) - ->first(); - - if ($client) { - $clientGroupId = $client->client_group_id; - } - } - - // 2. 가격 조회 (우선순위대로) - $priceHistory = null; - - // 1순위: 고객 그룹별 가격 - if ($clientGroupId) { - $priceHistory = $this->findPriceByType($itemType, $itemId, $priceType, $clientGroupId, $date); - } - - // 2순위: 기본 가격 (client_group_id = NULL) - if (! $priceHistory) { - $priceHistory = $this->findPriceByType($itemType, $itemId, $priceType, null, $date); - } - - // 3순위: NULL (경고) - if (! $priceHistory) { - return [ - 'price' => null, - 'price_history_id' => null, - 'client_group_id' => null, - 'warning' => __('error.price_not_found', [ - 'item_type' => $itemType, - 'item_id' => $itemId, - 'price_type' => $priceType, - 'date' => $date, - ]), - ]; - } - - return [ - 'price' => (float) $priceHistory->price, - 'price_history_id' => $priceHistory->id, - 'client_group_id' => $priceHistory->client_group_id, - 'warning' => null, - ]; - } - - /** - * 가격 이력에서 유효한 가격 조회 (SALE 타입 기본) - */ - private function findPrice(string $itemType, int $itemId, ?int $clientGroupId, string $date): ?PriceHistory - { - return $this->findPriceByType($itemType, $itemId, 'SALE', $clientGroupId, $date); - } - - /** - * 가격 이력에서 유효한 가격 조회 (가격 타입 지정) - * - * @param string $priceType 'SALE' | 'PURCHASE' - */ - private function findPriceByType( - string $itemType, - int $itemId, - string $priceType, - ?int $clientGroupId, - string $date - ): ?PriceHistory { - $query = PriceHistory::where('tenant_id', $this->tenantId()) - ->forItem($itemType, $itemId) - ->forClientGroup($clientGroupId); - - // 가격 타입에 따라 스코프 적용 - if ($priceType === 'PURCHASE') { - $query->purchasePrice(); - } else { - $query->salePrice(); - } - - return $query->validAt($date) - ->orderBy('started_at', 'desc') - ->first(); - } - - /** - * 여러 항목의 단가를 일괄 조회 - * - * @param array $items [['item_type' => 'PRODUCT', 'item_id' => 1], ...] - * @return array ['prices' => [...], 'warnings' => [...]] - */ - public function getBulkItemPrices(array $items, ?int $clientId = null, ?string $date = null): array - { - $prices = []; - $warnings = []; - - foreach ($items as $item) { - $result = $this->getItemPrice( - $item['item_type'], - $item['item_id'], - $clientId, - $date - ); - - $prices[] = array_merge($item, [ - 'price' => $result['price'], - 'price_history_id' => $result['price_history_id'], - 'client_group_id' => $result['client_group_id'], - ]); - - if ($result['warning']) { - $warnings[] = $result['warning']; - } - } - - return [ - 'prices' => $prices, - 'warnings' => $warnings, - ]; - } - - /** - * 가격 등록/수정 - */ - public function upsertPrice(array $data): PriceHistory - { - $data['tenant_id'] = $this->tenantId(); - $data['created_by'] = $this->apiUserId(); - $data['updated_by'] = $this->apiUserId(); - - // 중복 확인: 동일 조건(item, client_group, date 범위)의 가격이 이미 있는지 - $existing = PriceHistory::where('tenant_id', $data['tenant_id']) - ->where('item_type_code', $data['item_type_code']) - ->where('item_id', $data['item_id']) - ->where('price_type_code', $data['price_type_code']) - ->where('client_group_id', $data['client_group_id'] ?? null) - ->where('started_at', $data['started_at']) - ->first(); - - if ($existing) { - $existing->update($data); - - return $existing->fresh(); - } - - return PriceHistory::create($data); - } - - /** - * 가격 이력 조회 (페이지네이션) - * - * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator - */ - public function listPrices(array $filters = [], int $perPage = 15) - { - $query = PriceHistory::where('tenant_id', $this->tenantId()); - - if (isset($filters['item_type_code'])) { - $query->where('item_type_code', $filters['item_type_code']); - } - - if (isset($filters['item_id'])) { - $query->where('item_id', $filters['item_id']); - } - - if (isset($filters['price_type_code'])) { - $query->where('price_type_code', $filters['price_type_code']); - } - - if (isset($filters['client_group_id'])) { - $query->where('client_group_id', $filters['client_group_id']); - } - - if (isset($filters['date'])) { - $query->validAt($filters['date']); - } - - return $query->orderBy('started_at', 'desc') - ->orderBy('created_at', 'desc') - ->paginate($perPage); - } - - /** - * 가격 삭제 (Soft Delete) - */ - public function deletePrice(int $id): bool - { - $price = PriceHistory::where('tenant_id', $this->tenantId()) - ->findOrFail($id); - - $price->deleted_by = $this->apiUserId(); - $price->save(); - - return $price->delete(); - } -} diff --git a/lang/ko/error.php b/lang/ko/error.php index aeb2db4..0dfbcea 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -131,6 +131,13 @@ 'self_reference_bom' => 'BOM에 자기 자신을 포함할 수 없습니다.', ], + // 단가 관리 관련 + 'pricing' => [ + 'finalized_cannot_edit' => '확정된 단가는 수정할 수 없습니다.', + 'finalized_cannot_delete' => '확정된 단가는 삭제할 수 없습니다.', + 'cannot_finalize' => '확정할 수 없는 상태입니다.', + ], + // 잠금 관련 'relationship_locked' => '잠금된 연결은 해제할 수 없습니다.', 'has_locked_relationships' => '잠금된 연결이 포함되어 있어 처리할 수 없습니다.', diff --git a/lang/ko/message.php b/lang/ko/message.php index b7b837a..0dae961 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -122,6 +122,14 @@ 'deleted' => '자재가 삭제되었습니다.', ], + // 단가 관리 + 'pricing' => [ + 'created' => '단가가 등록되었습니다.', + 'updated' => '단가가 수정되었습니다.', + 'deleted' => '단가가 삭제되었습니다.', + 'finalized' => '단가가 확정되었습니다.', + ], + // 거래처 관리 'client' => [ 'fetched' => '거래처를 조회했습니다.',