feat: Items 테이블 통합 마이그레이션 Phase 0-5 구현
## 주요 변경사항 - Phase 0: 비표준 item_type 데이터 정규화 마이그레이션 - Phase 1.1: items 테이블 생성 (products + materials 통합) - Phase 1.2: item_details 테이블 생성 (1:1 확장 필드) - Phase 1.3: 데이터 이관 + item_id_mappings 테이블 생성 - Phase 3: item_pages.source_table 업데이트 - Phase 5: 참조 테이블 마이그레이션 (product_components, orders 등) ## 신규 파일 - app/Models/Items/Item.php - 통합 아이템 모델 - app/Models/Items/ItemDetail.php - 1:1 확장 필드 모델 - app/Services/ItemService.php - 통합 서비스 클래스 ## 수정 파일 - ItemPage.php - items 테이블 지원 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
440
app/Services/ItemService.php
Normal file
440
app/Services/ItemService.php
Normal file
@@ -0,0 +1,440 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Constants\SystemFields;
|
||||
use App\Models\Commons\Category;
|
||||
use App\Models\ItemMaster\ItemField;
|
||||
use App\Models\Items\Item;
|
||||
use App\Models\Items\ItemDetail;
|
||||
use App\Models\Products\CommonCode;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class ItemService extends Service
|
||||
{
|
||||
/**
|
||||
* items 테이블의 고정 컬럼 목록 조회 (SystemFields + ItemField 기반)
|
||||
*/
|
||||
private function getKnownFields(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
// 1. 기본 고정 필드
|
||||
$baseFields = [
|
||||
'id', 'tenant_id', 'item_type', 'code', 'name', 'unit', 'category_id',
|
||||
'bom', 'attributes', 'attributes_archive', 'options', 'description',
|
||||
'is_active', 'created_by', 'updated_by', 'deleted_by',
|
||||
'created_at', 'updated_at', 'deleted_at',
|
||||
];
|
||||
|
||||
// 2. ItemField에서 storage_type='column'인 필드의 field_key 조회
|
||||
$columnFields = ItemField::where('tenant_id', $tenantId)
|
||||
->where('source_table', 'items')
|
||||
->where('storage_type', 'column')
|
||||
->whereNotNull('field_key')
|
||||
->pluck('field_key')
|
||||
->toArray();
|
||||
|
||||
// 3. API 전용 필드
|
||||
$apiFields = ['type_code'];
|
||||
|
||||
return array_unique(array_merge($baseFields, $columnFields, $apiFields));
|
||||
}
|
||||
|
||||
/**
|
||||
* 정의된 필드 외의 동적 필드를 options로 추출
|
||||
*/
|
||||
private function extractDynamicOptions(array $params): array
|
||||
{
|
||||
$knownFields = $this->getKnownFields();
|
||||
|
||||
$dynamicOptions = [];
|
||||
foreach ($params as $key => $value) {
|
||||
if (! in_array($key, $knownFields) && $value !== null && $value !== '') {
|
||||
$dynamicOptions[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $dynamicOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 options 배열과 동적 필드를 병합
|
||||
*/
|
||||
private function mergeOptionsWithDynamic($existingOptions, array $dynamicOptions): array
|
||||
{
|
||||
if (! is_array($existingOptions) || empty($existingOptions)) {
|
||||
return $dynamicOptions;
|
||||
}
|
||||
|
||||
$isAssoc = array_keys($existingOptions) !== range(0, count($existingOptions) - 1);
|
||||
|
||||
if ($isAssoc) {
|
||||
return array_merge($existingOptions, $dynamicOptions);
|
||||
}
|
||||
|
||||
foreach ($dynamicOptions as $key => $value) {
|
||||
$existingOptions[] = ['label' => $key, 'value' => $value];
|
||||
}
|
||||
|
||||
return $existingOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* options 입력을 [{label, value, unit}] 형태로 정규화
|
||||
*/
|
||||
private function normalizeOptions(?array $in): ?array
|
||||
{
|
||||
if (! $in) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$isAssoc = array_keys($in) !== range(0, count($in) - 1);
|
||||
|
||||
if ($isAssoc) {
|
||||
$out = [];
|
||||
foreach ($in as $k => $v) {
|
||||
$label = trim((string) $k);
|
||||
$value = is_scalar($v) ? trim((string) $v) : json_encode($v, JSON_UNESCAPED_UNICODE);
|
||||
if ($label !== '' || $value !== '') {
|
||||
$out[] = ['label' => $label, 'value' => $value, 'unit' => ''];
|
||||
}
|
||||
}
|
||||
|
||||
return $out ?: null;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($in as $a) {
|
||||
if (! is_array($a)) {
|
||||
continue;
|
||||
}
|
||||
$label = trim((string) ($a['label'] ?? ''));
|
||||
$value = trim((string) ($a['value'] ?? ''));
|
||||
$unit = trim((string) ($a['unit'] ?? ''));
|
||||
|
||||
if ($label === '' && $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = ['label' => $label, 'value' => $value, 'unit' => $unit];
|
||||
}
|
||||
|
||||
return $out ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 트리 전체 조회
|
||||
*/
|
||||
public function getCategory($request)
|
||||
{
|
||||
$parentId = $request->parentId ?? null;
|
||||
|
||||
return $this->fetchCategoryTree($parentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 내부 재귀 함수 (하위 카테고리 트리 구조로 구성)
|
||||
*/
|
||||
protected function fetchCategoryTree(?int $parentId = null)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$query = Category::query()
|
||||
->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->when(
|
||||
is_null($parentId),
|
||||
fn ($q) => $q->whereNull('parent_id'),
|
||||
fn ($q) => $q->where('parent_id', $parentId)
|
||||
)
|
||||
->where('is_active', 1)
|
||||
->orderBy('sort_order');
|
||||
|
||||
$categories = $query->get();
|
||||
|
||||
foreach ($categories as $category) {
|
||||
$children = $this->fetchCategoryTree($category->id);
|
||||
$category->setRelation('children', $children);
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록/검색
|
||||
*/
|
||||
public function index(array $params): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$size = (int) ($params['size'] ?? $params['per_page'] ?? 20);
|
||||
$q = trim((string) ($params['q'] ?? $params['search'] ?? ''));
|
||||
$categoryId = $params['category_id'] ?? null;
|
||||
$itemType = $params['item_type'] ?? null;
|
||||
$active = $params['active'] ?? null;
|
||||
|
||||
$query = Item::query()
|
||||
->with(['category:id,name', 'details'])
|
||||
->where('tenant_id', $tenantId);
|
||||
|
||||
// 검색어
|
||||
if ($q !== '') {
|
||||
$query->where(function ($w) use ($q) {
|
||||
$w->where('name', 'like', "%{$q}%")
|
||||
->orWhere('code', 'like', "%{$q}%")
|
||||
->orWhere('description', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 카테고리
|
||||
if ($categoryId) {
|
||||
$query->where('category_id', (int) $categoryId);
|
||||
}
|
||||
|
||||
// item_type 필터
|
||||
if ($itemType) {
|
||||
$query->where('item_type', strtoupper($itemType));
|
||||
}
|
||||
|
||||
// 활성 상태
|
||||
if ($active !== null && $active !== '') {
|
||||
$query->where('is_active', (bool) $active);
|
||||
}
|
||||
|
||||
$paginator = $query->orderBy('id')->paginate($size);
|
||||
|
||||
// 날짜 형식 변환
|
||||
$paginator->setCollection(
|
||||
$paginator->getCollection()->transform(function ($item) {
|
||||
$arr = $item->toArray();
|
||||
$arr['created_at'] = $item->created_at
|
||||
? $item->created_at->format('Y-m-d')
|
||||
: null;
|
||||
|
||||
return $arr;
|
||||
})
|
||||
);
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성
|
||||
*/
|
||||
public function store(array $data): Item
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
// 동적 필드를 options에 병합
|
||||
$dynamicOptions = $this->extractDynamicOptions($data);
|
||||
if (! empty($dynamicOptions)) {
|
||||
$existingOptions = $data['options'] ?? [];
|
||||
$data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions);
|
||||
}
|
||||
|
||||
// options 정규화
|
||||
if (isset($data['options'])) {
|
||||
$data['options'] = $this->normalizeOptions($data['options']);
|
||||
}
|
||||
|
||||
// tenant별 code 유니크 체크
|
||||
$dup = Item::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $data['code'])
|
||||
->exists();
|
||||
if ($dup) {
|
||||
throw new BadRequestHttpException(__('error.duplicate_key'));
|
||||
}
|
||||
|
||||
// items 테이블 데이터
|
||||
$itemData = [
|
||||
'tenant_id' => $tenantId,
|
||||
'item_type' => strtoupper($data['item_type']),
|
||||
'code' => $data['code'],
|
||||
'name' => $data['name'],
|
||||
'unit' => $data['unit'] ?? null,
|
||||
'category_id' => $data['category_id'] ?? null,
|
||||
'bom' => $data['bom'] ?? null,
|
||||
'attributes' => $data['attributes'] ?? null,
|
||||
'options' => $data['options'] ?? null,
|
||||
'description' => $data['description'] ?? null,
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'created_by' => $userId,
|
||||
];
|
||||
|
||||
$item = Item::create($itemData);
|
||||
|
||||
// item_details 테이블 데이터
|
||||
$detailData = $this->extractDetailData($data);
|
||||
$detailData['item_id'] = $item->id;
|
||||
ItemDetail::create($detailData);
|
||||
|
||||
return $item->load('details');
|
||||
}
|
||||
|
||||
/**
|
||||
* 단건 조회
|
||||
*/
|
||||
public function show(int $id): Item
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$item = Item::query()
|
||||
->with(['category:id,name', 'details'])
|
||||
->where('tenant_id', $tenantId)
|
||||
->find($id);
|
||||
|
||||
if (! $item) {
|
||||
throw new BadRequestHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정
|
||||
*/
|
||||
public function update(int $id, array $data): Item
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$item = Item::query()->where('tenant_id', $tenantId)->find($id);
|
||||
if (! $item) {
|
||||
throw new BadRequestHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
// 동적 필드를 options에 병합
|
||||
$dynamicOptions = $this->extractDynamicOptions($data);
|
||||
if (! empty($dynamicOptions)) {
|
||||
$existingOptions = $data['options'] ?? [];
|
||||
$data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions);
|
||||
}
|
||||
|
||||
// options 정규화
|
||||
if (isset($data['options'])) {
|
||||
$data['options'] = $this->normalizeOptions($data['options']);
|
||||
}
|
||||
|
||||
// code 변경 시 중복 체크
|
||||
if (isset($data['code']) && $data['code'] !== $item->code) {
|
||||
$dup = Item::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $data['code'])
|
||||
->exists();
|
||||
if ($dup) {
|
||||
throw new BadRequestHttpException(__('error.duplicate_key'));
|
||||
}
|
||||
}
|
||||
|
||||
// items 테이블 업데이트
|
||||
$itemData = array_intersect_key($data, array_flip([
|
||||
'item_type', 'code', 'name', 'unit', 'category_id',
|
||||
'bom', 'attributes', 'options', 'description', 'is_active',
|
||||
]));
|
||||
$itemData['updated_by'] = $userId;
|
||||
|
||||
if (isset($itemData['item_type'])) {
|
||||
$itemData['item_type'] = strtoupper($itemData['item_type']);
|
||||
}
|
||||
|
||||
$item->update($itemData);
|
||||
|
||||
// item_details 테이블 업데이트
|
||||
$detailData = $this->extractDetailData($data);
|
||||
if (! empty($detailData)) {
|
||||
$item->details()->updateOrCreate(
|
||||
['item_id' => $item->id],
|
||||
$detailData
|
||||
);
|
||||
}
|
||||
|
||||
return $item->load('details')->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 (soft delete)
|
||||
*/
|
||||
public function destroy(int $id): void
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$item = Item::query()->where('tenant_id', $tenantId)->find($id);
|
||||
if (! $item) {
|
||||
throw new BadRequestHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
$item->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 간편 검색 (모달/드롭다운)
|
||||
*/
|
||||
public function search(array $params)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$q = trim((string) ($params['q'] ?? ''));
|
||||
$limit = (int) ($params['limit'] ?? 20);
|
||||
$itemType = $params['item_type'] ?? null;
|
||||
|
||||
$query = Item::query()->where('tenant_id', $tenantId);
|
||||
|
||||
if ($q !== '') {
|
||||
$query->where(function ($w) use ($q) {
|
||||
$w->where('name', 'like', "%{$q}%")
|
||||
->orWhere('code', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($itemType) {
|
||||
$query->where('item_type', strtoupper($itemType));
|
||||
}
|
||||
|
||||
return $query->orderBy('name')
|
||||
->limit($limit)
|
||||
->get(['id', 'code', 'name', 'item_type', 'category_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성/비활성 토글
|
||||
*/
|
||||
public function toggle(int $id): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$item = Item::query()->where('tenant_id', $tenantId)->find($id);
|
||||
if (! $item) {
|
||||
throw new BadRequestHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
$item->is_active = ! $item->is_active;
|
||||
$item->updated_by = $userId;
|
||||
$item->save();
|
||||
|
||||
return ['id' => $item->id, 'is_active' => $item->is_active];
|
||||
}
|
||||
|
||||
/**
|
||||
* item_details 데이터 추출
|
||||
*/
|
||||
private function extractDetailData(array $data): array
|
||||
{
|
||||
$detailFields = [
|
||||
// Products 전용
|
||||
'is_sellable', 'is_purchasable', 'is_producible',
|
||||
'safety_stock', 'lead_time', 'is_variable_size',
|
||||
'product_category', 'part_type',
|
||||
'bending_diagram', 'bending_details',
|
||||
'specification_file', 'specification_file_name',
|
||||
'certification_file', 'certification_file_name',
|
||||
'certification_number', 'certification_start_date', 'certification_end_date',
|
||||
// Materials 전용
|
||||
'is_inspection', 'item_name', 'specification', 'search_tag', 'remarks',
|
||||
];
|
||||
|
||||
return array_intersect_key($data, array_flip($detailFields));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user