diff --git a/app/Http/Requests/ItemMaster/IndependentFieldStoreRequest.php b/app/Http/Requests/ItemMaster/IndependentFieldStoreRequest.php index 05f1959..52acba9 100644 --- a/app/Http/Requests/ItemMaster/IndependentFieldStoreRequest.php +++ b/app/Http/Requests/ItemMaster/IndependentFieldStoreRequest.php @@ -16,6 +16,7 @@ public function rules(): array return [ 'group_id' => 'nullable|integer', 'field_name' => 'required|string|max:255', + 'field_key' => 'nullable|string|max:80|regex:/^[a-zA-Z][a-zA-Z0-9_]*$/', 'field_type' => 'required|in:textbox,number,dropdown,checkbox,date,textarea', 'is_required' => 'nullable|boolean', 'default_value' => 'nullable|string', @@ -24,6 +25,14 @@ public function rules(): array 'validation_rules' => 'nullable|array', 'options' => 'nullable|array', 'properties' => 'nullable|array', + 'is_locked' => 'nullable|boolean', + ]; + } + + public function messages(): array + { + return [ + 'field_key.regex' => __('validation.field_key_format'), ]; } } diff --git a/app/Http/Requests/ItemMaster/ItemFieldStoreRequest.php b/app/Http/Requests/ItemMaster/ItemFieldStoreRequest.php index f728e8e..35cc9c5 100644 --- a/app/Http/Requests/ItemMaster/ItemFieldStoreRequest.php +++ b/app/Http/Requests/ItemMaster/ItemFieldStoreRequest.php @@ -16,6 +16,7 @@ public function rules(): array return [ 'group_id' => 'nullable|integer|min:1', // 계층번호 'field_name' => 'required|string|max:255', + 'field_key' => 'nullable|string|max:80|regex:/^[a-zA-Z][a-zA-Z0-9_]*$/', 'field_type' => 'required|in:textbox,number,dropdown,checkbox,date,textarea', 'is_required' => 'nullable|boolean', 'default_value' => 'nullable|string', @@ -24,6 +25,14 @@ public function rules(): array 'validation_rules' => 'nullable|array', 'options' => 'nullable|array', 'properties' => 'nullable|array', + 'is_locked' => 'nullable|boolean', + ]; + } + + public function messages(): array + { + return [ + 'field_key.regex' => __('validation.field_key_format'), ]; } } diff --git a/app/Http/Requests/ItemMaster/ItemFieldUpdateRequest.php b/app/Http/Requests/ItemMaster/ItemFieldUpdateRequest.php index 6ac3590..fc43671 100644 --- a/app/Http/Requests/ItemMaster/ItemFieldUpdateRequest.php +++ b/app/Http/Requests/ItemMaster/ItemFieldUpdateRequest.php @@ -15,6 +15,7 @@ public function rules(): array { return [ 'field_name' => 'sometimes|string|max:255', + 'field_key' => 'nullable|string|max:80|regex:/^[a-zA-Z][a-zA-Z0-9_]*$/', 'field_type' => 'sometimes|in:textbox,number,dropdown,checkbox,date,textarea', 'is_required' => 'nullable|boolean', 'default_value' => 'nullable|string', @@ -23,6 +24,14 @@ public function rules(): array 'validation_rules' => 'nullable|array', 'options' => 'nullable|array', 'properties' => 'nullable|array', + 'is_locked' => 'nullable|boolean', + ]; + } + + public function messages(): array + { + return [ + 'field_key.regex' => __('validation.field_key_format'), ]; } } diff --git a/app/Models/ItemMaster/ItemField.php b/app/Models/ItemMaster/ItemField.php index 682076d..2b6da14 100644 --- a/app/Models/ItemMaster/ItemField.php +++ b/app/Models/ItemMaster/ItemField.php @@ -15,6 +15,7 @@ class ItemField extends Model 'tenant_id', 'group_id', 'field_name', + 'field_key', 'field_type', 'order_no', 'is_required', @@ -27,6 +28,9 @@ class ItemField extends Model 'category', 'description', 'is_common', + 'is_locked', + 'locked_by', + 'locked_at', 'created_by', 'updated_by', 'deleted_by', @@ -37,6 +41,7 @@ class ItemField extends Model 'order_no' => 'integer', 'is_required' => 'boolean', 'is_common' => 'boolean', + 'is_locked' => 'boolean', 'display_condition' => 'array', 'validation_rules' => 'array', 'options' => 'array', @@ -44,6 +49,7 @@ class ItemField extends Model 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', + 'locked_at' => 'datetime', ]; protected $hidden = [ diff --git a/app/Services/ItemMaster/ItemFieldService.php b/app/Services/ItemMaster/ItemFieldService.php index b618b94..0b4b997 100644 --- a/app/Services/ItemMaster/ItemFieldService.php +++ b/app/Services/ItemMaster/ItemFieldService.php @@ -37,6 +37,7 @@ public function storeIndependent(array $data): ItemField $tenantId = $this->tenantId(); $userId = $this->apiUserId(); + // 1. 필드 생성 (field_key 없이) $field = ItemField::create([ 'tenant_id' => $tenantId, 'group_id' => $data['group_id'] ?? 1, @@ -53,9 +54,17 @@ public function storeIndependent(array $data): ItemField 'category' => $data['category'] ?? null, 'description' => $data['description'] ?? null, 'is_common' => $data['is_common'] ?? false, + 'is_locked' => $data['is_locked'] ?? false, + 'locked_by' => ($data['is_locked'] ?? false) ? $userId : null, + 'locked_at' => ($data['is_locked'] ?? false) ? now() : null, 'created_by' => $userId, ]); + // 2. field_key 설정 ({ID}_{field_key} 형식) + if (! empty($data['field_key'])) { + $field->update(['field_key' => $field->id.'_'.$data['field_key']]); + } + return $field; } @@ -77,6 +86,7 @@ public function clone(int $id): ItemField throw new NotFoundHttpException(__('error.field_not_found')); } + // 1. 복제 생성 (field_key, is_locked 없이) $cloned = ItemField::create([ 'tenant_id' => $tenantId, 'group_id' => $original->group_id, @@ -93,9 +103,17 @@ public function clone(int $id): ItemField 'category' => $original->category, 'description' => $original->description, 'is_common' => $original->is_common, + 'is_locked' => false, // 복제본은 잠금 해제 상태 'created_by' => $userId, ]); + // 2. field_key 복제 ({새ID}_{원본key} 형식) + if ($original->field_key) { + // 원본 field_key에서 키 부분 추출 (예: "81_itemNum" → "itemNum") + $originalKey = preg_replace('/^\d+_/', '', $original->field_key); + $cloned->update(['field_key' => $cloned->id.'_'.$originalKey.'_copy']); + } + return $cloned; } @@ -142,7 +160,7 @@ public function store(int $sectionId, array $data): ItemField ->where('child_type', EntityRelationship::TYPE_FIELD) ->max('order_no'); - // 필드 생성 + // 1. 필드 생성 $field = ItemField::create([ 'tenant_id' => $tenantId, 'group_id' => $data['group_id'] ?? 1, @@ -156,10 +174,18 @@ public function store(int $sectionId, array $data): ItemField 'validation_rules' => $data['validation_rules'] ?? null, 'options' => $data['options'] ?? null, 'properties' => $data['properties'] ?? null, + 'is_locked' => $data['is_locked'] ?? false, + 'locked_by' => ($data['is_locked'] ?? false) ? $userId : null, + 'locked_at' => ($data['is_locked'] ?? false) ? now() : null, 'created_by' => $userId, ]); - // 섹션-필드 관계 생성 + // 2. field_key 설정 ({ID}_{field_key} 형식) + if (! empty($data['field_key'])) { + $field->update(['field_key' => $field->id.'_'.$data['field_key']]); + } + + // 3. 섹션-필드 관계 생성 EntityRelationship::link( $tenantId, EntityRelationship::TYPE_SECTION, @@ -190,7 +216,8 @@ public function update(int $id, array $data): ItemField throw new NotFoundHttpException(__('error.not_found')); } - $field->update([ + // 기본 필드 업데이트 + $updateData = [ 'field_name' => $data['field_name'] ?? $field->field_name, 'field_type' => $data['field_type'] ?? $field->field_type, 'is_required' => $data['is_required'] ?? $field->is_required, @@ -204,7 +231,28 @@ public function update(int $id, array $data): ItemField 'description' => $data['description'] ?? $field->description, 'is_common' => $data['is_common'] ?? $field->is_common, 'updated_by' => $userId, - ]); + ]; + + // field_key 변경 시 {ID}_{field_key} 형식 유지 + if (array_key_exists('field_key', $data)) { + $updateData['field_key'] = $data['field_key'] ? $field->id.'_'.$data['field_key'] : null; + } + + // is_locked 변경 처리 + if (array_key_exists('is_locked', $data)) { + $updateData['is_locked'] = $data['is_locked']; + if ($data['is_locked'] && ! $field->is_locked) { + // 새로 잠금 설정 + $updateData['locked_by'] = $userId; + $updateData['locked_at'] = now(); + } elseif (! $data['is_locked'] && $field->is_locked) { + // 잠금 해제 + $updateData['locked_by'] = null; + $updateData['locked_at'] = null; + } + } + + $field->update($updateData); return $field; } diff --git a/app/Swagger/v1/ItemMasterApi.php b/app/Swagger/v1/ItemMasterApi.php index 1cb1d73..a46a753 100644 --- a/app/Swagger/v1/ItemMasterApi.php +++ b/app/Swagger/v1/ItemMasterApi.php @@ -70,10 +70,13 @@ * @OA\Property(property="tenant_id", type="integer", example=1), * @OA\Property(property="group_id", type="integer", nullable=true, example=1, description="계층번호"), * @OA\Property(property="field_name", type="string", example="제품명"), + * @OA\Property(property="field_key", type="string", nullable=true, example="81_itemNum", description="필드 고유 키 ({ID}_{key} 형식)"), * @OA\Property(property="field_type", type="string", enum={"textbox","number","dropdown","checkbox","date","textarea"}, example="textbox"), * @OA\Property(property="order_no", type="integer", example=0), * @OA\Property(property="is_required", type="boolean", example=true), - * @OA\Property(property="is_locked", type="boolean", example=false, description="연결 잠금 여부 (init API 응답 시 포함)"), + * @OA\Property(property="is_locked", type="boolean", example=false, description="엔티티 잠금 여부"), + * @OA\Property(property="locked_by", type="integer", nullable=true, example=null, description="잠금 설정자 ID"), + * @OA\Property(property="locked_at", type="string", nullable=true, example=null, description="잠금 설정 일시"), * @OA\Property(property="default_value", type="string", nullable=true, example=null), * @OA\Property(property="placeholder", type="string", nullable=true, example="제품명을 입력하세요"), * @OA\Property(property="display_condition", type="object", nullable=true, example=null), @@ -201,6 +204,7 @@ * * @OA\Property(property="group_id", type="integer", nullable=true, example=1, description="계층번호"), * @OA\Property(property="field_name", type="string", maxLength=255, example="제품명"), + * @OA\Property(property="field_key", type="string", nullable=true, maxLength=80, example="itemNum", description="필드 고유 키 (영문+숫자+언더스코어, 저장 시 {ID}_{key} 형식으로 변환)"), * @OA\Property(property="field_type", type="string", enum={"textbox","number","dropdown","checkbox","date","textarea"}, example="textbox"), * @OA\Property(property="is_required", type="boolean", example=false), * @OA\Property(property="default_value", type="string", nullable=true, example=null), @@ -208,7 +212,8 @@ * @OA\Property(property="display_condition", type="object", nullable=true, example=null), * @OA\Property(property="validation_rules", type="object", nullable=true, example=null), * @OA\Property(property="options", type="object", nullable=true, example=null), - * @OA\Property(property="properties", type="object", nullable=true, example=null) + * @OA\Property(property="properties", type="object", nullable=true, example=null), + * @OA\Property(property="is_locked", type="boolean", nullable=true, example=false, description="잠금 여부") * ) * * @OA\Schema( @@ -262,6 +267,7 @@ * * @OA\Property(property="group_id", type="integer", nullable=true, example=1, description="계층번호"), * @OA\Property(property="field_name", type="string", maxLength=255, example="제품명"), + * @OA\Property(property="field_key", type="string", nullable=true, maxLength=80, example="itemNum", description="필드 고유 키 (영문+숫자+언더스코어, 저장 시 {ID}_{key} 형식으로 변환)"), * @OA\Property(property="field_type", type="string", enum={"textbox","number","dropdown","checkbox","date","textarea"}, example="textbox"), * @OA\Property(property="is_required", type="boolean", example=true), * @OA\Property(property="default_value", type="string", nullable=true, example=null), @@ -269,7 +275,8 @@ * @OA\Property(property="display_condition", type="object", nullable=true, example=null), * @OA\Property(property="validation_rules", type="object", nullable=true, example=null), * @OA\Property(property="options", type="object", nullable=true, example=null), - * @OA\Property(property="properties", type="object", nullable=true, example=null) + * @OA\Property(property="properties", type="object", nullable=true, example=null), + * @OA\Property(property="is_locked", type="boolean", nullable=true, example=false, description="잠금 여부") * ) * * @OA\Schema( @@ -277,6 +284,7 @@ * type="object", * * @OA\Property(property="field_name", type="string", maxLength=255, example="제품명"), + * @OA\Property(property="field_key", type="string", nullable=true, maxLength=80, example="itemNum", description="필드 고유 키 (영문+숫자+언더스코어)"), * @OA\Property(property="field_type", type="string", enum={"textbox","number","dropdown","checkbox","date","textarea"}, example="textbox"), * @OA\Property(property="is_required", type="boolean", example=true), * @OA\Property(property="default_value", type="string", nullable=true, example=null), @@ -284,7 +292,8 @@ * @OA\Property(property="display_condition", type="object", nullable=true, example=null), * @OA\Property(property="validation_rules", type="object", nullable=true, example=null), * @OA\Property(property="options", type="object", nullable=true, example=null), - * @OA\Property(property="properties", type="object", nullable=true, example=null) + * @OA\Property(property="properties", type="object", nullable=true, example=null), + * @OA\Property(property="is_locked", type="boolean", nullable=true, example=false, description="잠금 여부") * ) * * @OA\Schema( diff --git a/database/migrations/2025_11_28_171952_add_field_key_and_lock_columns_to_item_fields_table.php b/database/migrations/2025_11_28_171952_add_field_key_and_lock_columns_to_item_fields_table.php new file mode 100644 index 0000000..dfa5463 --- /dev/null +++ b/database/migrations/2025_11_28_171952_add_field_key_and_lock_columns_to_item_fields_table.php @@ -0,0 +1,46 @@ +string('field_key', 100)->nullable()->after('field_name')->comment('필드 고유 키 ({ID}_{key} 형식)'); + $table->boolean('is_locked')->default(false)->after('is_common')->comment('잠금 여부'); + $table->unsignedBigInteger('locked_by')->nullable()->after('is_locked')->comment('잠금 설정자 ID'); + $table->timestamp('locked_at')->nullable()->after('locked_by')->comment('잠금 설정 일시'); + + // 인덱스 + $table->unique(['tenant_id', 'field_key'], 'uq_item_fields_tenant_field_key'); + $table->index('is_locked', 'idx_item_fields_is_locked'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('item_fields', function (Blueprint $table) { + $table->dropUnique('uq_item_fields_tenant_field_key'); + $table->dropIndex('idx_item_fields_is_locked'); + $table->dropColumn(['field_key', 'is_locked', 'locked_by', 'locked_at']); + }); + } +};