From bf92b37ff6eff48b4f7ceaf1995069b4e843a59e Mon Sep 17 00:00:00 2001 From: hskwon Date: Tue, 9 Dec 2025 09:39:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=92=88=EB=AA=A9=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EC=86=8C=EC=8A=A4=20=EB=A7=A4=ED=95=91=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ItemField 모델: 소스 매핑 컬럼 추가 (source_table, source_column 등) - ItemPage 모델: source_table 컬럼 추가 - ItemDataService: 동적 데이터 조회 서비스 - ItemMasterApi Swagger 업데이트 - ItemTypeSeeder: 품목 유형 시더 - 스펙 문서: ITEM_MASTER_FIELD_INTEGRATION_PLAN.md --- app/Models/ItemMaster/ItemField.php | 37 + app/Models/ItemMaster/ItemPage.php | 30 + app/Services/ItemMaster/ItemDataService.php | 174 +++ app/Swagger/v1/ItemMasterApi.php | 772 +++++------ ...3_add_source_table_to_item_pages_table.php | 46 + ...e_mapping_columns_to_item_fields_table.php | 51 + database/seeders/ItemTypeSeeder.php | 45 + .../ITEM_MASTER_FIELD_INTEGRATION_PLAN.md | 1165 +++++++++++++++++ 8 files changed, 1950 insertions(+), 370 deletions(-) create mode 100644 app/Services/ItemMaster/ItemDataService.php create mode 100644 database/migrations/2025_12_08_191113_add_source_table_to_item_pages_table.php create mode 100644 database/migrations/2025_12_08_191114_add_source_mapping_columns_to_item_fields_table.php create mode 100644 database/seeders/ItemTypeSeeder.php create mode 100644 docs/specs/ITEM_MASTER_FIELD_INTEGRATION_PLAN.md diff --git a/app/Models/ItemMaster/ItemField.php b/app/Models/ItemMaster/ItemField.php index 52ef30c..a262415 100644 --- a/app/Models/ItemMaster/ItemField.php +++ b/app/Models/ItemMaster/ItemField.php @@ -35,6 +35,11 @@ class ItemField extends Model 'created_by', 'updated_by', 'deleted_by', + // 내부용 매핑 컬럼 + 'source_table', + 'source_column', + 'storage_type', + 'json_path', ]; protected $casts = [ @@ -54,9 +59,17 @@ class ItemField extends Model 'locked_at' => 'datetime', ]; + /** + * API 응답에서 제외할 컬럼 (내부용) + */ protected $hidden = [ 'deleted_by', 'deleted_at', + // 내부용 매핑 컬럼 - API 응답에서 제외 + 'source_table', + 'source_column', + 'storage_type', + 'json_path', ]; /** @@ -95,4 +108,28 @@ public function allParentRelationships() return EntityRelationship::where('child_type', EntityRelationship::TYPE_FIELD) ->where('child_id', $this->id); } + + /** + * 시스템 필드 여부 확인 (DB 컬럼과 매핑된 필드) + */ + public function isSystemField(): bool + { + return ! is_null($this->source_table) && ! is_null($this->source_column); + } + + /** + * 컬럼 저장 방식 여부 확인 + */ + public function isColumnStorage(): bool + { + return $this->storage_type === 'column'; + } + + /** + * JSON 저장 방식 여부 확인 + */ + public function isJsonStorage(): bool + { + return $this->storage_type === 'json'; + } } diff --git a/app/Models/ItemMaster/ItemPage.php b/app/Models/ItemMaster/ItemPage.php index 08e4e99..a96f336 100644 --- a/app/Models/ItemMaster/ItemPage.php +++ b/app/Models/ItemMaster/ItemPage.php @@ -16,6 +16,7 @@ class ItemPage extends Model 'group_id', 'page_name', 'item_type', + 'source_table', // 실제 저장 테이블명 (products, materials 등) 'absolute_path', 'is_active', 'created_by', @@ -95,4 +96,33 @@ public function allRelationships() ->where('parent_type', EntityRelationship::TYPE_PAGE) ->orderBy('order_no'); } + + /** + * source_table에 해당하는 모델 클래스명 반환 + */ + public function getTargetModelClass(): ?string + { + $mapping = [ + 'products' => \App\Models\Product::class, + 'materials' => \App\Models\Material::class, + ]; + + return $mapping[$this->source_table] ?? null; + } + + /** + * 제품 페이지인지 확인 + */ + public function isProductPage(): bool + { + return $this->source_table === 'products'; + } + + /** + * 자재 페이지인지 확인 + */ + public function isMaterialPage(): bool + { + return $this->source_table === 'materials'; + } } diff --git a/app/Services/ItemMaster/ItemDataService.php b/app/Services/ItemMaster/ItemDataService.php new file mode 100644 index 0000000..a6b2f58 --- /dev/null +++ b/app/Services/ItemMaster/ItemDataService.php @@ -0,0 +1,174 @@ + value] 형태 + * @param int|null $recordId 수정 시 레코드 ID + * @return array 저장된 데이터 + */ + public function saveData(string $sourceTable, array $fieldValues, ?int $recordId = null): array + { + // 해당 테이블의 필드 매핑 정보 조회 + $fields = ItemField::where('tenant_id', $this->tenantId()) + ->where('source_table', $sourceTable) + ->get() + ->keyBy('id'); + + $columnData = []; // DB 컬럼 직접 저장 + $jsonData = []; // JSON (attributes/options) 저장 + + foreach ($fieldValues as $fieldId => $value) { + $field = $fields->get($fieldId); + + if (! $field) { + // 시스템 필드가 아닌 커스텀 필드 + $customField = ItemField::find($fieldId); + if ($customField) { + $jsonPath = $customField->json_path ?? "attributes.{$customField->field_key}"; + data_set($jsonData, $jsonPath, $value); + } + + continue; + } + + if ($field->isColumnStorage()) { + // DB 컬럼에 직접 저장 + $columnData[$field->source_column] = $this->castValue($value, $field); + } else { + // JSON 필드에 저장 + $jsonPath = $field->json_path ?? "attributes.{$field->field_key}"; + data_set($jsonData, $jsonPath, $value); + } + } + + // JSON 데이터 병합 + if (! empty($jsonData['attributes'])) { + $columnData['attributes'] = json_encode($jsonData['attributes']); + } + if (! empty($jsonData['options'])) { + $columnData['options'] = json_encode($jsonData['options']); + } + + // 공통 컬럼 추가 + $columnData['tenant_id'] = $this->tenantId(); + $columnData['updated_by'] = $this->apiUserId(); + + if ($recordId) { + // 수정 + DB::table($sourceTable) + ->where('tenant_id', $this->tenantId()) + ->where('id', $recordId) + ->update($columnData); + + return array_merge(['id' => $recordId], $columnData); + } else { + // 생성 + $columnData['created_by'] = $this->apiUserId(); + $id = DB::table($sourceTable)->insertGetId($columnData); + + return array_merge(['id' => $id], $columnData); + } + } + + /** + * 필드 타입에 따른 값 변환 + */ + private function castValue($value, ItemField $field) + { + return match ($field->field_type) { + 'number' => is_numeric($value) ? (float) $value : null, + 'checkbox' => filter_var($value, FILTER_VALIDATE_BOOLEAN), + 'date' => $value ? date('Y-m-d', strtotime($value)) : null, + default => $value, + }; + } + + /** + * 레코드 조회 시 필드 매핑 적용 + * + * @param string $sourceTable 대상 테이블 (products, materials 등) + * @param int $recordId 레코드 ID + * @return array 필드 ID => 값 형태의 데이터 + */ + public function getData(string $sourceTable, int $recordId): array + { + $record = DB::table($sourceTable) + ->where('tenant_id', $this->tenantId()) + ->where('id', $recordId) + ->first(); + + if (! $record) { + return []; + } + + // 필드 매핑 정보 조회 + $fields = ItemField::where('tenant_id', $this->tenantId()) + ->where('source_table', $sourceTable) + ->get(); + + $result = []; + $attributes = json_decode($record->attributes ?? '{}', true); + $options = json_decode($record->options ?? '{}', true); + + foreach ($fields as $field) { + if ($field->isColumnStorage()) { + $result[$field->id] = $record->{$field->source_column} ?? null; + } else { + $jsonPath = $field->json_path ?? "attributes.{$field->field_key}"; + $result[$field->id] = data_get( + ['attributes' => $attributes, 'options' => $options], + $jsonPath + ); + } + } + + return $result; + } + + /** + * 페이지 기반으로 레코드 저장 + * + * @param int $pageId ItemPage ID + * @param array $fieldValues [field_id => value] 형태 + * @param int|null $recordId 수정 시 레코드 ID + * @return array 저장된 데이터 + */ + public function saveDataByPage(int $pageId, array $fieldValues, ?int $recordId = null): array + { + $page = \App\Models\ItemMaster\ItemPage::find($pageId); + + if (! $page || ! $page->source_table) { + throw new \InvalidArgumentException("Invalid page or source_table not defined for page: {$pageId}"); + } + + return $this->saveData($page->source_table, $fieldValues, $recordId); + } + + /** + * 페이지 기반으로 레코드 조회 + * + * @param int $pageId ItemPage ID + * @param int $recordId 레코드 ID + * @return array 필드 ID => 값 형태의 데이터 + */ + public function getDataByPage(int $pageId, int $recordId): array + { + $page = \App\Models\ItemMaster\ItemPage::find($pageId); + + if (! $page || ! $page->source_table) { + throw new \InvalidArgumentException("Invalid page or source_table not defined for page: {$pageId}"); + } + + return $this->getData($page->source_table, $recordId); + } +} diff --git a/app/Swagger/v1/ItemMasterApi.php b/app/Swagger/v1/ItemMasterApi.php index a46a753..ad522be 100644 --- a/app/Swagger/v1/ItemMasterApi.php +++ b/app/Swagger/v1/ItemMasterApi.php @@ -32,7 +32,7 @@ * @OA\Schema( * schema="ItemSection", * type="object", - * description="독립 엔티티 - 관계는 entity_relationships로 관리", + * description="엔티티 - 관계는 entity_relationships로 관리", * * @OA\Property(property="id", type="integer", example=1), * @OA\Property(property="tenant_id", type="integer", example=1), @@ -64,7 +64,7 @@ * @OA\Schema( * schema="ItemField", * type="object", - * description="독립 엔티티 - 관계는 entity_relationships로 관리", + * description="엔티티 - 관계는 entity_relationships로 관리", * * @OA\Property(property="id", type="integer", example=1), * @OA\Property(property="tenant_id", type="integer", example=1), @@ -93,7 +93,7 @@ * @OA\Schema( * schema="ItemBomItem", * type="object", - * description="독립 엔티티 - 관계는 entity_relationships로 관리", + * description="엔티티 - 관계는 entity_relationships로 관리", * * @OA\Property(property="id", type="integer", example=1), * @OA\Property(property="tenant_id", type="integer", example=1), @@ -190,7 +190,7 @@ * required={"title","type"}, * * @OA\Property(property="group_id", type="integer", nullable=true, example=1, description="계층번호"), - * @OA\Property(property="title", type="string", maxLength=255, example="독립 섹션"), + * @OA\Property(property="title", type="string", maxLength=255, example="섹션"), * @OA\Property(property="type", type="string", enum={"fields","bom"}, example="fields"), * @OA\Property(property="is_template", type="boolean", example=false), * @OA\Property(property="is_default", type="boolean", example=false), @@ -415,7 +415,7 @@ * @OA\Property( * property="sections", * type="array", - * description="모든 독립 섹션 목록 (재사용 가능)", + * description="모든 섹션 목록 (재사용 가능)", * * @OA\Items(ref="#/components/schemas/ItemSection") * ), @@ -423,7 +423,7 @@ * @OA\Property( * property="fields", * type="array", - * description="모든 독립 필드 목록 (재사용 가능)", + * description="모든 필드 목록 (재사용 가능)", * * @OA\Items(ref="#/components/schemas/ItemField") * ), @@ -445,6 +445,10 @@ */ class ItemMasterApi { + // ════════════════════════════════════════════════════════════════════════ + // 초기화 + // ════════════════════════════════════════════════════════════════════════ + /** * @OA\Get( * path="/api/v1/item-master/init", @@ -466,6 +470,253 @@ class ItemMasterApi */ public function init() {} + // ════════════════════════════════════════════════════════════════════════ + // 필드 (Fields) + // ════════════════════════════════════════════════════════════════════════ + + /** + * @OA\Get( + * path="/api/v1/item-master/fields", + * tags={"ItemMaster"}, + * summary="필드 목록 조회", + * description="섹션과 연결되지 않은 필드 목록을 조회합니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Response(response=200, description="조회 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/ItemField"))) + * }) + * ), + * + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function indexFields() {} + + /** + * @OA\Post( + * path="/api/v1/item-master/fields", + * tags={"ItemMaster"}, + * summary="필드 생성", + * description="섹션과 연결되지 않은 필드를 생성합니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/IndependentFieldStoreRequest")), + * + * @OA\Response(response=200, description="생성 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemField")) + * }) + * ), + * + * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function storeIndependentField() {} + + /** + * @OA\Post( + * path="/api/v1/item-master/sections/{sectionId}/fields", + * tags={"ItemMaster"}, + * summary="필드 생성 (섹션 연결)", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="sectionId", in="path", required=true, @OA\Schema(type="integer")), + * + * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ItemFieldStoreRequest")), + * + * @OA\Response(response=200, description="생성 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemField")) + * }) + * ), + * + * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function storeFields() {} + + /** + * @OA\Put( + * path="/api/v1/item-master/fields/{id}", + * tags={"ItemMaster"}, + * summary="필드 수정", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * + * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ItemFieldUpdateRequest")), + * + * @OA\Response(response=200, description="수정 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemField")) + * }) + * ), + * + * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function updateFields() {} + + /** + * @OA\Delete( + * path="/api/v1/item-master/fields/{id}", + * tags={"ItemMaster"}, + * summary="필드 삭제", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * + * @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), + * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function destroyFields() {} + + /** + * @OA\Put( + * path="/api/v1/item-master/sections/{sectionId}/fields/reorder", + * tags={"ItemMaster"}, + * summary="필드 순서 변경", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="sectionId", in="path", required=true, @OA\Schema(type="integer")), + * + * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ReorderRequest")), + * + * @OA\Response(response=200, description="순서 변경 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), + * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function reorderFields() {} + + /** + * @OA\Post( + * path="/api/v1/item-master/fields/{id}/clone", + * tags={"ItemMaster"}, + * summary="필드 복제", + * description="기존 필드를 복제하여 새 필드를 생성합니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, description="복제할 필드 ID", @OA\Schema(type="integer")), + * + * @OA\Response(response=200, description="복제 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemField")) + * }) + * ), + * + * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function cloneField() {} + + /** + * @OA\Get( + * path="/api/v1/item-master/fields/{id}/usage", + * tags={"ItemMaster"}, + * summary="필드 사용처 조회", + * description="필드가 어떤 섹션/페이지에 연결되어 있는지 조회합니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, description="필드 ID", @OA\Schema(type="integer")), + * + * @OA\Response(response=200, description="조회 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/FieldUsageResponse")) + * }) + * ), + * + * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function getFieldUsage() {} + + // ════════════════════════════════════════════════════════════════════════ + // 단위 (Unit Options) + // ════════════════════════════════════════════════════════════════════════ + + /** + * @OA\Get( + * path="/api/v1/item-master/unit-options", + * tags={"ItemMaster"}, + * summary="단위 옵션 목록", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Response(response=200, description="조회 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/UnitOption"))) + * }) + * ), + * + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function indexUnitOptions() {} + + /** + * @OA\Post( + * path="/api/v1/item-master/unit-options", + * tags={"ItemMaster"}, + * summary="단위 옵션 생성", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/UnitOptionStoreRequest")), + * + * @OA\Response(response=200, description="생성 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/UnitOption")) + * }) + * ), + * + * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function storeUnitOptions() {} + + /** + * @OA\Delete( + * path="/api/v1/item-master/unit-options/{id}", + * tags={"ItemMaster"}, + * summary="단위 옵션 삭제", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * + * @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), + * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function destroyUnitOptions() {} + + // ════════════════════════════════════════════════════════════════════════ + // 페이지 (Pages) + // ════════════════════════════════════════════════════════════════════════ + /** * @OA\Get( * path="/api/v1/item-master/pages", @@ -553,12 +804,16 @@ public function updatePages() {} */ public function destroyPages() {} + // ════════════════════════════════════════════════════════════════════════ + // 섹션 (Sections) + // ════════════════════════════════════════════════════════════════════════ + /** * @OA\Get( * path="/api/v1/item-master/sections", * tags={"ItemMaster"}, - * summary="독립 섹션 목록 조회", - * description="페이지와 연결되지 않은 독립 섹션 목록을 조회합니다. is_template 파라미터로 템플릿 필터링이 가능합니다.", + * summary="섹션 목록 조회", + * description="페이지와 연결되지 않은 섹션 목록을 조회합니다. is_template 파라미터로 템플릿 필터링이 가능합니다.", * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, * * @OA\Parameter(name="is_template", in="query", description="템플릿 여부 필터", @OA\Schema(type="boolean")), @@ -581,8 +836,8 @@ public function indexSections() {} * @OA\Post( * path="/api/v1/item-master/sections", * tags={"ItemMaster"}, - * summary="독립 섹션 생성", - * description="페이지와 연결되지 않은 독립 섹션을 생성합니다.", + * summary="섹션 생성", + * description="페이지와 연결되지 않은 섹션을 생성합니다.", * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, * * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/IndependentSectionStoreRequest")), @@ -601,194 +856,6 @@ public function indexSections() {} */ public function storeIndependentSection() {} - /** - * @OA\Post( - * path="/api/v1/item-master/sections/{id}/clone", - * tags={"ItemMaster"}, - * summary="섹션 복제", - * description="기존 섹션을 복제하여 새 독립 섹션을 생성합니다. 하위 필드와 BOM 항목도 함께 복제됩니다.", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Parameter(name="id", in="path", required=true, description="복제할 섹션 ID", @OA\Schema(type="integer")), - * - * @OA\Response(response=200, description="복제 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemSection")) - * }) - * ), - * - * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function cloneSection() {} - - /** - * @OA\Get( - * path="/api/v1/item-master/sections/{id}/usage", - * tags={"ItemMaster"}, - * summary="섹션 사용처 조회", - * description="섹션이 어떤 페이지에 연결되어 있는지 조회합니다 (entity_relationships 기반).", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Parameter(name="id", in="path", required=true, description="섹션 ID", @OA\Schema(type="integer")), - * - * @OA\Response(response=200, description="조회 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/SectionUsageResponse")) - * }) - * ), - * - * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function getSectionUsage() {} - - /** - * @OA\Get( - * path="/api/v1/item-master/fields", - * tags={"ItemMaster"}, - * summary="독립 필드 목록 조회", - * description="섹션과 연결되지 않은 독립 필드 목록을 조회합니다.", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Response(response=200, description="조회 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/ItemField"))) - * }) - * ), - * - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function indexFields() {} - - /** - * @OA\Post( - * path="/api/v1/item-master/fields", - * tags={"ItemMaster"}, - * summary="독립 필드 생성", - * description="섹션과 연결되지 않은 독립 필드를 생성합니다.", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/IndependentFieldStoreRequest")), - * - * @OA\Response(response=200, description="생성 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemField")) - * }) - * ), - * - * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function storeIndependentField() {} - - /** - * @OA\Post( - * path="/api/v1/item-master/fields/{id}/clone", - * tags={"ItemMaster"}, - * summary="필드 복제", - * description="기존 필드를 복제하여 새 독립 필드를 생성합니다.", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Parameter(name="id", in="path", required=true, description="복제할 필드 ID", @OA\Schema(type="integer")), - * - * @OA\Response(response=200, description="복제 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemField")) - * }) - * ), - * - * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function cloneField() {} - - /** - * @OA\Get( - * path="/api/v1/item-master/fields/{id}/usage", - * tags={"ItemMaster"}, - * summary="필드 사용처 조회", - * description="필드가 어떤 섹션/페이지에 연결되어 있는지 조회합니다.", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Parameter(name="id", in="path", required=true, description="필드 ID", @OA\Schema(type="integer")), - * - * @OA\Response(response=200, description="조회 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/FieldUsageResponse")) - * }) - * ), - * - * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function getFieldUsage() {} - - /** - * @OA\Get( - * path="/api/v1/item-master/bom-items", - * tags={"ItemMaster"}, - * summary="독립 BOM 목록 조회", - * description="섹션과 연결되지 않은 독립 BOM 항목 목록을 조회합니다.", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Response(response=200, description="조회 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/ItemBomItem"))) - * }) - * ), - * - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function indexBomItems() {} - - /** - * @OA\Post( - * path="/api/v1/item-master/bom-items", - * tags={"ItemMaster"}, - * summary="독립 BOM 생성", - * description="섹션과 연결되지 않은 독립 BOM 항목을 생성합니다.", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/IndependentBomItemStoreRequest")), - * - * @OA\Response(response=200, description="생성 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemBomItem")) - * }) - * ), - * - * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function storeIndependentBomItem() {} - /** * @OA\Post( * path="/api/v1/item-master/pages/{pageId}/sections", @@ -874,150 +941,55 @@ public function reorderSections() {} /** * @OA\Post( - * path="/api/v1/item-master/sections/{sectionId}/fields", + * path="/api/v1/item-master/sections/{id}/clone", * tags={"ItemMaster"}, - * summary="필드 생성", + * summary="섹션 복제", + * description="기존 섹션을 복제하여 새 섹션을 생성합니다. 하위 필드와 BOM 항목도 함께 복제됩니다.", * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, * - * @OA\Parameter(name="sectionId", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Parameter(name="id", in="path", required=true, description="복제할 섹션 ID", @OA\Schema(type="integer")), * - * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ItemFieldStoreRequest")), - * - * @OA\Response(response=200, description="생성 성공", + * @OA\Response(response=200, description="복제 성공", * * @OA\JsonContent(allOf={ * * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemField")) - * }) - * ), - * - * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function storeFields() {} - - /** - * @OA\Put( - * path="/api/v1/item-master/fields/{id}", - * tags={"ItemMaster"}, - * summary="필드 수정", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), - * - * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ItemFieldUpdateRequest")), - * - * @OA\Response(response=200, description="수정 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemField")) + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemSection")) * }) * ), * * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ - public function updateFields() {} + public function cloneSection() {} /** - * @OA\Delete( - * path="/api/v1/item-master/fields/{id}", + * @OA\Get( + * path="/api/v1/item-master/sections/{id}/usage", * tags={"ItemMaster"}, - * summary="필드 삭제", + * summary="섹션 사용처 조회", + * description="섹션이 어떤 페이지에 연결되어 있는지 조회합니다 (entity_relationships 기반).", * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, * - * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Parameter(name="id", in="path", required=true, description="섹션 ID", @OA\Schema(type="integer")), * - * @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), - * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function destroyFields() {} - - /** - * @OA\Put( - * path="/api/v1/item-master/sections/{sectionId}/fields/reorder", - * tags={"ItemMaster"}, - * summary="필드 순서 변경", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Parameter(name="sectionId", in="path", required=true, @OA\Schema(type="integer")), - * - * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ReorderRequest")), - * - * @OA\Response(response=200, description="순서 변경 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), - * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function reorderFields() {} - - /** - * @OA\Post( - * path="/api/v1/item-master/sections/{sectionId}/bom-items", - * tags={"ItemMaster"}, - * summary="BOM 항목 생성", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Parameter(name="sectionId", in="path", required=true, @OA\Schema(type="integer")), - * - * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ItemBomItemStoreRequest")), - * - * @OA\Response(response=200, description="생성 성공", + * @OA\Response(response=200, description="조회 성공", * * @OA\JsonContent(allOf={ * * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemBomItem")) - * }) - * ), - * - * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function storeBomItems() {} - - /** - * @OA\Put( - * path="/api/v1/item-master/bom-items/{id}", - * tags={"ItemMaster"}, - * summary="BOM 항목 수정", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), - * - * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ItemBomItemUpdateRequest")), - * - * @OA\Response(response=200, description="수정 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemBomItem")) + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/SectionUsageResponse")) * }) * ), * * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ - public function updateBomItems() {} + public function getSectionUsage() {} - /** - * @OA\Delete( - * path="/api/v1/item-master/bom-items/{id}", - * tags={"ItemMaster"}, - * summary="BOM 항목 삭제", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), - * - * @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), - * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function destroyBomItems() {} + // ──────────────────────────────────────────────────────────────────────── + // 섹션 템플릿 (Section Templates) + // ──────────────────────────────────────────────────────────────────────── /** * @OA\Get( @@ -1103,6 +1075,125 @@ public function updateSectionTemplates() {} */ public function destroySectionTemplates() {} + // ════════════════════════════════════════════════════════════════════════ + // BOM + // ════════════════════════════════════════════════════════════════════════ + + /** + * @OA\Get( + * path="/api/v1/item-master/bom-items", + * tags={"ItemMaster"}, + * summary="BOM 목록 조회", + * description="섹션과 연결되지 않은 BOM 항목 목록을 조회합니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Response(response=200, description="조회 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/ItemBomItem"))) + * }) + * ), + * + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function indexBomItems() {} + + /** + * @OA\Post( + * path="/api/v1/item-master/bom-items", + * tags={"ItemMaster"}, + * summary="BOM 생성", + * description="섹션과 연결되지 않은 BOM 항목을 생성합니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/IndependentBomItemStoreRequest")), + * + * @OA\Response(response=200, description="생성 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemBomItem")) + * }) + * ), + * + * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function storeIndependentBomItem() {} + + /** + * @OA\Post( + * path="/api/v1/item-master/sections/{sectionId}/bom-items", + * tags={"ItemMaster"}, + * summary="BOM 항목 생성 (섹션 연결)", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="sectionId", in="path", required=true, @OA\Schema(type="integer")), + * + * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ItemBomItemStoreRequest")), + * + * @OA\Response(response=200, description="생성 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemBomItem")) + * }) + * ), + * + * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function storeBomItems() {} + + /** + * @OA\Put( + * path="/api/v1/item-master/bom-items/{id}", + * tags={"ItemMaster"}, + * summary="BOM 항목 수정", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * + * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ItemBomItemUpdateRequest")), + * + * @OA\Response(response=200, description="수정 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemBomItem")) + * }) + * ), + * + * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function updateBomItems() {} + + /** + * @OA\Delete( + * path="/api/v1/item-master/bom-items/{id}", + * tags={"ItemMaster"}, + * summary="BOM 항목 삭제", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * + * @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), + * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function destroyBomItems() {} + + // ════════════════════════════════════════════════════════════════════════ + // 커스텀 탭 (Custom Tabs) + // ════════════════════════════════════════════════════════════════════════ + /** * @OA\Get( * path="/api/v1/item-master/custom-tabs", @@ -1201,63 +1292,4 @@ public function destroyCustomTabs() {} * ) */ public function reorderCustomTabs() {} - - /** - * @OA\Get( - * path="/api/v1/item-master/unit-options", - * tags={"ItemMaster"}, - * summary="단위 옵션 목록", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Response(response=200, description="조회 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/UnitOption"))) - * }) - * ), - * - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function indexUnitOptions() {} - - /** - * @OA\Post( - * path="/api/v1/item-master/unit-options", - * tags={"ItemMaster"}, - * summary="단위 옵션 생성", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/UnitOptionStoreRequest")), - * - * @OA\Response(response=200, description="생성 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/UnitOption")) - * }) - * ), - * - * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function storeUnitOptions() {} - - /** - * @OA\Delete( - * path="/api/v1/item-master/unit-options/{id}", - * tags={"ItemMaster"}, - * summary="단위 옵션 삭제", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), - * - * @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), - * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function destroyUnitOptions() {} } diff --git a/database/migrations/2025_12_08_191113_add_source_table_to_item_pages_table.php b/database/migrations/2025_12_08_191113_add_source_table_to_item_pages_table.php new file mode 100644 index 0000000..5f4bb74 --- /dev/null +++ b/database/migrations/2025_12_08_191113_add_source_table_to_item_pages_table.php @@ -0,0 +1,46 @@ +string('source_table', 100) + ->nullable() + ->after('item_type') + ->comment('실제 저장 테이블명 (products, materials 등)'); + + // 인덱스 + $table->index('source_table', 'idx_source_table'); + }); + + // 기존 데이터 업데이트: item_type 기반으로 source_table 설정 + DB::table('item_pages') + ->whereIn('item_type', ['FG', 'PT']) + ->update(['source_table' => 'products']); + + DB::table('item_pages') + ->whereIn('item_type', ['SM', 'RM', 'CS']) + ->update(['source_table' => 'materials']); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('item_pages', function (Blueprint $table) { + $table->dropIndex('idx_source_table'); + $table->dropColumn('source_table'); + }); + } +}; diff --git a/database/migrations/2025_12_08_191114_add_source_mapping_columns_to_item_fields_table.php b/database/migrations/2025_12_08_191114_add_source_mapping_columns_to_item_fields_table.php new file mode 100644 index 0000000..3a77451 --- /dev/null +++ b/database/migrations/2025_12_08_191114_add_source_mapping_columns_to_item_fields_table.php @@ -0,0 +1,51 @@ +string('source_table', 100) + ->nullable() + ->after('properties') + ->comment('내부용: 원본 테이블명 (products, materials 등)'); + + $table->string('source_column', 100) + ->nullable() + ->after('source_table') + ->comment('내부용: 원본 컬럼명 (code, name 등)'); + + $table->enum('storage_type', ['column', 'json']) + ->default('json') + ->after('source_column') + ->comment('내부용: 저장방식 (column=DB컬럼, json=attributes/options)'); + + $table->string('json_path', 200) + ->nullable() + ->after('storage_type') + ->comment('내부용: JSON 저장 경로 (예: attributes.custom_size)'); + + // 인덱스 + $table->index(['source_table', 'source_column'], 'idx_source_mapping'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('item_fields', function (Blueprint $table) { + $table->dropIndex('idx_source_mapping'); + $table->dropColumn(['source_table', 'source_column', 'storage_type', 'json_path']); + }); + } +}; diff --git a/database/seeders/ItemTypeSeeder.php b/database/seeders/ItemTypeSeeder.php new file mode 100644 index 0000000..aa377c2 --- /dev/null +++ b/database/seeders/ItemTypeSeeder.php @@ -0,0 +1,45 @@ + 'item_type', 'code' => 'FG', 'name' => '완제품', 'tenant_id' => $tenantId], + ['code_group' => 'item_type', 'code' => 'PT', 'name' => '반제품', 'tenant_id' => $tenantId], + ['code_group' => 'item_type', 'code' => 'SM', 'name' => '부자재', 'tenant_id' => $tenantId], + ['code_group' => 'item_type', 'code' => 'RM', 'name' => '원자재', 'tenant_id' => $tenantId], + ['code_group' => 'item_type', 'code' => 'CS', 'name' => '소모품', 'tenant_id' => $tenantId], + ]; + + foreach ($itemTypes as $index => $item) { + DB::table('common_codes')->updateOrInsert( + [ + 'code_group' => $item['code_group'], + 'code' => $item['code'], + 'tenant_id' => $item['tenant_id'], + ], + array_merge($item, [ + 'sort_order' => $index + 1, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]) + ); + } + + $this->command->info('ItemTypeSeeder: '.count($itemTypes).'개 item_type 코드가 시딩되었습니다.'); + } +} diff --git a/docs/specs/ITEM_MASTER_FIELD_INTEGRATION_PLAN.md b/docs/specs/ITEM_MASTER_FIELD_INTEGRATION_PLAN.md new file mode 100644 index 0000000..f0ca28c --- /dev/null +++ b/docs/specs/ITEM_MASTER_FIELD_INTEGRATION_PLAN.md @@ -0,0 +1,1165 @@ +# ItemMaster 범용 메타 필드 시스템 구현 계획 + +**작성일**: 2025-12-08 +**버전**: v1.2 +**상태**: Draft (검토 필요) + +--- + +## 1. 개요 + +### 1.1 목적 +ItemMaster를 **범용 메타 필드 정의 시스템**으로 확장하여, 다양한 도메인(제품, 자재, 회계, 생산 등)의 필드를 동일한 구조로 관리 + +### 1.2 핵심 원칙 +| 항목 | 방침 | +|------|------| +| **프론트엔드** | 변경 없음 | +| **API 응답** | 변경 없음 (매핑 정보 미노출) | +| **DB 스키마** | `common_codes`로 도메인 관리, `source_table`로 테이블 분기 | +| **백엔드 서비스** | `page.source_table`로 테이블 분기, 저장 시 자동 분배 | + +### 1.3 적용 대상 테이블 (1차) +- `products` - 제품 (FG, PT) +- `materials` - 자재 (SM, RM, CS) +- `product_components` - BOM +- `material_inspections` - 자재 검수 +- `material_inspection_items` - 검수 항목 +- `material_receipts` - 자재 입고 + +### 1.4 향후 확장 예정 +- `journals` - 회계 전표 +- `work_orders` - 생산 지시 +- `quality_controls` - 품질 관리 +- 기타 도메인 테이블 + +--- + +## 2. 분기 로직 플로우 + +### 2.1 현재 구조 (item_type 기반) + +``` +item_master_pages.item_type +┌─────────────────────────────────────────┐ +│ FG (완제품) ──┐ │ +│ PT (반제품) ──┴──→ products 테이블 │ +│ │ +│ SM (부자재) ──┐ │ +│ RM (원자재) ──┼──→ materials 테이블 │ +│ CS (소모품) ──┘ │ +└─────────────────────────────────────────┘ + +문제점: +- 회계, 생산 등 새 도메인 추가 시 item_type 의미가 맞지 않음 +- 테이블 분기 로직이 코드에 하드코딩됨 +``` + +### 2.2 변경 구조 (단순화) + +#### 2.2.1 common_codes에 item_type 그룹 추가 + +``` +common_codes (code_group = 'item_type') +┌────────────┬────────┬──────────┐ +│ code_group │ code │ name │ +├────────────┼────────┼──────────┤ +│ item_type │ FG │ 완제품 │ +│ item_type │ PT │ 반제품 │ +│ item_type │ SM │ 부자재 │ +│ item_type │ RM │ 원자재 │ +│ item_type │ CS │ 소모품 │ +└────────────┴────────┴──────────┘ + +→ code_group = 'item_type' (컬럼명과 동일 = 직관적!) +→ 계층 구조 없음 (단순) +``` + +#### 2.2.2 item_master_pages 테이블 변경 + +``` +item_master_pages (변경 후) +┌────┬──────────┬────────────┬──────────────────┐ +│ id │ group_id │ item_type │ source_table │ +├────┼──────────┼────────────┼──────────────────┤ +│ 1 │ 1 │ FG │ products │ +│ 2 │ 1 │ PT │ products │ +│ 3 │ 1 │ SM │ materials │ ← 모두 group_id=1 (품목관리) +│ 4 │ 1 │ RM │ materials │ +│ 5 │ 1 │ CS │ materials │ +├────┼──────────┼────────────┼──────────────────┤ +│ 6 │ 2 │ JOURNAL │ journals │ ← group_id=2 (회계) - 향후 확장 +│ 7 │ 3 │ WO │ work_orders │ ← group_id=3 (생산) - 향후 확장 +└────┴──────────┴────────────┴──────────────────┘ + +→ group_id: 테이블 내 자체 그룹핑 (1=품목관리, 2=회계, 3=생산) +→ item_type: 키! common_codes와 매핑 +→ source_table: 실제 저장할 테이블명 (새 컬럼!) +→ page_name: 삭제 (common_codes.name으로 JOIN 조회) +``` + +#### 2.2.3 매핑 조회 + +```sql +-- item_type 컬럼명 = code_group 이름 → 직관적! +SELECT + p.*, + c.name as page_name +FROM item_master_pages p +JOIN common_codes c + ON c.code_group = 'item_type' -- 컬럼명과 동일! + AND c.code = p.item_type +WHERE p.group_id = 1; -- 품목관리 그룹 +``` + +#### 2.2.4 향후 테이블 분리 확장 예시 + +``` +나중에 item_type별로 다른 테이블 사용이 필요할 경우: + +현재: + FG → source_table = 'products' + PT → source_table = 'products' + +확장 가능: + FG → source_table = 'finished_goods' (별도 테이블) + PT → source_table = 'semi_products' (별도 테이블) + +→ source_table만 변경하면 테이블 스위칭 가능 +→ item_type은 그대로 유지 (프론트엔드 변경 없음) +``` + +### 2.3 데이터 저장 플로우 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ [프론트엔드] │ +│ │ │ +│ ▼ │ +│ 1. 페이지 선택 (page_id = 1, 완제품) │ +│ │ │ +│ ▼ │ +│ 2. 필드 입력 후 저장 │ +│ │ │ +│ ▼ │ +│ POST /item-master/data │ +│ { │ +│ "page_id": 1, │ +│ "field_values": { │ +│ "1": "FG-001", ← 품목코드 │ +│ "2": "완제품A", ← 품목명 │ +│ "3": "EA" ← 단위 │ +│ } │ +│ } │ +│ │ │ +│ ▼ │ +│ [백엔드] │ +│ │ │ +│ ▼ │ +│ 3. page_id → source_table 조회 ('products') │ +│ │ │ +│ ▼ │ +│ 4. source_table = 'products' → products 테이블에 저장 │ +│ │ │ +│ ▼ │ +│ 5. 필드별 source_column 매핑 │ +│ field_id=1 → source_column='code' │ +│ field_id=2 → source_column='name' │ +│ field_id=3 → source_column='unit' │ +│ │ │ +│ ▼ │ +│ 6. INSERT INTO products (code, name, unit) VALUES (...) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.4 향후 확장 예시 (회계) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ [프론트엔드] - 동일한 ItemMaster UI 사용 │ +│ │ │ +│ ▼ │ +│ POST /item-master/data │ +│ { │ +│ "page_id": 6, ← 회계전표 페이지 │ +│ "field_values": { │ +│ "101": "2025-12-08", ← 전표일자 │ +│ "102": "매출", ← 전표유형 │ +│ "103": 1000000 ← 금액 │ +│ } │ +│ } │ +│ │ │ +│ ▼ │ +│ [백엔드] │ +│ │ │ +│ ▼ │ +│ page_id=6 → source_table='journals' → journals 테이블에 저장 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. 현재 테이블 스키마 분석 + +### 3.1 products (31 컬럼) + +| 컬럼명 | 타입 | 설명 | ItemMaster 필드 타입 | +|--------|------|------|---------------------| +| code | varchar(50) | 품목코드 | textbox (필수) | +| name | varchar(255) | 품목명 | textbox (필수) | +| unit | varchar(20) | 단위 | dropdown (필수) | +| product_type | varchar(20) | 제품유형 (FG/PT) | dropdown | +| category_id | bigint | 카테고리 | dropdown | +| is_sellable | tinyint(1) | 판매가능 | checkbox | +| is_purchasable | tinyint(1) | 구매가능 | checkbox | +| is_producible | tinyint(1) | 생산가능 | checkbox | +| is_active | tinyint(1) | 활성화 | checkbox | +| certification_number | varchar(100) | 인증번호 | textbox | +| certification_date | date | 인증일자 | date | +| certification_expiry | date | 인증만료일 | date | +| bending_diagram_file_id | bigint | 밴딩도면 파일 | file | +| specification_file_id | bigint | 시방서 파일 | file | +| certification_file_id | bigint | 인증서 파일 | file | +| attributes | json | 동적 속성 | (커스텀 필드 저장용) | + +### 3.2 materials (20 컬럼) + +| 컬럼명 | 타입 | 설명 | ItemMaster 필드 타입 | +|--------|------|------|---------------------| +| material_code | varchar(50) | 자재코드 | textbox (필수) | +| name | varchar(255) | 자재명 | textbox (필수) | +| item_name | varchar(255) | 품목명 | textbox | +| specification | varchar(255) | 규격 | textbox | +| unit | varchar(20) | 단위 | dropdown (필수) | +| category_id | bigint | 카테고리 | dropdown | +| is_inspection | tinyint(1) | 검수필요 | checkbox | +| search_tag | text | 검색태그 | textarea | +| attributes | json | 동적 속성 | (커스텀 필드 저장용) | +| options | json | 옵션 | (커스텀 필드 저장용) | + +### 3.3 product_components (15 컬럼) - BOM + +| 컬럼명 | 타입 | 설명 | ItemMaster 필드 타입 | +|--------|------|------|---------------------| +| parent_product_id | bigint | 상위제품 | lookup | +| ref_type | varchar(20) | 참조유형 (product/material) | dropdown | +| ref_id | bigint | 참조ID | lookup | +| quantity | decimal(18,6) | 수량 | number (필수) | +| formula | varchar(500) | 계산공식 | textbox | +| sort_order | int | 정렬순서 | number | +| note | text | 비고 | textarea | + +### 3.4 material_inspections (14 컬럼) + +| 컬럼명 | 타입 | 설명 | ItemMaster 필드 타입 | +|--------|------|------|---------------------| +| material_id | bigint | 자재ID | lookup | +| inspection_date | date | 검수일 | date (필수) | +| inspector_id | bigint | 검수자 | dropdown | +| status | varchar(20) | 상태 | dropdown | +| lot_no | varchar(50) | LOT번호 | textbox | +| quantity | decimal(15,4) | 검수수량 | number | +| passed_quantity | decimal(15,4) | 합격수량 | number | +| rejected_quantity | decimal(15,4) | 불합격수량 | number | +| note | text | 비고 | textarea | + +### 3.5 material_inspection_items (9 컬럼) + +| 컬럼명 | 타입 | 설명 | ItemMaster 필드 타입 | +|--------|------|------|---------------------| +| inspection_id | bigint | 검수ID | lookup | +| check_item | varchar(255) | 점검항목 | textbox (필수) | +| standard | varchar(255) | 기준 | textbox | +| result | varchar(20) | 결과 | dropdown | +| measured_value | varchar(100) | 측정값 | textbox | +| note | text | 비고 | textarea | + +### 3.6 material_receipts (18 컬럼) + +| 컬럼명 | 타입 | 설명 | ItemMaster 필드 타입 | +|--------|------|------|---------------------| +| material_id | bigint | 자재ID | lookup | +| receipt_date | date | 입고일 | date (필수) | +| lot_no | varchar(50) | LOT번호 | textbox | +| quantity | decimal(15,4) | 입고수량 | number (필수) | +| unit_price | decimal(15,4) | 단가 | number | +| total_price | decimal(15,4) | 금액 | number | +| supplier_id | bigint | 공급업체 | dropdown | +| warehouse_id | bigint | 입고창고 | dropdown | +| po_number | varchar(50) | 발주번호 | textbox | +| invoice_number | varchar(50) | 송장번호 | textbox | +| note | text | 비고 | textarea | + +--- + +## 4. DB 스키마 변경 + +### 4.1 마이그레이션: item_fields 확장 + +```php +string('source_table', 100) + ->nullable() + ->after('properties') + ->comment('내부용: 원본 테이블명 (products, materials 등)'); + + $table->string('source_column', 100) + ->nullable() + ->after('source_table') + ->comment('내부용: 원본 컬럼명 (code, name 등)'); + + $table->enum('storage_type', ['column', 'json']) + ->default('json') + ->after('source_column') + ->comment('내부용: 저장방식 (column=DB컬럼, json=attributes/options)'); + + $table->string('json_path', 200) + ->nullable() + ->after('storage_type') + ->comment('내부용: JSON 저장 경로 (예: attributes.custom_size)'); + + // 인덱스 + $table->index(['source_table', 'source_column'], 'idx_source_mapping'); + }); + } + + public function down(): void + { + Schema::table('item_fields', function (Blueprint $table) { + $table->dropIndex('idx_source_mapping'); + $table->dropColumn(['source_table', 'source_column', 'storage_type', 'json_path']); + }); + } +}; +``` + +### 4.2 컬럼 설명 + +| 컬럼 | 타입 | 용도 | +|------|------|------| +| `source_table` | varchar(100) | 원본 테이블명 (NULL이면 커스텀 필드) | +| `source_column` | varchar(100) | 원본 컬럼명 | +| `storage_type` | enum | `column`: DB 컬럼 직접 저장, `json`: JSON 필드에 저장 | +| `json_path` | varchar(200) | JSON 저장 시 경로 (예: `attributes.custom_size`) | + +### 4.3 마이그레이션: item_pages 변경 + +```php +string('source_table', 100) + ->nullable() + ->after('item_type') + ->comment('실제 저장 테이블명 (products, materials 등)'); + + // page_name 삭제 (common_codes.name으로 대체) + $table->dropColumn('page_name'); + + // 인덱스 + $table->index('source_table', 'idx_source_table'); + }); + } + + public function down(): void + { + Schema::table('item_pages', function (Blueprint $table) { + $table->dropIndex('idx_source_table'); + $table->dropColumn('source_table'); + $table->string('page_name', 100)->after('item_type'); + }); + } +}; +``` + +### 4.4 common_codes 시더 (item_type) + +```php + 'item_type', 'code' => 'FG', 'name' => '완제품', 'tenant_id' => $tenantId], + ['code_group' => 'item_type', 'code' => 'PT', 'name' => '반제품', 'tenant_id' => $tenantId], + ['code_group' => 'item_type', 'code' => 'SM', 'name' => '부자재', 'tenant_id' => $tenantId], + ['code_group' => 'item_type', 'code' => 'RM', 'name' => '원자재', 'tenant_id' => $tenantId], + ['code_group' => 'item_type', 'code' => 'CS', 'name' => '소모품', 'tenant_id' => $tenantId], + ]; + + foreach ($itemTypes as $index => $item) { + DB::table('common_codes')->updateOrInsert( + [ + 'code_group' => $item['code_group'], + 'code' => $item['code'], + 'tenant_id' => $item['tenant_id'], + ], + array_merge($item, [ + 'sort_order' => $index + 1, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]) + ); + } + } +} +``` + +--- + +## 5. 모델 수정 + +### 5.1 ItemField 모델 + +```php + 'boolean', + 'display_condition' => 'array', + 'validation_rules' => 'array', + 'options' => 'array', + 'properties' => 'array', + ]; + + /** + * API 응답에서 제외할 컬럼 (내부용) + */ + protected $hidden = [ + 'source_table', + 'source_column', + 'storage_type', + 'json_path', + ]; + + /** + * 시스템 필드 여부 확인 + */ + public function isSystemField(): bool + { + return !is_null($this->source_table) && !is_null($this->source_column); + } + + /** + * 컬럼 직접 저장 여부 + */ + public function isColumnStorage(): bool + { + return $this->storage_type === 'column'; + } + + /** + * JSON 저장 여부 + */ + public function isJsonStorage(): bool + { + return $this->storage_type === 'json'; + } +} +``` + +--- + +## 6. 시딩 데이터 + +### 6.1 시더 클래스 + +```php +getProductFields($tenantId), + $this->getMaterialFields($tenantId), + $this->getBomFields($tenantId), + $this->getInspectionFields($tenantId), + $this->getReceiptFields($tenantId) + ); + + foreach ($systemFields as $field) { + DB::table('item_fields')->updateOrInsert( + [ + 'tenant_id' => $field['tenant_id'], + 'source_table' => $field['source_table'], + 'source_column' => $field['source_column'], + ], + $field + ); + } + } + + private function getProductFields(int $tenantId): array + { + $baseFields = [ + 'tenant_id' => $tenantId, + 'source_table' => 'products', + 'storage_type' => 'column', + 'created_at' => now(), + 'updated_at' => now(), + ]; + + return [ + array_merge($baseFields, [ + 'source_column' => 'code', + 'field_name' => '품목코드', + 'field_type' => 'textbox', + 'is_required' => true, + 'order_no' => 1, + ]), + array_merge($baseFields, [ + 'source_column' => 'name', + 'field_name' => '품목명', + 'field_type' => 'textbox', + 'is_required' => true, + 'order_no' => 2, + ]), + array_merge($baseFields, [ + 'source_column' => 'unit', + 'field_name' => '단위', + 'field_type' => 'dropdown', + 'is_required' => true, + 'order_no' => 3, + ]), + array_merge($baseFields, [ + 'source_column' => 'product_type', + 'field_name' => '제품유형', + 'field_type' => 'dropdown', + 'order_no' => 4, + 'options' => json_encode([ + ['label' => '완제품', 'value' => 'FG'], + ['label' => '반제품', 'value' => 'PT'], + ]), + ]), + array_merge($baseFields, [ + 'source_column' => 'category_id', + 'field_name' => '카테고리', + 'field_type' => 'dropdown', + 'order_no' => 5, + ]), + array_merge($baseFields, [ + 'source_column' => 'is_sellable', + 'field_name' => '판매가능', + 'field_type' => 'checkbox', + 'order_no' => 6, + 'default_value' => 'true', + ]), + array_merge($baseFields, [ + 'source_column' => 'is_purchasable', + 'field_name' => '구매가능', + 'field_type' => 'checkbox', + 'order_no' => 7, + 'default_value' => 'false', + ]), + array_merge($baseFields, [ + 'source_column' => 'is_producible', + 'field_name' => '생산가능', + 'field_type' => 'checkbox', + 'order_no' => 8, + 'default_value' => 'true', + ]), + array_merge($baseFields, [ + 'source_column' => 'is_active', + 'field_name' => '활성화', + 'field_type' => 'checkbox', + 'order_no' => 9, + 'default_value' => 'true', + ]), + array_merge($baseFields, [ + 'source_column' => 'certification_number', + 'field_name' => '인증번호', + 'field_type' => 'textbox', + 'order_no' => 10, + ]), + array_merge($baseFields, [ + 'source_column' => 'certification_date', + 'field_name' => '인증일자', + 'field_type' => 'date', + 'order_no' => 11, + ]), + array_merge($baseFields, [ + 'source_column' => 'certification_expiry', + 'field_name' => '인증만료일', + 'field_type' => 'date', + 'order_no' => 12, + ]), + ]; + } + + private function getMaterialFields(int $tenantId): array + { + $baseFields = [ + 'tenant_id' => $tenantId, + 'source_table' => 'materials', + 'storage_type' => 'column', + 'created_at' => now(), + 'updated_at' => now(), + ]; + + return [ + array_merge($baseFields, [ + 'source_column' => 'material_code', + 'field_name' => '자재코드', + 'field_type' => 'textbox', + 'is_required' => true, + 'order_no' => 1, + ]), + array_merge($baseFields, [ + 'source_column' => 'name', + 'field_name' => '자재명', + 'field_type' => 'textbox', + 'is_required' => true, + 'order_no' => 2, + ]), + array_merge($baseFields, [ + 'source_column' => 'item_name', + 'field_name' => '품목명', + 'field_type' => 'textbox', + 'order_no' => 3, + ]), + array_merge($baseFields, [ + 'source_column' => 'specification', + 'field_name' => '규격', + 'field_type' => 'textbox', + 'order_no' => 4, + ]), + array_merge($baseFields, [ + 'source_column' => 'unit', + 'field_name' => '단위', + 'field_type' => 'dropdown', + 'is_required' => true, + 'order_no' => 5, + ]), + array_merge($baseFields, [ + 'source_column' => 'category_id', + 'field_name' => '카테고리', + 'field_type' => 'dropdown', + 'order_no' => 6, + ]), + array_merge($baseFields, [ + 'source_column' => 'is_inspection', + 'field_name' => '검수필요', + 'field_type' => 'checkbox', + 'order_no' => 7, + 'default_value' => 'false', + ]), + array_merge($baseFields, [ + 'source_column' => 'search_tag', + 'field_name' => '검색태그', + 'field_type' => 'textarea', + 'order_no' => 8, + ]), + ]; + } + + private function getBomFields(int $tenantId): array + { + $baseFields = [ + 'tenant_id' => $tenantId, + 'source_table' => 'product_components', + 'storage_type' => 'column', + 'created_at' => now(), + 'updated_at' => now(), + ]; + + return [ + array_merge($baseFields, [ + 'source_column' => 'ref_type', + 'field_name' => '참조유형', + 'field_type' => 'dropdown', + 'order_no' => 1, + 'options' => json_encode([ + ['label' => '제품', 'value' => 'product'], + ['label' => '자재', 'value' => 'material'], + ]), + ]), + array_merge($baseFields, [ + 'source_column' => 'ref_id', + 'field_name' => '참조품목', + 'field_type' => 'dropdown', + 'order_no' => 2, + ]), + array_merge($baseFields, [ + 'source_column' => 'quantity', + 'field_name' => '수량', + 'field_type' => 'number', + 'is_required' => true, + 'order_no' => 3, + 'properties' => json_encode(['precision' => 6]), + ]), + array_merge($baseFields, [ + 'source_column' => 'formula', + 'field_name' => '계산공식', + 'field_type' => 'textbox', + 'order_no' => 4, + ]), + array_merge($baseFields, [ + 'source_column' => 'note', + 'field_name' => '비고', + 'field_type' => 'textarea', + 'order_no' => 5, + ]), + ]; + } + + private function getInspectionFields(int $tenantId): array + { + $baseFields = [ + 'tenant_id' => $tenantId, + 'source_table' => 'material_inspections', + 'storage_type' => 'column', + 'created_at' => now(), + 'updated_at' => now(), + ]; + + return [ + array_merge($baseFields, [ + 'source_column' => 'inspection_date', + 'field_name' => '검수일', + 'field_type' => 'date', + 'is_required' => true, + 'order_no' => 1, + ]), + array_merge($baseFields, [ + 'source_column' => 'inspector_id', + 'field_name' => '검수자', + 'field_type' => 'dropdown', + 'order_no' => 2, + ]), + array_merge($baseFields, [ + 'source_column' => 'status', + 'field_name' => '검수상태', + 'field_type' => 'dropdown', + 'order_no' => 3, + 'options' => json_encode([ + ['label' => '대기', 'value' => 'pending'], + ['label' => '진행중', 'value' => 'in_progress'], + ['label' => '완료', 'value' => 'completed'], + ['label' => '불합격', 'value' => 'rejected'], + ]), + ]), + array_merge($baseFields, [ + 'source_column' => 'lot_no', + 'field_name' => 'LOT번호', + 'field_type' => 'textbox', + 'order_no' => 4, + ]), + array_merge($baseFields, [ + 'source_column' => 'quantity', + 'field_name' => '검수수량', + 'field_type' => 'number', + 'order_no' => 5, + ]), + array_merge($baseFields, [ + 'source_column' => 'passed_quantity', + 'field_name' => '합격수량', + 'field_type' => 'number', + 'order_no' => 6, + ]), + array_merge($baseFields, [ + 'source_column' => 'rejected_quantity', + 'field_name' => '불합격수량', + 'field_type' => 'number', + 'order_no' => 7, + ]), + array_merge($baseFields, [ + 'source_column' => 'note', + 'field_name' => '비고', + 'field_type' => 'textarea', + 'order_no' => 8, + ]), + ]; + } + + private function getReceiptFields(int $tenantId): array + { + $baseFields = [ + 'tenant_id' => $tenantId, + 'source_table' => 'material_receipts', + 'storage_type' => 'column', + 'created_at' => now(), + 'updated_at' => now(), + ]; + + return [ + array_merge($baseFields, [ + 'source_column' => 'receipt_date', + 'field_name' => '입고일', + 'field_type' => 'date', + 'is_required' => true, + 'order_no' => 1, + ]), + array_merge($baseFields, [ + 'source_column' => 'lot_no', + 'field_name' => 'LOT번호', + 'field_type' => 'textbox', + 'order_no' => 2, + ]), + array_merge($baseFields, [ + 'source_column' => 'quantity', + 'field_name' => '입고수량', + 'field_type' => 'number', + 'is_required' => true, + 'order_no' => 3, + ]), + array_merge($baseFields, [ + 'source_column' => 'unit_price', + 'field_name' => '단가', + 'field_type' => 'number', + 'order_no' => 4, + 'properties' => json_encode(['precision' => 4]), + ]), + array_merge($baseFields, [ + 'source_column' => 'total_price', + 'field_name' => '금액', + 'field_type' => 'number', + 'order_no' => 5, + 'properties' => json_encode(['precision' => 4]), + ]), + array_merge($baseFields, [ + 'source_column' => 'supplier_id', + 'field_name' => '공급업체', + 'field_type' => 'dropdown', + 'order_no' => 6, + ]), + array_merge($baseFields, [ + 'source_column' => 'warehouse_id', + 'field_name' => '입고창고', + 'field_type' => 'dropdown', + 'order_no' => 7, + ]), + array_merge($baseFields, [ + 'source_column' => 'po_number', + 'field_name' => '발주번호', + 'field_type' => 'textbox', + 'order_no' => 8, + ]), + array_merge($baseFields, [ + 'source_column' => 'invoice_number', + 'field_name' => '송장번호', + 'field_type' => 'textbox', + 'order_no' => 9, + ]), + array_merge($baseFields, [ + 'source_column' => 'note', + 'field_name' => '비고', + 'field_type' => 'textarea', + 'order_no' => 10, + ]), + ]; + } +} +``` + +--- + +## 7. 서비스 로직 (데이터 저장) + +### 7.1 ItemDataService (신규) + +```php + value] 형태 + * @param int|null $recordId 수정 시 레코드 ID + * @return array 저장된 데이터 + */ + public function saveData(string $sourceTable, array $fieldValues, ?int $recordId = null): array + { + // 해당 테이블의 필드 매핑 정보 조회 + $fields = ItemField::where('tenant_id', $this->tenantId()) + ->where('source_table', $sourceTable) + ->get() + ->keyBy('id'); + + $columnData = []; // DB 컬럼 직접 저장 + $jsonData = []; // JSON (attributes/options) 저장 + + foreach ($fieldValues as $fieldId => $value) { + $field = $fields->get($fieldId); + + if (!$field) { + // 시스템 필드가 아닌 커스텀 필드 + $customField = ItemField::find($fieldId); + if ($customField) { + $jsonPath = $customField->json_path ?? "attributes.{$customField->field_name}"; + data_set($jsonData, $jsonPath, $value); + } + continue; + } + + if ($field->isColumnStorage()) { + // DB 컬럼에 직접 저장 + $columnData[$field->source_column] = $this->castValue($value, $field); + } else { + // JSON 필드에 저장 + $jsonPath = $field->json_path ?? "attributes.{$field->field_name}"; + data_set($jsonData, $jsonPath, $value); + } + } + + // JSON 데이터 병합 + if (!empty($jsonData['attributes'])) { + $columnData['attributes'] = json_encode($jsonData['attributes']); + } + if (!empty($jsonData['options'])) { + $columnData['options'] = json_encode($jsonData['options']); + } + + // 공통 컬럼 추가 + $columnData['tenant_id'] = $this->tenantId(); + $columnData['updated_by'] = $this->apiUserId(); + + if ($recordId) { + // 수정 + DB::table($sourceTable) + ->where('tenant_id', $this->tenantId()) + ->where('id', $recordId) + ->update($columnData); + + return array_merge(['id' => $recordId], $columnData); + } else { + // 생성 + $columnData['created_by'] = $this->apiUserId(); + $id = DB::table($sourceTable)->insertGetId($columnData); + + return array_merge(['id' => $id], $columnData); + } + } + + /** + * 필드 타입에 따른 값 변환 + */ + private function castValue($value, ItemField $field) + { + return match ($field->field_type) { + 'number' => is_numeric($value) ? (float) $value : null, + 'checkbox' => filter_var($value, FILTER_VALIDATE_BOOLEAN), + 'date' => $value ? date('Y-m-d', strtotime($value)) : null, + default => $value, + }; + } + + /** + * 레코드 조회 시 필드 매핑 적용 + */ + public function getData(string $sourceTable, int $recordId): array + { + $record = DB::table($sourceTable) + ->where('tenant_id', $this->tenantId()) + ->where('id', $recordId) + ->first(); + + if (!$record) { + return []; + } + + // 필드 매핑 정보 조회 + $fields = ItemField::where('tenant_id', $this->tenantId()) + ->where('source_table', $sourceTable) + ->get(); + + $result = []; + $attributes = json_decode($record->attributes ?? '{}', true); + $options = json_decode($record->options ?? '{}', true); + + foreach ($fields as $field) { + if ($field->isColumnStorage()) { + $result[$field->id] = $record->{$field->source_column} ?? null; + } else { + $jsonPath = $field->json_path ?? "attributes.{$field->field_name}"; + $result[$field->id] = data_get( + ['attributes' => $attributes, 'options' => $options], + $jsonPath + ); + } + } + + return $result; + } +} +``` + +--- + +## 8. API 영향 없음 확인 + +### 8.1 기존 API 응답 (변경 없음) + +```json +// GET /api/v1/item-master/init +{ + "success": true, + "message": "message.fetched", + "data": { + "pages": [{ + "id": 1, + "page_name": "기본정보", + "item_type": "FG", + "sections": [{ + "id": 1, + "title": "품목코드 정보", + "fields": [ + { + "id": 1, + "field_name": "품목코드", + "field_type": "textbox", + "is_required": true, + "order_no": 1 + // source_table, source_column 등은 $hidden으로 제외됨 + } + ] + }] + }] + } +} +``` + +### 8.2 프론트엔드 (변경 없음) + +- 기존 ItemMaster API 그대로 사용 +- 필드 정의 조회/수정 동일 +- 품목 데이터 저장 시 기존 Products/Materials API 사용 + +--- + +## 9. 구현 순서 + +| 순서 | 작업 | 예상 시간 | 담당 | +|------|------|----------|------| +| 1 | 마이그레이션 파일 생성 및 실행 | 30분 | Backend | +| 2 | ItemField 모델 수정 ($hidden 추가) | 15분 | Backend | +| 3 | 시더 클래스 생성 | 1시간 | Backend | +| 4 | 시딩 실행 및 데이터 확인 | 30분 | Backend | +| 5 | ItemDataService 구현 | 2시간 | Backend | +| 6 | 기존 ProductService/MaterialService 연동 | 2시간 | Backend | +| 7 | 테스트 | 1시간 | Backend | + +**총 예상 시간: 7~8시간 (1일)** + +--- + +## 10. 향후 확장 + +### 10.1 신규 도메인 추가 시 +1. 대상 테이블 스키마 분석 +2. 시더에 필드 매핑 추가 +3. 시딩 실행 +4. (필요시) ItemDataService에 특수 로직 추가 + +### 10.2 예정 도메인 +- [ ] 회계 (accounts, journals, ledgers) +- [ ] 생산 (work_orders, production_records) +- [ ] 재고 (inventories, stock_movements) +- [ ] 품질 (quality_controls, defect_reports) + +--- + +## 11. 체크리스트 + +### 구현 전 +- [ ] 현재 item_fields 테이블 구조 확인 +- [ ] 마이그레이션 롤백 계획 수립 +- [ ] 기존 데이터 백업 + +### 구현 중 +- [ ] 마이그레이션 실행 +- [ ] 모델 $hidden 적용 +- [ ] 시더 실행 +- [ ] API 응답 검증 (매핑 컬럼 미노출 확인) + +### 구현 후 +- [ ] 기존 ItemMaster API 정상 동작 확인 +- [ ] 프론트엔드 영향 없음 확인 +- [ ] 품목 저장 시 매핑 정상 동작 확인 + +--- + +**문서 끝**