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; } }