diff --git a/app/Constants/SystemFields.php b/app/Constants/SystemFields.php new file mode 100644 index 0000000..834f5f6 --- /dev/null +++ b/app/Constants/SystemFields.php @@ -0,0 +1,144 @@ + self::PRODUCTS, + self::SOURCE_TABLE_MATERIALS => self::MATERIALS, + default => [] + }; + + // 공통 시스템 컬럼 병합 + return array_unique(array_merge(self::COMMON, $tableFields)); + } + + /** + * 특정 field_key가 시스템 필드인지 확인 + */ + public static function isReserved(string $fieldKey, string $sourceTable): bool + { + return in_array($fieldKey, self::getReservedKeys($sourceTable), true); + } + + /** + * group_id에 속한 모든 테이블의 예약어 목록 조회 + * (item_type을 모를 때 안전하게 모든 예약어 체크) + */ + public static function getAllReservedKeysForGroup(int $groupId): array + { + $allFields = self::COMMON; + + if ($groupId === self::GROUP_ITEM_MASTER) { + $allFields = array_merge($allFields, self::PRODUCTS, self::MATERIALS); + } + + return array_unique($allFields); + } + + /** + * 특정 field_key가 그룹 내 어떤 테이블에서든 시스템 필드인지 확인 + */ + public static function isReservedInGroup(string $fieldKey, int $groupId): bool + { + return in_array($fieldKey, self::getAllReservedKeysForGroup($groupId), true); + } +} diff --git a/app/Services/ItemMaster/ItemFieldService.php b/app/Services/ItemMaster/ItemFieldService.php index df6c651..fdee4a3 100644 --- a/app/Services/ItemMaster/ItemFieldService.php +++ b/app/Services/ItemMaster/ItemFieldService.php @@ -2,11 +2,13 @@ namespace App\Services\ItemMaster; +use App\Constants\SystemFields; use App\Models\ItemMaster\EntityRelationship; use App\Models\ItemMaster\ItemField; use App\Services\Service; use App\Traits\LockCheckTrait; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class ItemFieldService extends Service @@ -61,14 +63,64 @@ public function storeIndependent(array $data): ItemField 'created_by' => $userId, ]); - // 2. field_key 설정 ({ID}_{field_key} 형식) + // 2. field_key 설정 (시스템 필드 + 중복 검증 후 저장) if (! empty($data['field_key'])) { - $field->update(['field_key' => $field->id.'_'.$data['field_key']]); + $sourceTable = $data['source_table'] ?? null; + $groupId = $data['group_id'] ?? 1; + $this->validateFieldKeyUnique($data['field_key'], $tenantId, $sourceTable, $groupId); + $field->update(['field_key' => $data['field_key']]); } return $field; } + /** + * field_key 유효성 검증 (시스템 필드 + 중복 체크) + * + * @param string|null $sourceTable 소스 테이블 (products, materials), null이면 그룹 전체 예약어 체크 + * @param int $groupId 그룹 ID (1=품목관리), sourceTable이 null일 때 사용 + * + * @throws ValidationException + */ + private function validateFieldKeyUnique( + string $fieldKey, + int $tenantId, + ?string $sourceTable = null, + int $groupId = 1, + ?int $excludeId = null + ): void { + // 1. 시스템 필드(예약어) 체크 + if ($sourceTable) { + // source_table이 지정된 경우: 해당 테이블의 예약어만 체크 + if (SystemFields::isReserved($fieldKey, $sourceTable)) { + throw ValidationException::withMessages([ + 'field_key' => [__('error.field_key_reserved', ['field_key' => $fieldKey])], + ]); + } + } else { + // source_table이 없는 경우: 그룹 내 모든 테이블의 예약어 체크 (안전 모드) + if (SystemFields::isReservedInGroup($fieldKey, $groupId)) { + throw ValidationException::withMessages([ + 'field_key' => [__('error.field_key_reserved', ['field_key' => $fieldKey])], + ]); + } + } + + // 2. 기존 필드 중복 체크 + $query = ItemField::where('tenant_id', $tenantId) + ->where('field_key', $fieldKey); + + if ($excludeId) { + $query->where('id', '!=', $excludeId); + } + + if ($query->exists()) { + throw ValidationException::withMessages([ + 'field_key' => [__('validation.unique', ['attribute' => 'field_key'])], + ]); + } + } + /** * 필드 복제 * @@ -109,11 +161,19 @@ public function clone(int $id): ItemField 'created_by' => $userId, ]); - // 2. field_key 복제 ({새ID}_{원본key} 형식) + // 2. field_key 복제 (원본key_copy 형식, 중복 시 _copy2, _copy3...) if ($original->field_key) { - // 원본 field_key에서 키 부분 추출 (예: "81_itemNum" → "itemNum") - $originalKey = preg_replace('/^\d+_/', '', $original->field_key); - $cloned->update(['field_key' => $cloned->id.'_'.$originalKey.'_copy']); + $baseKey = $original->field_key.'_copy'; + $copyKey = $baseKey; + $suffix = 1; + + // 중복되지 않는 키 찾기 + while (ItemField::where('tenant_id', $tenantId)->where('field_key', $copyKey)->exists()) { + $suffix++; + $copyKey = $baseKey.$suffix; + } + + $cloned->update(['field_key' => $copyKey]); } return $cloned; @@ -183,9 +243,12 @@ public function store(int $sectionId, array $data): ItemField 'created_by' => $userId, ]); - // 2. field_key 설정 ({ID}_{field_key} 형식) + // 2. field_key 설정 (시스템 필드 + 중복 검증 후 저장) if (! empty($data['field_key'])) { - $field->update(['field_key' => $field->id.'_'.$data['field_key']]); + $sourceTable = $data['source_table'] ?? null; + $groupId = $data['group_id'] ?? 1; + $this->validateFieldKeyUnique($data['field_key'], $tenantId, $sourceTable, $groupId); + $field->update(['field_key' => $data['field_key']]); } // 3. 섹션-필드 관계 생성 @@ -237,9 +300,16 @@ public function update(int $id, array $data): ItemField 'updated_by' => $userId, ]; - // field_key 변경 시 {ID}_{field_key} 형식 유지 + // field_key 변경 시 시스템 필드 + 중복 검증 후 저장 if (array_key_exists('field_key', $data)) { - $updateData['field_key'] = $data['field_key'] ? $field->id.'_'.$data['field_key'] : null; + if ($data['field_key']) { + $sourceTable = $data['source_table'] ?? null; + $groupId = $field->group_id ?? 1; + $this->validateFieldKeyUnique($data['field_key'], $tenantId, $sourceTable, $groupId, $id); + $updateData['field_key'] = $data['field_key']; + } else { + $updateData['field_key'] = null; + } } // is_locked 변경 처리 diff --git a/docs/specs/item-master-field-key-validation.md b/docs/specs/item-master-field-key-validation.md new file mode 100644 index 0000000..c035c59 --- /dev/null +++ b/docs/specs/item-master-field-key-validation.md @@ -0,0 +1,200 @@ +# Item Master field_key 검증 정책 + +## 개요 + +field_key 저장 및 검증 정책을 변경하여 시스템 필드(고정 컬럼)와의 충돌을 방지합니다. + +## 변경 사항 + +### 1. field_key 저장 정책 변경 + +**변경 전:** +``` +field_key = {id}_{입력값} +예: 98_code, 99_name +``` + +**변경 후:** +``` +field_key = {입력값} +예: code, name (단, 시스템 예약어는 사용 불가) +``` + +### 2. 시스템 필드 예약어 검증 추가 + +#### 검증 흐름 +``` +field_key 입력 + ↓ +source_table 확인 (products / materials) + ↓ +해당 테이블 예약어 체크 + ↓ +기존 필드 중복 체크 + ↓ +저장 +``` + +#### source_table 기반 예약어 매핑 + +| source_table | 대상 테이블 | 예약어 목록 | +|--------------|-------------|-------------| +| `products` | products | code, name, unit, product_type, ... | +| `materials` | materials | name, material_code, material_type, ... | +| `null` | 전체 | products + materials 예약어 모두 체크 (안전 모드) | + +## 구현 상세 + +### 파일 구조 + +``` +app/ +├── Constants/ +│ └── SystemFields.php # 신규: 예약어 상수 클래스 +└── Services/ + └── ItemMaster/ + └── ItemFieldService.php # 수정: 예약어 검증 추가 +``` + +### SystemFields 상수 클래스 + +```php +// app/Constants/SystemFields.php + +class SystemFields +{ + // 소스 테이블 상수 + public const SOURCE_TABLE_PRODUCTS = 'products'; + public const SOURCE_TABLE_MATERIALS = 'materials'; + + // 그룹 ID 상수 + public const GROUP_ITEM_MASTER = 1; + + // products 테이블 고정 컬럼 + public const PRODUCTS = [ + 'code', 'name', 'unit', 'category_id', 'product_type', 'description', + 'is_sellable', 'is_purchasable', 'is_producible', 'is_variable_size', 'is_active', + 'safety_stock', 'lead_time', 'product_category', 'part_type', + 'bending_diagram', 'bending_details', + 'specification_file', 'specification_file_name', + 'certification_file', 'certification_file_name', + 'certification_number', 'certification_start_date', 'certification_end_date', + 'attributes', 'attributes_archive', + ]; + + // materials 테이블 고정 컬럼 + public const MATERIALS = [ + 'name', 'item_name', 'specification', 'material_code', 'material_type', + 'unit', 'category_id', 'is_inspection', 'is_active', + 'search_tag', 'remarks', 'attributes', 'options', + ]; + + // 공통 시스템 컬럼 + public const COMMON = [ + 'id', 'tenant_id', 'created_by', 'updated_by', 'deleted_by', + 'created_at', 'updated_at', 'deleted_at', + ]; + + // source_table 기반 예약어 조회 + public static function getReservedKeys(string $sourceTable): array; + + // 예약어 여부 확인 + public static function isReserved(string $fieldKey, string $sourceTable): bool; + + // 그룹 내 전체 예약어 조회 (안전 모드) + public static function getAllReservedKeysForGroup(int $groupId): array; + + // 그룹 내 예약어 여부 확인 + public static function isReservedInGroup(string $fieldKey, int $groupId): bool; +} +``` + +### ItemFieldService 검증 메서드 + +```php +private function validateFieldKeyUnique( + string $fieldKey, + int $tenantId, + ?string $sourceTable = null, + int $groupId = 1, + ?int $excludeId = null +): void { + // 1. 시스템 필드(예약어) 체크 + if ($sourceTable) { + if (SystemFields::isReserved($fieldKey, $sourceTable)) { + throw ValidationException::withMessages([ + 'field_key' => [__('error.field_key_reserved', ['field_key' => $fieldKey])], + ]); + } + } else { + // 안전 모드: 그룹 내 모든 테이블 예약어 체크 + if (SystemFields::isReservedInGroup($fieldKey, $groupId)) { + throw ValidationException::withMessages([ + 'field_key' => [__('error.field_key_reserved', ['field_key' => $fieldKey])], + ]); + } + } + + // 2. 기존 필드 중복 체크 + // ... +} +``` + +### 호출 예시 + +```php +// 독립 필드 생성 시 +$this->validateFieldKeyUnique( + $data['field_key'], + $tenantId, + $data['source_table'] ?? null, // 'products' 또는 'materials' + $data['group_id'] ?? 1 +); + +// 필드 수정 시 +$this->validateFieldKeyUnique( + $data['field_key'], + $tenantId, + $data['source_table'] ?? null, + $field->group_id ?? 1, + $id // excludeId +); +``` + +## 에러 메시지 + +| 상황 | 메시지 키 | 메시지 | +|------|----------|--------| +| 시스템 예약어 충돌 | `error.field_key_reserved` | `"code"은(는) 시스템 예약어로 사용할 수 없습니다.` | +| 기존 필드 중복 | `validation.unique` | `field_key은(는) 이미 사용 중입니다.` | + +```php +// lang/ko/error.php +'field_key_reserved' => '":field_key"은(는) 시스템 예약어로 사용할 수 없습니다.', + +// lang/ko/validation.php (Laravel 기본) +'unique' => ':attribute은(는) 이미 사용 중입니다.', +``` + +## clone 메서드 field_key 복제 정책 + +``` +원본 field_key: custom_field +복제본: custom_field_copy + +중복 시: custom_field_copy2, custom_field_copy3, ... +``` + +## 관련 파일 + +| 파일 | 변경 유형 | 설명 | +|------|----------|------| +| `app/Constants/SystemFields.php` | 신규 | 예약어 상수 클래스 | +| `app/Services/ItemMaster/ItemFieldService.php` | 수정 | 검증 로직 추가 | +| `lang/ko/error.php` | 수정 | 에러 메시지 추가 | + +## 참고 + +- ItemPage 테이블의 `source_table` 컬럼: 실제 저장 테이블명 (products, materials) +- ItemPage 테이블의 `item_type` 컬럼: FG, PT, SM, RM, CS (품목 유형 코드) +- `group_id`: 카테고리 격리용 (1 = 품목관리) diff --git a/lang/ko/error.php b/lang/ko/error.php index 96e3502..04cd165 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -109,6 +109,7 @@ 'page_not_found' => '페이지를 찾을 수 없습니다.', 'section_not_found' => '섹션을 찾을 수 없습니다.', 'field_not_found' => '필드를 찾을 수 없습니다.', + 'field_key_reserved' => '":field_key"은(는) 시스템 예약어로 사용할 수 없습니다.', 'bom_not_found' => 'BOM 항목을 찾을 수 없습니다.', // 품목 관리 관련