- ITEM-MASTER-INDEX.md: 문서 인덱스 및 핵심 개념 정의 - item-master-field-integration.md: 범용 메타 필드 시스템 구현 계획 (v1.3) - item-master-field-key-validation.md: field_key 검증 정책
47 KiB
47 KiB
ItemMaster 범용 메타 필드 시스템 구현 계획
작성일: 2025-12-08 버전: v1.3 상태: 구현 중 (DB 구조 확정)
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 현재 구조 (✅ 구현 완료)
참고: 아래 구조는 이미 DB에 구현되어 운영 중입니다.
2.1.1 common_codes (item_type 마스터)
common_codes (code_group = 'item_type') - ✅ 시딩 완료
┌────────────┬────────┬──────────┬─────────────────────────────────────────┐
│ code_group │ code │ name │ attributes (JSON) │
├────────────┼────────┼──────────┼─────────────────────────────────────────┤
│ item_type │ FG │ 완제품 │ {"source_table":"products","name_en":...}│
│ item_type │ PT │ 부품 │ {"source_table":"products","name_en":...}│
│ item_type │ SM │ 부자재 │ {"source_table":"materials","name_en":..}│
│ item_type │ RM │ 원자재 │ {"source_table":"materials","name_en":..}│
│ item_type │ CS │ 소모품 │ {"source_table":"materials","name_en":..}│
└────────────┴────────┴──────────┴─────────────────────────────────────────┘
→ attributes.source_table: 물리 테이블 매핑 정보
→ FG/PT → products, SM/RM/CS → materials
2.1.2 item_pages (페이지 설정)
item_pages - ✅ 테이블 존재 (source_table 컬럼 포함)
┌────┬───────────┬──────────┬────────────┬──────────────┬───────────────────┐
│ id │ tenant_id │ group_id │ page_name │ item_type │ source_table │
├────┼───────────┼──────────┼────────────┼──────────────┼───────────────────┤
│ 1 │ 287 │ 1 │ 완제품기본 │ FG │ products │
│ 2 │ 287 │ 1 │ 완제품상세 │ FG │ products │ ← 같은 FG로 여러 페이지!
│ 3 │ 287 │ 1 │ 부품관리 │ PT │ products │
│ 4 │ 287 │ 1 │ 부자재 │ SM │ materials │
└────┴───────────┴──────────┴────────────┴──────────────┴───────────────────┘
→ page_name: 유지 (테넌트별 페이지명 커스터마이징)
→ source_table: 성능을 위해 중복 저장 (common_codes에서도 조회 가능)
→ 같은 item_type으로 여러 페이지 생성 가능
2.1.3 entity_relationships (N:M 링크)
entity_relationships - ✅ 테이블 존재
┌────┬─────────────┬───────────┬─────────────┬──────────┐
│ id │ parent_type │ parent_id │ child_type │ child_id │
├────┼─────────────┼───────────┼─────────────┼──────────┤
│ 1 │ page │ 981 │ section │ 1 │
│ 2 │ page │ 981 │ section │ 2 │
│ 3 │ section │ 1 │ field │ 1 │
└────┴─────────────┴───────────┴─────────────┴──────────┘
→ 독립 엔티티 아키텍처
→ page → section → field 관계를 링크 테이블로 관리
2.2 아키텍처 관계도
┌─────────────────────────────────────────────────────────────────────────┐
│ ItemMaster 테이블 구조 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────┐ │
│ │ common_codes (마스터 코드) │ │
│ │ code_group='item_type' │ │
│ │ ────────────────────── │ │
│ │ FG → attributes.source_table │ │
│ │ PT → attributes.source_table │ │
│ │ SM/RM/CS → attributes.source_table │ │
│ └──────────────┬───────────────────────┘ │
│ │ 참조 (item_type) │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ item_pages (페이지 설정) │ │
│ │ ────────────────────── │ │
│ │ - 테넌트별 페이지 구성 │ │
│ │ - 같은 item_type으로 N개 페이지 │ │
│ │ - source_table (성능용 중복 저장) │ │
│ └──────────────┬───────────────────────┘ │
│ │ parent_id │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ entity_relationships (N:M 링크) │ │
│ │ ────────────────────── │ │
│ │ page → section → field │ │
│ └──────────────┬───────────────────────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ │
│ │item_sections│ │item_fields │ │
│ │(독립 엔티티)│ │(독립 엔티티)│ │
│ └────────────┘ └────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
2.3 source_table 조회 방법
// 방법 1: item_pages에서 직접 조회 (성능 우선)
$page = ItemPage::find($pageId);
$sourceTable = $page->source_table; // 'products' or 'materials'
// 방법 2: common_codes에서 조회 (정규화 우선)
$code = CommonCode::where('code_group', 'item_type')
->where('code', $itemType)
->first();
$sourceTable = $code->attributes['source_table'];
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 테이블 (✅ 이미 구현됨)
참고: item_pages 테이블은 이미 source_table 컬럼이 추가되어 있습니다. page_name은 유지됩니다 (테넌트별 페이지명 커스터마이징 필요).
-- 현재 item_pages 구조
CREATE TABLE item_pages (
id BIGINT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
group_id INT DEFAULT 1,
page_name VARCHAR(100), -- 유지 (테넌트별 커스텀 가능)
item_type ENUM('FG','PT','SM','RM','CS'),
source_table VARCHAR(100), -- ✅ 이미 추가됨
absolute_path VARCHAR(500),
is_active TINYINT(1) DEFAULT 1,
...
);
4.4 common_codes 시더 (item_type) - ✅ 구현 완료
<?php
// api/database/seeders/ItemTypeSeeder.php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class ItemTypeSeeder extends Seeder
{
/**
* Run the database seeds.
* common_codes 테이블에 item_type 그룹 데이터 시딩
*
* attributes.source_table: 실제 저장 테이블 매핑
* - FG, PT → products 테이블
* - SM, RM, CS → materials 테이블
*/
public function run(): void
{
$tenantId = 1; // 기본 테넌트
$itemTypes = [
[
'code_group' => 'item_type',
'code' => 'FG',
'name' => '완제품',
'tenant_id' => $tenantId,
'attributes' => json_encode([
'source_table' => 'products',
'name_en' => 'Finished Goods',
]),
],
[
'code_group' => 'item_type',
'code' => 'PT',
'name' => '부품',
'tenant_id' => $tenantId,
'attributes' => json_encode([
'source_table' => 'products',
'name_en' => 'Parts',
]),
],
[
'code_group' => 'item_type',
'code' => 'SM',
'name' => '부자재',
'tenant_id' => $tenantId,
'attributes' => json_encode([
'source_table' => 'materials',
'name_en' => 'Sub-Materials',
]),
],
[
'code_group' => 'item_type',
'code' => 'RM',
'name' => '원자재',
'tenant_id' => $tenantId,
'attributes' => json_encode([
'source_table' => 'materials',
'name_en' => 'Raw Materials',
]),
],
[
'code_group' => 'item_type',
'code' => 'CS',
'name' => '소모품',
'tenant_id' => $tenantId,
'attributes' => json_encode([
'source_table' => 'materials',
'name_en' => 'Consumables',
]),
],
];
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(),
])
);
}
}
}
// 실행: php artisan db:seed --class=ItemTypeSeeder
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 정상 동작 확인
- 프론트엔드 영향 없음 확인
- 품목 저장 시 매핑 정상 동작 확인
문서 끝