Files
sam-api/docs/specs/ITEM_MASTER_FIELD_INTEGRATION_PLAN.md
hskwon bf92b37ff6 feat: 품목 마스터 소스 매핑 기능 추가
- ItemField 모델: 소스 매핑 컬럼 추가 (source_table, source_column 등)
- ItemPage 모델: source_table 컬럼 추가
- ItemDataService: 동적 데이터 조회 서비스
- ItemMasterApi Swagger 업데이트
- ItemTypeSeeder: 품목 유형 시더
- 스펙 문서: ITEM_MASTER_FIELD_INTEGRATION_PLAN.md
2025-12-09 09:39:16 +09:00

40 KiB

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 매핑 조회

-- 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

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('item_fields', function (Blueprint $table) {
            // 내부용 매핑 컬럼 (API 응답에서 제외)
            $table->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

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('item_pages', function (Blueprint $table) {
            // source_table 컬럼 추가
            $table->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

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class ItemTypeSeeder extends Seeder
{
    public function run(): void
    {
        $tenantId = 1; // 기본 테넌트

        // code_group = 'item_type' (컬럼명과 동일하게!)
        $itemTypes = [
            ['code_group' => '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

namespace App\Models\ItemMaster;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\BelongsToTenant;

class ItemField extends Model
{
    use BelongsToTenant, SoftDeletes;

    protected $table = 'item_fields';

    protected $fillable = [
        'tenant_id',
        'section_id',
        'group_id',
        'field_name',
        'field_type',
        'order_no',
        'is_required',
        'default_value',
        'placeholder',
        'display_condition',
        'validation_rules',
        'options',
        'properties',
        // 내부용 매핑 컬럼
        'source_table',
        'source_column',
        'storage_type',
        'json_path',
    ];

    protected $casts = [
        'is_required' => '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

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class ItemSystemFieldsSeeder extends Seeder
{
    public function run(): void
    {
        $tenantId = 1; // 기본 테넌트 (또는 동적으로 처리)

        $systemFields = array_merge(
            $this->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

namespace App\Services\ItemMaster;

use App\Models\ItemMaster\ItemField;
use App\Services\Service;
use Illuminate\Support\Facades\DB;

class ItemDataService extends Service
{
    /**
     * 필드 값을 적절한 테이블/컬럼에 저장
     *
     * @param string $sourceTable 대상 테이블 (products, materials 등)
     * @param array $fieldValues [field_id => 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 응답 (변경 없음)

// 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 정상 동작 확인
  • 프론트엔드 영향 없음 확인
  • 품목 저장 시 매핑 정상 동작 확인

문서 끝