feat: Items API item_type 기반 통합 조회/삭제 개선

- ItemTypeHelper를 활용한 item_type(FG/PT/SM/RM/CS) → source_table 매핑
- getItem: item_type 파라미터로 products/materials 테이블 자동 결정
- deleteItem: item_type 필수 파라미터 추가
- batchDeleteItems: item_type별 일괄 삭제 지원
- 목록 조회 시 attributes 플랫 전개
- Swagger 문서 업데이트
This commit is contained in:
2025-12-09 21:51:46 +09:00
parent 9d5f0ba4ca
commit cde89b2fb3
7 changed files with 367 additions and 93 deletions

View File

@@ -2,6 +2,7 @@
namespace App\Services;
use App\Helpers\ItemTypeHelper;
use App\Models\Materials\Material;
use App\Models\Products\Product;
use Illuminate\Support\Facades\DB;
@@ -46,12 +47,14 @@ public function getItems(array $filters = [], int $perPage = 20, bool $includeDe
->where('is_active', 1)
->select([
'id',
DB::raw("'PRODUCT' as item_type"),
'product_type as item_type',
'code',
'name',
DB::raw('NULL as specification'),
'unit',
'category_id',
'product_type as type_code',
'attributes',
'created_at',
'deleted_at',
]);
@@ -83,12 +86,14 @@ public function getItems(array $filters = [], int $perPage = 20, bool $includeDe
->whereIn('material_type', $materialTypes)
->select([
'id',
DB::raw("'MATERIAL' as item_type"),
'material_type as item_type',
'material_code as code',
'name',
'specification',
'unit',
'category_id',
'material_type as type_code',
'attributes',
'created_at',
'deleted_at',
]);
@@ -127,9 +132,21 @@ public function getItems(array $filters = [], int $perPage = 20, bool $includeDe
$items = $items->merge($materials);
}
// 정렬 (name 기준 오름차순)
// 정렬 (created_at 기준 내림차순)
$items = $items->sortByDesc('created_at')->values();
// attributes 플랫 전개
$items = $items->map(function ($item) {
$data = $item->toArray();
$attributes = $data['attributes'] ?? [];
if (is_string($attributes)) {
$attributes = json_decode($attributes, true) ?? [];
}
unset($data['attributes']);
return array_merge($data, $attributes);
});
// 페이지네이션 처리
$page = request()->input('page', 1);
$offset = ($page - 1) * $perPage;
@@ -149,46 +166,27 @@ public function getItems(array $filters = [], int $perPage = 20, bool $includeDe
/**
* 단일 품목 조회
*
* @param string $itemType 'PRODUCT' | 'MATERIAL'
* @param int $id 품목 ID
* @param string $itemType 품목 유형 코드 (FG/PT/SM/RM/CS)
* @param bool $includePrice 가격 정보 포함 여부
* @param int|null $clientId 고객 ID (가격 조회 시)
* @param string|null $priceDate 기준일 (가격 조회 시)
* @return array 품목 데이터 (item_type, prices 포함)
*/
public function getItem(
string $itemType,
int $id,
string $itemType = 'FG',
bool $includePrice = false,
?int $clientId = null,
?string $priceDate = null
): array {
$tenantId = $this->tenantId();
$itemType = strtoupper($itemType);
if ($itemType === 'PRODUCT') {
$product = Product::query()
->with('category:id,name')
->where('tenant_id', $tenantId)
->find($id);
if (! $product) {
throw new NotFoundHttpException(__('error.not_found'));
}
$data = $product->toArray();
$data['item_type'] = 'PRODUCT';
$data['type_code'] = $product->product_type;
// 가격 정보 추가
if ($includePrice) {
$data['prices'] = $this->fetchPrices($itemType, $id, $clientId, $priceDate);
}
return $data;
} elseif ($itemType === 'MATERIAL') {
// item_type으로 source_table 결정
if (ItemTypeHelper::isMaterial($itemType, $tenantId)) {
$material = Material::query()
->with('category:id,name')
->where('tenant_id', $tenantId)
->find($id);
@@ -196,20 +194,53 @@ public function getItem(
throw new NotFoundHttpException(__('error.not_found'));
}
$data = $material->toArray();
$data['item_type'] = 'MATERIAL';
$data = $this->flattenAttributes($material->toArray());
$data['item_type'] = $itemType;
$data['code'] = $material->material_code;
$data['type_code'] = $material->material_type;
// 가격 정보 추가
if ($includePrice) {
$data['prices'] = $this->fetchPrices($itemType, $id, $clientId, $priceDate);
$data['prices'] = $this->fetchPrices('MATERIAL', $id, $clientId, $priceDate);
}
return $data;
} else {
throw new \InvalidArgumentException(__('error.invalid_item_type'));
}
// Product (FG, PT)
$product = Product::query()
->with('category:id,name')
->where('tenant_id', $tenantId)
->find($id);
if (! $product) {
throw new NotFoundHttpException(__('error.not_found'));
}
$data = $this->flattenAttributes($product->toArray());
$data['item_type'] = $itemType;
$data['type_code'] = $product->product_type;
// 가격 정보 추가
if ($includePrice) {
$data['prices'] = $this->fetchPrices('PRODUCT', $id, $clientId, $priceDate);
}
return $data;
}
/**
* attributes JSON 필드를 최상위로 플랫 전개
*/
private function flattenAttributes(array $data): array
{
$attributes = $data['attributes'] ?? [];
if (is_string($attributes)) {
$attributes = json_decode($attributes, true) ?? [];
}
unset($data['attributes']);
return array_merge($data, $attributes);
}
/**
@@ -406,12 +437,27 @@ private function resolveUniqueCode(string $code, int $tenantId): string
}
/**
* 품목 수정 (Product 전용)
* 품목 수정 (Product/Material 통합)
*
* @param int $id 품목 ID
* @param array $data 검증된 데이터
* @param array $data 검증된 데이터 (item_type 필수)
*/
public function updateItem(int $id, array $data): Product
public function updateItem(int $id, array $data): Product|Material
{
$tenantId = $this->tenantId();
$itemType = strtoupper($data['item_type'] ?? 'FG');
if (ItemTypeHelper::isMaterial($itemType, $tenantId)) {
return $this->updateMaterial($id, $data);
}
return $this->updateProduct($id, $data);
}
/**
* Product 수정 (FG, PT)
*/
private function updateProduct(int $id, array $data): Product
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
@@ -437,6 +483,8 @@ public function updateItem(int $id, array $data): Product
}
}
// item_type은 DB 필드가 아니므로 제거
unset($data['item_type']);
$data['updated_by'] = $userId;
$product->update($data);
@@ -444,14 +492,71 @@ public function updateItem(int $id, array $data): Product
}
/**
* 품목 삭제 (Product 전용, Soft Delete)
* Material 수정 (SM, RM, CS)
*/
private function updateMaterial(int $id, array $data): Material
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$material = Material::query()
->where('tenant_id', $tenantId)
->find($id);
if (! $material) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 코드 변경 시 중복 체크
$newCode = $data['material_code'] ?? $data['code'] ?? null;
if ($newCode && $newCode !== $material->material_code) {
$exists = Material::query()
->where('tenant_id', $tenantId)
->where('material_code', $newCode)
->where('id', '!=', $material->id)
->exists();
if ($exists) {
throw new BadRequestHttpException(__('error.duplicate_code'));
}
$data['material_code'] = $newCode;
}
// item_type, code는 DB 필드가 아니므로 제거
unset($data['item_type'], $data['code']);
$data['updated_by'] = $userId;
$material->update($data);
return $material->refresh();
}
/**
* 품목 삭제 (Product/Material 통합, Soft Delete)
*
* - 이미 삭제된 품목은 404 반환
* - 다른 BOM의 구성품으로 사용 중이면 삭제 불가
*
* @param int $id 품목 ID
* @param string $itemType 품목 유형 코드 (FG/PT/SM/RM/CS)
*/
public function deleteItem(int $id): void
public function deleteItem(int $id, string $itemType = 'FG'): void
{
$tenantId = $this->tenantId();
$itemType = strtoupper($itemType);
if (ItemTypeHelper::isMaterial($itemType, $tenantId)) {
$this->deleteMaterial($id);
return;
}
$this->deleteProduct($id);
}
/**
* Product 삭제 (FG, PT)
*/
private function deleteProduct(int $id): void
{
$tenantId = $this->tenantId();
@@ -472,6 +577,30 @@ public function deleteItem(int $id): void
$product->delete();
}
/**
* Material 삭제 (SM, RM, CS)
*/
private function deleteMaterial(int $id): void
{
$tenantId = $this->tenantId();
$material = Material::query()
->where('tenant_id', $tenantId)
->find($id);
if (! $material) {
throw new NotFoundHttpException(__('error.item.not_found'));
}
// BOM 구성품으로 사용 중인지 체크
$usageCount = $this->checkMaterialUsageInBom($tenantId, $id);
if ($usageCount > 0) {
throw new BadRequestHttpException(__('error.item.in_use_as_bom_component', ['count' => $usageCount]));
}
$material->delete();
}
/**
* Product가 다른 BOM의 구성품으로 사용 중인지 체크
*
@@ -489,13 +618,47 @@ private function checkProductUsageInBom(int $tenantId, int $productId): int
}
/**
* 품목 일괄 삭제 (Product 전용, Soft Delete)
* Material이 다른 BOM의 구성품으로 사용 중인지 체크
*
* @param int $tenantId 테넌트 ID
* @param int $materialId 자재 ID
* @return int 사용 건수
*/
private function checkMaterialUsageInBom(int $tenantId, int $materialId): int
{
return \App\Models\Products\ProductComponent::query()
->where('tenant_id', $tenantId)
->where('ref_type', 'MATERIAL')
->where('ref_id', $materialId)
->count();
}
/**
* 품목 일괄 삭제 (Product/Material 통합, Soft Delete)
*
* - 다른 BOM의 구성품으로 사용 중인 품목은 삭제 불가
*
* @param array $ids 품목 ID 배열
* @param string $itemType 품목 유형 코드 (FG/PT/SM/RM/CS)
*/
public function batchDeleteItems(array $ids): void
public function batchDeleteItems(array $ids, string $itemType = 'FG'): void
{
$tenantId = $this->tenantId();
$itemType = strtoupper($itemType);
if (ItemTypeHelper::isMaterial($itemType, $tenantId)) {
$this->batchDeleteMaterials($ids);
return;
}
$this->batchDeleteProducts($ids);
}
/**
* Product 일괄 삭제 (FG, PT)
*/
private function batchDeleteProducts(array $ids): void
{
$tenantId = $this->tenantId();
@@ -527,15 +690,53 @@ public function batchDeleteItems(array $ids): void
}
/**
* 품목 상세 조회 (code 기반, BOM 포함 옵션)
*
* @param string $code 품목 코드
* @param bool $includeBom BOM 포함 여부
* Material 일괄 삭제 (SM, RM, CS)
*/
public function getItemByCode(string $code, bool $includeBom = false): Product
private function batchDeleteMaterials(array $ids): void
{
$tenantId = $this->tenantId();
$materials = Material::query()
->where('tenant_id', $tenantId)
->whereIn('id', $ids)
->get();
if ($materials->isEmpty()) {
throw new NotFoundHttpException(__('error.item.not_found'));
}
// BOM 구성품으로 사용 중인 자재 체크
$inUseIds = [];
foreach ($materials as $material) {
$usageCount = $this->checkMaterialUsageInBom($tenantId, $material->id);
if ($usageCount > 0) {
$inUseIds[] = $material->id;
}
}
if (! empty($inUseIds)) {
throw new BadRequestHttpException(__('error.item.in_use_as_bom_component', ['count' => count($inUseIds)]));
}
foreach ($materials as $material) {
$material->delete();
}
}
/**
* 품목 상세 조회 (code 기반, BOM 포함 옵션)
*
* Product 먼저 조회 → 없으면 Material 조회
*
* @param string $code 품목 코드
* @param bool $includeBom BOM 포함 여부
* @return array 품목 데이터 (attributes 플랫 전개)
*/
public function getItemByCode(string $code, bool $includeBom = false): array
{
$tenantId = $this->tenantId();
// 1. Product에서 먼저 조회
$query = Product::query()
->with('category:id,name')
->where('tenant_id', $tenantId)
@@ -547,10 +748,30 @@ public function getItemByCode(string $code, bool $includeBom = false): Product
$product = $query->first();
if (! $product) {
throw new NotFoundHttpException(__('error.not_found'));
if ($product) {
$data = $this->flattenAttributes($product->toArray());
$data['item_type'] = $product->product_type;
$data['type_code'] = $product->product_type;
return $data;
}
return $product;
// 2. Material에서 조회
$material = Material::query()
->with('category:id,name')
->where('tenant_id', $tenantId)
->where('material_code', $code)
->first();
if ($material) {
$data = $this->flattenAttributes($material->toArray());
$data['item_type'] = $material->material_type;
$data['code'] = $material->material_code;
$data['type_code'] = $material->material_type;
return $data;
}
throw new NotFoundHttpException(__('error.not_found'));
}
}