- ItemField 모델: 소스 매핑 컬럼 추가 (source_table, source_column 등) - ItemPage 모델: source_table 컬럼 추가 - ItemDataService: 동적 데이터 조회 서비스 - ItemMasterApi Swagger 업데이트 - ItemTypeSeeder: 품목 유형 시더 - 스펙 문서: ITEM_MASTER_FIELD_INTEGRATION_PLAN.md
40 KiB
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- BOMmaterial_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 신규 도메인 추가 시
- 대상 테이블 스키마 분석
- 시더에 필드 매핑 추가
- 시딩 실행
- (필요시) ItemDataService에 특수 로직 추가
10.2 예정 도메인
- 회계 (accounts, journals, ledgers)
- 생산 (work_orders, production_records)
- 재고 (inventories, stock_movements)
- 품질 (quality_controls, defect_reports)
11. 체크리스트
구현 전
- 현재 item_fields 테이블 구조 확인
- 마이그레이션 롤백 계획 수립
- 기존 데이터 백업
구현 중
- 마이그레이션 실행
- 모델 $hidden 적용
- 시더 실행
- API 응답 검증 (매핑 컬럼 미노출 확인)
구현 후
- 기존 ItemMaster API 정상 동작 확인
- 프론트엔드 영향 없음 확인
- 품목 저장 시 매핑 정상 동작 확인
문서 끝