feat: 통합 품목 조회 API 및 가격 통합 시스템 구현

- 통합 품목 조회 API (materials + products UNION)
  - ItemsService, ItemsController, Swagger 문서 생성
  - 타입 필터링 (FG/PT/SM/RM/CS), 검색, 카테고리 지원
  - Collection merge 방식으로 UNION 쿼리 안정화

- 품목-가격 통합 조회
  - PricingService.getPriceByType() 추가 (SALE/PURCHASE 지원)
  - 단일 품목 조회 시 판매가/매입가 선택적 포함
  - 고객그룹 가격 우선순위 적용 및 시계열 조회

- 자재 타입 명시적 관리
  - materials.material_type 컬럼 추가 (SM/RM/CS)
  - 기존 데이터 344개 자동 변환 (RAW→RM, SUB→SM)
  - 인덱스 추가로 조회 성능 최적화

- DB 데이터 정규화
  - products.product_type: 760개 정규화 (PRODUCT→FG, PART/SUBASSEMBLY→PT)
  - 타입 코드 표준화로 API 일관성 확보

최종 데이터: 제품 760개(FG 297, PT 463), 자재 344개(SM 215, RM 129)
This commit is contained in:
2025-11-11 11:30:17 +09:00
parent fdef567863
commit ddc4bb99a0
10 changed files with 4996 additions and 6 deletions

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2025-11-10 21:01:46
> **자동 생성**: 2025-11-11 11:24:13
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황
@@ -63,6 +63,7 @@ ### category_templates
### files
**모델**: `App\Models\Commons\File`
- **tenant()**: belongsTo → `tenants`
- **folder()**: belongsTo → `folders`
- **uploader()**: belongsTo → `users`
- **shareLinks()**: hasMany → `file_share_links`
@@ -115,6 +116,8 @@ ### estimate_items
### file_share_links
**모델**: `App\Models\FileShareLink`
- **file()**: belongsTo → `files`
- **tenant()**: belongsTo → `tenants`
### folders
**모델**: `App\Models\Folder`

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\ItemsService;
use Illuminate\Http\Request;
class ItemsController extends Controller
{
public function __construct(private ItemsService $service) {}
/**
* 통합 품목 목록 조회 (materials + products)
*
* GET /api/v1/items
*/
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
$filters = $request->only(['type', 'search', 'q', 'category_id']);
$perPage = (int) ($request->input('size') ?? 20);
return $this->service->getItems($filters, $perPage);
}, __('message.fetched'));
}
/**
* 단일 품목 조회
*
* GET /api/v1/items/{id}?item_type=PRODUCT|MATERIAL&include_price=true&client_id=1&price_date=2025-01-10
*/
public function show(Request $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
$itemType = strtoupper($request->input('item_type', 'PRODUCT'));
$includePrice = filter_var($request->input('include_price', false), FILTER_VALIDATE_BOOLEAN);
$clientId = $request->input('client_id') ? (int) $request->input('client_id') : null;
$priceDate = $request->input('price_date');
return $this->service->getItem($itemType, $id, $includePrice, $clientId, $priceDate);
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,223 @@
<?php
namespace App\Services;
use App\Models\Materials\Material;
use App\Models\Products\Product;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ItemsService extends Service
{
/**
* 통합 품목 조회 (materials + products UNION)
*
* @param array $filters 필터 조건 (type, search, category_id, page, size)
* @param int $perPage 페이지당 항목 수
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
public function getItems(array $filters = [], int $perPage = 20)
{
$tenantId = $this->tenantId();
// 필터 파라미터 추출
$types = $filters['type'] ?? ['FG', 'PT', 'SM', 'RM', 'CS'];
$search = trim($filters['search'] ?? $filters['q'] ?? '');
$categoryId = $filters['category_id'] ?? null;
// 타입을 배열로 변환 (문자열인 경우 쉼표로 분리)
if (is_string($types)) {
$types = array_map('trim', explode(',', $types));
}
// Product 타입 (FG, PT)
$productTypes = array_intersect(['FG', 'PT'], $types);
// Material 타입 (SM, RM, CS)
$materialTypes = array_intersect(['SM', 'RM', 'CS'], $types);
// Products 쿼리 (FG, PT 타입만)
$productsQuery = null;
if (! empty($productTypes)) {
$productsQuery = Product::query()
->where('tenant_id', $tenantId)
->whereIn('product_type', $productTypes)
->where('is_active', 1)
->select([
'id',
DB::raw("'PRODUCT' as item_type"),
'code',
'name',
'unit',
'category_id',
'product_type as type_code',
'created_at',
]);
// 검색 조건
if ($search !== '') {
$productsQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%");
});
}
// 카테고리 필터
if ($categoryId) {
$productsQuery->where('category_id', (int) $categoryId);
}
}
// Materials 쿼리 (SM, RM, CS 타입)
$materialsQuery = null;
if (! empty($materialTypes)) {
$materialsQuery = Material::query()
->where('tenant_id', $tenantId)
->whereIn('material_type', $materialTypes)
->select([
'id',
DB::raw("'MATERIAL' as item_type"),
'material_code as code',
'name',
'unit',
'category_id',
'material_type as type_code',
'created_at',
]);
// 검색 조건
if ($search !== '') {
$materialsQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('item_name', 'like', "%{$search}%")
->orWhere('material_code', 'like', "%{$search}%")
->orWhere('search_tag', 'like', "%{$search}%");
});
}
// 카테고리 필터
if ($categoryId) {
$materialsQuery->where('category_id', (int) $categoryId);
}
}
// 각 쿼리 실행 후 merge (UNION 바인딩 문제 방지)
$items = collect([]);
if ($productsQuery) {
$products = $productsQuery->get();
$items = $items->merge($products);
}
if ($materialsQuery) {
$materials = $materialsQuery->get();
$items = $items->merge($materials);
}
// 정렬 (name 기준 오름차순)
$items = $items->sortBy('name')->values();
// 페이지네이션 처리
$page = request()->input('page', 1);
$offset = ($page - 1) * $perPage;
$total = $items->count();
$paginatedItems = $items->slice($offset, $perPage)->values();
return new \Illuminate\Pagination\LengthAwarePaginator(
$paginatedItems,
$total,
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
}
/**
* 단일 품목 조회
*
* @param string $itemType 'PRODUCT' | 'MATERIAL'
* @param int $id 품목 ID
* @param bool $includePrice 가격 정보 포함 여부
* @param int|null $clientId 고객 ID (가격 조회 시)
* @param string|null $priceDate 기준일 (가격 조회 시)
* @return array 품목 데이터 (item_type, prices 포함)
*/
public function getItem(
string $itemType,
int $id,
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') {
$material = Material::query()
->where('tenant_id', $tenantId)
->find($id);
if (! $material) {
throw new NotFoundHttpException(__('error.not_found'));
}
$data = $material->toArray();
$data['item_type'] = 'MATERIAL';
$data['code'] = $material->material_code;
$data['type_code'] = $material->material_type;
// 가격 정보 추가
if ($includePrice) {
$data['prices'] = $this->fetchPrices($itemType, $id, $clientId, $priceDate);
}
return $data;
} else {
throw new \InvalidArgumentException(__('error.invalid_item_type'));
}
}
/**
* 품목의 판매가/매입가 조회
*
* @param string $itemType 'PRODUCT' | 'MATERIAL'
* @param int $itemId 품목 ID
* @param int|null $clientId 고객 ID
* @param string|null $priceDate 기준일
* @return array ['sale' => array, 'purchase' => array]
*/
private function fetchPrices(string $itemType, int $itemId, ?int $clientId, ?string $priceDate): array
{
// PricingService DI가 없으므로 직접 생성
$pricingService = app(\App\Services\Pricing\PricingService::class);
$salePrice = $pricingService->getPriceByType($itemType, $itemId, 'SALE', $clientId, $priceDate);
$purchasePrice = $pricingService->getPriceByType($itemType, $itemId, 'PURCHASE', $clientId, $priceDate);
return [
'sale' => $salePrice,
'purchase' => $purchasePrice,
];
}
}

