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:
@@ -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`
|
||||
|
||||
45
app/Http/Controllers/Api/V1/ItemsController.php
Normal file
45
app/Http/Controllers/Api/V1/ItemsController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
223
app/Services/ItemsService.php
Normal file
223
app/Services/ItemsService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
254
app/Swagger/v1/ItemsApi.php
Normal 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() {}
|
||||
}
|
||||
1681
claudedocs/SAM_Item_DB_API_Analysis_v2.md
Normal file
1681
claudedocs/SAM_Item_DB_API_Analysis_v2.md
Normal file
File diff suppressed because it is too large
Load Diff
1262
claudedocs/SAM_Item_DB_API_Analysis_v3_FINAL.md
Normal file
1262
claudedocs/SAM_Item_DB_API_Analysis_v3_FINAL.md
Normal file
File diff suppressed because it is too large
Load Diff
1373
claudedocs/SAM_Item_Management_DB_Modeling_Analysis.md
Normal file
1373
claudedocs/SAM_Item_Management_DB_Modeling_Analysis.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user