View File

@@ -70,15 +70,104 @@ public function getItemPrice(string $itemType, int $itemId, ?int $clientId = nul
}
/**
* 가격 이력에서 유효한 가격 조회
* 특정 항목의 가격 타입별 단가 조회
*
* @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 PriceHistory::where('tenant_id', $this->tenantId())
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)
->salePrice()
->validAt($date)
->forClientGroup($clientGroupId);
// 가격 타입에 따라 스코프 적용
if ($priceType === 'PURCHASE') {
$query->purchasePrice();
} else {
$query->salePrice();
}
return $query->validAt($date)
->orderBy('started_at', 'desc')
->first();
}

254
app/Swagger/v1/ItemsApi.php Normal file
View File

@@ -0,0 +1,254 @@
<?php
namespace App\Swagger\v1;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(name="Items", description="통합 품목 조회 (materials + products)")
*
* ========= 공용 스키마 =========
*
* 통합 품목 스키마
*
* @OA\Schema(
* schema="Item",
* type="object",
* description="통합 품목 (PRODUCT 또는 MATERIAL)",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="item_type", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT", description="품목 유형"),
* @OA\Property(property="code", type="string", example="PRD-001", description="제품 코드 또는 자재 코드"),
* @OA\Property(property="name", type="string", example="스크린 모듈 KS001"),
* @OA\Property(property="unit", type="string", nullable=true, example="SET"),
* @OA\Property(property="category_id", type="integer", nullable=true, example=2),
* @OA\Property(property="type_code", type="string", example="FG", description="제품: FG/PT, 자재: SM/RM/CS"),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-01-10T10:00:00Z")
* )
*
* 가격 정보 스키마
*
* @OA\Schema(
* schema="PriceInfo",
* type="object",
* description="가격 정보 (우선순위: 고객그룹가격 → 기본가격)",
*
* @OA\Property(property="price", type="number", format="float", nullable=true, example=15000.5000, description="단가"),
* @OA\Property(property="price_history_id", type="integer", nullable=true, example=123, description="가격 이력 ID"),
* @OA\Property(property="client_group_id", type="integer", nullable=true, example=5, description="고객 그룹 ID"),
* @OA\Property(property="warning", type="string", nullable=true, example="가격 정보를 찾을 수 없습니다.", description="경고 메시지")
* )
*
* 가격 통합 품목 스키마
*
* @OA\Schema(
* schema="ItemWithPrice",
* allOf={
*
* @OA\Schema(ref="#/components/schemas/Item"),
* @OA\Schema(
* type="object",
*
* @OA\Property(
* property="prices",
* type="object",
* description="판매가/매입가 정보",
*
* @OA\Property(property="sale", ref="#/components/schemas/PriceInfo"),
* @OA\Property(property="purchase", ref="#/components/schemas/PriceInfo")
* )
* )
* }
* )
*
* 통합 품목 페이지네이션
*
* @OA\Schema(
* schema="ItemPagination",
* type="object",
*
* @OA\Property(
* property="data",
* type="array",
*
* @OA\Items(ref="#/components/schemas/Item")
* ),
*
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=5),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=100),
* @OA\Property(property="from", type="integer", example=1),
* @OA\Property(property="to", type="integer", example=20)
* )
*/
class ItemsApi
{
/**
* 통합 품목 목록 조회
*
* @OA\Get(
* path="/api/v1/items",
* tags={"Items"},
* summary="통합 품목 목록 조회 (materials + products UNION)",
* description="제품과 자재를 UNION으로 통합하여 조회합니다. 타입 필터링, 검색, 카테고리 필터 지원.",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(
* name="type",
* in="query",
* description="품목 타입 (쉼표 구분 또는 배열). FG=완제품, PT=부품/서브조립, SM=부자재, RM=원자재, CS=소모품",
* required=false,
*
* @OA\Schema(
* type="string",
* example="FG,PT,SM"
* )
* ),
*
* @OA\Parameter(
* name="search",
* in="query",
* description="검색어 (name, code 필드 LIKE 검색)",
* required=false,
*
* @OA\Schema(type="string", example="스크린")
* ),
*
* @OA\Parameter(
* name="category_id",
* in="query",
* description="카테고리 ID 필터",
* required=false,
*
* @OA\Schema(type="integer", example=2)
* ),
*
* @OA\Parameter(
* name="page",
* in="query",
* description="페이지 번호 (기본: 1)",
* required=false,
*
* @OA\Schema(type="integer", example=1)
* ),
*
* @OA\Parameter(
* name="size",
* in="query",
* description="페이지당 항목 수 (기본: 20)",
* required=false,
*
* @OA\Schema(type="integer", example=20)
* ),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/ItemPagination")
* )
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* 단일 품목 조회 (가격 정보 포함 가능)
*
* @OA\Get(
* path="/api/v1/items/{id}",
* tags={"Items"},
* summary="단일 품목 조회 (가격 정보 포함 가능)",
* description="PRODUCT 또는 MATERIAL 단일 품목 상세 조회. include_price=true로 판매가/매입가 포함 가능.",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(
* name="id",
* in="path",
* required=true,
* description="품목 ID",
*
* @OA\Schema(type="integer", example=10)
* ),
*
* @OA\Parameter(
* name="item_type",
* in="query",
* required=true,
* description="품목 유형 (PRODUCT 또는 MATERIAL)",
*
* @OA\Schema(type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT")
* ),
*
* @OA\Parameter(
* name="include_price",
* in="query",
* required=false,
* description="가격 정보 포함 여부 (기본: false)",
*
* @OA\Schema(type="boolean", example=true)
* ),
*
* @OA\Parameter(
* name="client_id",
* in="query",
* required=false,
* description="고객 ID (가격 조회 시 고객그룹 가격 우선 적용)",
*
* @OA\Schema(type="integer", example=5)
* ),
*
* @OA\Parameter(
* name="price_date",
* in="query",
* required=false,
* description="가격 기준일 (기본: 오늘)",
*
* @OA\Schema(type="string", format="date", example="2025-01-10")
* ),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* type="object",
*
* @OA\Property(
* property="data",
* oneOf={
*
* @OA\Schema(ref="#/components/schemas/Item"),
* @OA\Schema(ref="#/components/schemas/ItemWithPrice")
* },
* description="include_price=false: Item, include_price=true: ItemWithPrice"
* )
* )
* }
* )
* ),
*
* @OA\Response(response=404, description="품목 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('materials', function (Blueprint $table) {
// material_type 컬럼 추가 (SM=부자재, RM=원자재, CS=소모품)
$table->string('material_type', 10)
->nullable()
->after('category_id')
->comment('자재 타입: SM=부자재, RM=원자재, CS=소모품');
// 조회 성능을 위한 인덱스
$table->index('material_type');
});
// 기존 데이터 업데이트
DB::statement("
UPDATE materials
SET material_type = CASE
WHEN JSON_EXTRACT(options, '$.categories.item_type.code') = 'RAW' THEN 'RM'
WHEN JSON_EXTRACT(options, '$.categories.item_type.code') = 'SUB' THEN 'SM'
ELSE 'SM'
END
WHERE tenant_id = 1
AND deleted_at IS NULL
");
// 모든 자재에 타입이 설정되었으므로 NOT NULL로 변경
Schema::table('materials', function (Blueprint $table) {
$table->string('material_type', 10)->nullable(false)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('materials', function (Blueprint $table) {
$table->dropIndex(['material_type']);
$table->dropColumn('material_type');
});
}
};

View File

@@ -322,6 +322,12 @@
Route::get('{id}/bom/categories', [ProductBomItemController::class, 'listCategories'])->name('v1.products.bom.categories'); // 해당 제품에서 사용 중
});
// Items (통합 품목 조회 - materials + products UNION)
Route::prefix('items')->group(function () {
Route::get('', [\App\Http\Controllers\Api\V1\ItemsController::class, 'index'])->name('v1.items.index'); // 통합 목록
Route::get('/{id}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'show'])->name('v1.items.show'); // 단건 (item_type 파라미터 필수)
});
// BOM (product_components: ref_type=PRODUCT|MATERIAL)
Route::prefix('products/{id}/bom')->group(function () {
Route::post('/', [ProductBomItemController::class, 'replace'])->name('v1.products.bom.replace');