## 주요 변경사항
### 1. 시스템 필드 정의 DB 마이그레이션
- 기존 하드코딩된 Constants/SystemFieldDefinitions.php 제거
- 신규 Models/SystemFieldDefinition.php 모델 추가
- system_field_definitions 테이블 기반 동적 관리
### 2. 테이블 등록 시 자동 필드 생성
- DB 실제 테이블 목록에서 선택하여 등록
- MySQL INFORMATION_SCHEMA에서 컬럼 정보 자동 조회
- COLUMN_COMMENT를 필드명(한글)으로 사용
- IS_NULLABLE로 필수 여부 자동 설정
- 시스템 컬럼(id, tenant_id, timestamps 등) 자동 제외
### 3. 필드명 동기화 기능
- 기존 등록된 테이블의 필드명을 DB COMMENT로 업데이트
- POST /source-tables/{table}/sync-field-names API 추가
### 4. 시딩 상태 계산 수정
- getFieldCountFor(): is_seed_default=true인 필드만 카운트
- getTotalFieldCountFor(): 전체 활성 필드 카운트 (신규)
- "제외" 필드가 있어도 시딩 완료 상태 정상 표시
### 5. UI 개선
- 시스템 필드 정의 탭에서 테이블별 관리
- 테이블 헤더에 "필드명 동기화", "삭제" 버튼 추가
- 테이블 선택 모달에서 COMMENT(한글명) 표시
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
565 lines
19 KiB
PHP
565 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\ItemField;
|
|
use App\Models\SystemFieldDefinition;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* 품목기준 필드 시딩 서비스
|
|
*
|
|
* 테넌트별 시스템 필드 시딩 및 커스텀 필드 관리
|
|
* DB 기반 시스템 필드 정의 사용 (system_field_definitions 테이블)
|
|
*/
|
|
class ItemFieldSeedingService
|
|
{
|
|
/**
|
|
* 최근 오류 목록 (세션 저장용)
|
|
*/
|
|
protected array $errors = [];
|
|
|
|
/**
|
|
* 테이블별 시딩 상태 조회
|
|
*/
|
|
public function getSeedingStatus(int $tenantId): array
|
|
{
|
|
$statuses = [];
|
|
|
|
$sourceTables = SystemFieldDefinition::getSourceTableOptions();
|
|
|
|
foreach ($sourceTables as $sourceTable => $label) {
|
|
$totalCount = SystemFieldDefinition::getFieldCountFor($sourceTable);
|
|
|
|
// 현재 등록된 시스템 필드 수
|
|
$currentCount = ItemField::where('tenant_id', $tenantId)
|
|
->where('source_table', $sourceTable)
|
|
->where('storage_type', 'column')
|
|
->count();
|
|
|
|
$statuses[$sourceTable] = [
|
|
'label' => $label,
|
|
'source_table' => $sourceTable,
|
|
'total_count' => $totalCount,
|
|
'current_count' => $currentCount,
|
|
'is_complete' => $currentCount >= $totalCount,
|
|
'status' => $currentCount >= $totalCount ? 'complete' : ($currentCount > 0 ? 'partial' : 'empty'),
|
|
];
|
|
}
|
|
|
|
return $statuses;
|
|
}
|
|
|
|
/**
|
|
* 특정 소스 테이블의 시딩 가능한 필드 목록 조회
|
|
*
|
|
* @param bool $onlyDefault true면 기본 시딩 대상만, false면 전체
|
|
*/
|
|
public function getAvailableFields(string $sourceTable, bool $onlyDefault = false): Collection
|
|
{
|
|
if ($onlyDefault) {
|
|
return SystemFieldDefinition::getSeedDefaultFieldsFor($sourceTable);
|
|
}
|
|
|
|
return SystemFieldDefinition::getFieldsFor($sourceTable);
|
|
}
|
|
|
|
/**
|
|
* 단일 테이블 시스템 필드 시딩
|
|
*
|
|
* @param array|null $fieldIds 시딩할 필드 ID 목록 (null이면 is_seed_default=true인 것만)
|
|
*/
|
|
public function seedTable(int $tenantId, string $sourceTable, ?array $fieldIds = null): array
|
|
{
|
|
// 필드 ID가 지정되면 해당 필드만, 아니면 기본 시딩 대상만
|
|
if ($fieldIds !== null) {
|
|
$fields = SystemFieldDefinition::whereIn('id', $fieldIds)
|
|
->where('source_table', $sourceTable)
|
|
->where('is_active', true)
|
|
->orderBy('order_no')
|
|
->get();
|
|
} else {
|
|
$fields = SystemFieldDefinition::getSeedDefaultFieldsFor($sourceTable);
|
|
}
|
|
|
|
if ($fields->isEmpty()) {
|
|
return [
|
|
'success' => false,
|
|
'message' => '시딩할 필드가 없습니다.',
|
|
'seeded_count' => 0,
|
|
];
|
|
}
|
|
|
|
$seededCount = 0;
|
|
$skippedCount = 0;
|
|
$errorCount = 0;
|
|
$this->errors = [];
|
|
|
|
foreach ($fields as $fieldDef) {
|
|
try {
|
|
// 이미 존재하는지 확인 (field_key + source_table 조합)
|
|
$exists = ItemField::where('tenant_id', $tenantId)
|
|
->where('source_table', $sourceTable)
|
|
->where('field_key', $fieldDef->field_key)
|
|
->exists();
|
|
|
|
if ($exists) {
|
|
$skippedCount++;
|
|
|
|
continue;
|
|
}
|
|
|
|
// DB 유니크 제약조건 충돌 방지: tenant_id + field_key 조합 체크
|
|
$existsGlobal = ItemField::where('tenant_id', $tenantId)
|
|
->where('field_key', $fieldDef->field_key)
|
|
->exists();
|
|
|
|
if ($existsGlobal) {
|
|
// 다른 source_table에 같은 field_key가 있으면 고유한 키로 변환
|
|
$fieldKey = "{$sourceTable}_{$fieldDef->field_key}";
|
|
|
|
// 변환된 키도 이미 존재하는지 확인
|
|
$existsTransformed = ItemField::where('tenant_id', $tenantId)
|
|
->where('field_key', $fieldKey)
|
|
->exists();
|
|
|
|
if ($existsTransformed) {
|
|
$skippedCount++;
|
|
|
|
continue;
|
|
}
|
|
} else {
|
|
$fieldKey = $fieldDef->field_key;
|
|
}
|
|
|
|
// 필드 생성
|
|
ItemField::create([
|
|
'tenant_id' => $tenantId,
|
|
'group_id' => 1, // 품목관리 그룹
|
|
'field_key' => $fieldKey,
|
|
'field_name' => $fieldDef->field_name,
|
|
'field_type' => $fieldDef->field_type,
|
|
'order_no' => $fieldDef->order_no ?? 0,
|
|
'is_required' => $fieldDef->is_required ?? false,
|
|
'default_value' => $fieldDef->default_value ?? null,
|
|
'options' => $fieldDef->options ?? null,
|
|
'source_table' => $sourceTable,
|
|
'source_column' => $fieldDef->field_key, // 원본 컬럼명 유지
|
|
'storage_type' => 'column',
|
|
'is_active' => true,
|
|
'is_common' => true,
|
|
'created_by' => auth()->id(),
|
|
]);
|
|
|
|
$seededCount++;
|
|
|
|
} catch (\Illuminate\Database\QueryException $e) {
|
|
$errorCount++;
|
|
$errorInfo = [
|
|
'field_key' => $fieldDef->field_key,
|
|
'field_name' => $fieldDef->field_name,
|
|
'source_table' => $sourceTable,
|
|
'error_code' => $e->getCode(),
|
|
'error_message' => $e->getMessage(),
|
|
'sql_state' => $e->errorInfo[0] ?? null,
|
|
'timestamp' => now()->toDateTimeString(),
|
|
];
|
|
$this->errors[] = $errorInfo;
|
|
|
|
Log::error('ItemField 시딩 오류', [
|
|
'tenant_id' => $tenantId,
|
|
'source_table' => $sourceTable,
|
|
'field_key' => $fieldDef->field_key,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
} catch (\Exception $e) {
|
|
$errorCount++;
|
|
$this->errors[] = [
|
|
'field_key' => $fieldDef->field_key,
|
|
'field_name' => $fieldDef->field_name,
|
|
'source_table' => $sourceTable,
|
|
'error_code' => 'UNKNOWN',
|
|
'error_message' => $e->getMessage(),
|
|
'timestamp' => now()->toDateTimeString(),
|
|
];
|
|
|
|
Log::error('ItemField 시딩 예외', [
|
|
'tenant_id' => $tenantId,
|
|
'source_table' => $sourceTable,
|
|
'field_key' => $fieldDef->field_key,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 오류가 있으면 세션에 저장
|
|
if (! empty($this->errors)) {
|
|
$this->saveErrorsToSession($tenantId, $sourceTable);
|
|
}
|
|
|
|
$success = $errorCount === 0;
|
|
$message = $success
|
|
? "시딩 완료: {$seededCount}개 추가, {$skippedCount}개 건너뜀"
|
|
: "시딩 부분 완료: {$seededCount}개 추가, {$skippedCount}개 건너뜀, {$errorCount}개 오류";
|
|
|
|
return [
|
|
'success' => $success,
|
|
'message' => $message,
|
|
'seeded_count' => $seededCount,
|
|
'skipped_count' => $skippedCount,
|
|
'error_count' => $errorCount,
|
|
'errors' => $this->errors,
|
|
'has_errors' => $errorCount > 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 오류 정보를 세션에 저장
|
|
*/
|
|
protected function saveErrorsToSession(int $tenantId, string $sourceTable): void
|
|
{
|
|
$errorLog = [
|
|
'tenant_id' => $tenantId,
|
|
'source_table' => $sourceTable,
|
|
'errors' => $this->errors,
|
|
'timestamp' => now()->toDateTimeString(),
|
|
'user_id' => auth()->id(),
|
|
'user_name' => auth()->user()?->name,
|
|
];
|
|
|
|
// 기존 오류 로그 가져오기
|
|
$existingLogs = session('item_field_errors', []);
|
|
|
|
// 최신 오류를 앞에 추가 (최대 10개 유지)
|
|
array_unshift($existingLogs, $errorLog);
|
|
$existingLogs = array_slice($existingLogs, 0, 10);
|
|
|
|
session(['item_field_errors' => $existingLogs]);
|
|
}
|
|
|
|
/**
|
|
* 저장된 오류 로그 조회
|
|
*/
|
|
public function getErrorLogs(): array
|
|
{
|
|
return session('item_field_errors', []);
|
|
}
|
|
|
|
/**
|
|
* 오류 로그 초기화
|
|
*/
|
|
public function clearErrorLogs(): void
|
|
{
|
|
session()->forget('item_field_errors');
|
|
}
|
|
|
|
/**
|
|
* 전체 테이블 시스템 필드 시딩
|
|
*/
|
|
public function seedAll(int $tenantId): array
|
|
{
|
|
$results = [];
|
|
$totalSeeded = 0;
|
|
$totalSkipped = 0;
|
|
|
|
$sourceTables = array_keys(SystemFieldDefinition::getSourceTableOptions());
|
|
|
|
foreach ($sourceTables as $sourceTable) {
|
|
$result = $this->seedTable($tenantId, $sourceTable);
|
|
$results[$sourceTable] = $result;
|
|
$totalSeeded += $result['seeded_count'];
|
|
$totalSkipped += $result['skipped_count'] ?? 0;
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => "전체 시딩 완료: {$totalSeeded}개 추가, {$totalSkipped}개 건너뜀",
|
|
'total_seeded' => $totalSeeded,
|
|
'total_skipped' => $totalSkipped,
|
|
'details' => $results,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 단일 테이블 시스템 필드 초기화 (삭제 후 재시딩)
|
|
*/
|
|
public function resetTable(int $tenantId, string $sourceTable): array
|
|
{
|
|
// 시스템 필드만 삭제 (커스텀 필드는 유지)
|
|
$deletedCount = ItemField::where('tenant_id', $tenantId)
|
|
->where('source_table', $sourceTable)
|
|
->where('storage_type', 'column')
|
|
->forceDelete();
|
|
|
|
// 재시딩
|
|
$seedResult = $this->seedTable($tenantId, $sourceTable);
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => "초기화 완료: {$deletedCount}개 삭제, {$seedResult['seeded_count']}개 재등록",
|
|
'deleted_count' => $deletedCount,
|
|
'seeded_count' => $seedResult['seeded_count'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 전체 테이블 시스템 필드 초기화
|
|
*/
|
|
public function resetAll(int $tenantId): array
|
|
{
|
|
$results = [];
|
|
$totalDeleted = 0;
|
|
$totalSeeded = 0;
|
|
|
|
$sourceTables = array_keys(SystemFieldDefinition::getSourceTableOptions());
|
|
|
|
foreach ($sourceTables as $sourceTable) {
|
|
$result = $this->resetTable($tenantId, $sourceTable);
|
|
$results[$sourceTable] = $result;
|
|
$totalDeleted += $result['deleted_count'];
|
|
$totalSeeded += $result['seeded_count'];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => "전체 초기화 완료: {$totalDeleted}개 삭제, {$totalSeeded}개 재등록",
|
|
'total_deleted' => $totalDeleted,
|
|
'total_seeded' => $totalSeeded,
|
|
'details' => $results,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 필드 목록 조회 (시스템 + 커스텀)
|
|
*
|
|
* @param array $filters source_table, field_type, field_category (system|custom), search
|
|
*/
|
|
public function getFields(int $tenantId, array $filters = []): Collection
|
|
{
|
|
$query = ItemField::where('tenant_id', $tenantId);
|
|
|
|
// 필드 유형 필터 (system=is_common:1, custom=is_common:0)
|
|
if (! empty($filters['field_category'])) {
|
|
if ($filters['field_category'] === 'system') {
|
|
$query->where('is_common', true);
|
|
} elseif ($filters['field_category'] === 'custom') {
|
|
$query->where('is_common', false);
|
|
}
|
|
}
|
|
|
|
// 소스 테이블 필터
|
|
if (! empty($filters['source_table'])) {
|
|
$query->where('source_table', $filters['source_table']);
|
|
}
|
|
|
|
// 필드 타입 필터
|
|
if (! empty($filters['field_type'])) {
|
|
$query->where('field_type', $filters['field_type']);
|
|
}
|
|
|
|
// 검색
|
|
if (! empty($filters['search'])) {
|
|
$search = $filters['search'];
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('field_key', 'like', "%{$search}%")
|
|
->orWhere('field_name', 'like', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
// 시스템 필드 우선 정렬 (is_common DESC), 그 다음 source_table, order_no
|
|
return $query->orderByDesc('is_common')
|
|
->orderBy('source_table')
|
|
->orderBy('order_no')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* 커스텀 필드 목록 조회 (기존 호환성 유지)
|
|
*
|
|
* @deprecated Use getFields() instead
|
|
*/
|
|
public function getCustomFields(int $tenantId, array $filters = []): Collection
|
|
{
|
|
// 기존 호환성 유지: 커스텀 필드만 반환
|
|
$filters['field_category'] = 'custom';
|
|
|
|
return $this->getFields($tenantId, $filters);
|
|
}
|
|
|
|
/**
|
|
* 커스텀 필드 추가
|
|
*/
|
|
public function createCustomField(int $tenantId, array $data): array
|
|
{
|
|
$sourceTable = $data['source_table'];
|
|
$fieldKey = $data['field_key'];
|
|
|
|
// 시스템 예약어 체크
|
|
$systemFieldKeys = SystemFieldDefinition::getAllSystemFieldKeys($sourceTable);
|
|
if (in_array($fieldKey, $systemFieldKeys)) {
|
|
return [
|
|
'success' => false,
|
|
'message' => "\"{$fieldKey}\"은(는) 시스템 예약어로 사용할 수 없습니다.",
|
|
];
|
|
}
|
|
|
|
// 기존 필드 키 중복 체크
|
|
$exists = ItemField::where('tenant_id', $tenantId)
|
|
->where('source_table', $sourceTable)
|
|
->where('field_key', $fieldKey)
|
|
->exists();
|
|
|
|
if ($exists) {
|
|
return [
|
|
'success' => false,
|
|
'message' => "\"{$fieldKey}\"는 이미 사용 중인 필드 키입니다.",
|
|
];
|
|
}
|
|
|
|
// 커스텀 필드 생성
|
|
$field = ItemField::create([
|
|
'tenant_id' => $tenantId,
|
|
'group_id' => 1,
|
|
'field_key' => $fieldKey,
|
|
'field_name' => $data['field_name'],
|
|
'field_type' => $data['field_type'],
|
|
'order_no' => $data['order_no'] ?? 0,
|
|
'is_required' => $data['is_required'] ?? false,
|
|
'default_value' => $data['default_value'] ?? null,
|
|
'options' => $data['options'] ?? null,
|
|
'source_table' => $sourceTable,
|
|
'source_column' => null,
|
|
'storage_type' => 'json',
|
|
'json_path' => "attributes.{$fieldKey}",
|
|
'is_active' => true,
|
|
'is_common' => false,
|
|
'created_by' => auth()->id(),
|
|
]);
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => '커스텀 필드가 추가되었습니다.',
|
|
'field' => $field,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 커스텀 필드 수정
|
|
*/
|
|
public function updateCustomField(int $tenantId, int $fieldId, array $data): array
|
|
{
|
|
$field = ItemField::where('tenant_id', $tenantId)
|
|
->where('id', $fieldId)
|
|
->first();
|
|
|
|
if (! $field) {
|
|
return [
|
|
'success' => false,
|
|
'message' => '필드를 찾을 수 없습니다.',
|
|
];
|
|
}
|
|
|
|
// 시스템 필드(storage_type = column)의 경우 일부 필드만 수정 가능
|
|
$isSystemField = $field->storage_type === 'column';
|
|
|
|
// field_key 변경 시 중복 체크 (시스템 필드는 field_key 변경 불가)
|
|
if (! $isSystemField && ! empty($data['field_key']) && $data['field_key'] !== $field->field_key) {
|
|
$exists = ItemField::where('tenant_id', $tenantId)
|
|
->where('field_key', $data['field_key'])
|
|
->where('id', '!=', $fieldId)
|
|
->exists();
|
|
|
|
if ($exists) {
|
|
return [
|
|
'success' => false,
|
|
'message' => "\"{$data['field_key']}\"는 이미 사용 중인 필드 키입니다.",
|
|
];
|
|
}
|
|
}
|
|
|
|
// 업데이트 데이터 준비
|
|
$updateData = [
|
|
'field_name' => $data['field_name'],
|
|
'field_type' => $data['field_type'],
|
|
'is_required' => $data['is_required'] ?? false,
|
|
'default_value' => $data['default_value'] ?? null,
|
|
'options' => $data['options'] ?? null,
|
|
'updated_by' => auth()->id(),
|
|
];
|
|
|
|
// 커스텀 필드만 source_table, field_key 변경 가능
|
|
if (! $isSystemField) {
|
|
if (isset($data['source_table'])) {
|
|
$updateData['source_table'] = $data['source_table'];
|
|
}
|
|
if (isset($data['field_key'])) {
|
|
$updateData['field_key'] = $data['field_key'];
|
|
$updateData['json_path'] = "attributes.{$data['field_key']}";
|
|
}
|
|
}
|
|
|
|
$field->update($updateData);
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => '커스텀 필드가 수정되었습니다.',
|
|
'field' => $field->fresh(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 커스텀 필드 삭제
|
|
*/
|
|
public function deleteCustomField(int $tenantId, int $fieldId): array
|
|
{
|
|
$field = ItemField::where('tenant_id', $tenantId)
|
|
->where('id', $fieldId)
|
|
->first();
|
|
|
|
if (! $field) {
|
|
return [
|
|
'success' => false,
|
|
'message' => '필드를 찾을 수 없습니다.',
|
|
];
|
|
}
|
|
|
|
// 시스템 필드는 삭제 불가
|
|
if ($field->storage_type === 'column') {
|
|
return [
|
|
'success' => false,
|
|
'message' => '시스템 필드는 삭제할 수 없습니다. 초기화 기능을 사용하세요.',
|
|
];
|
|
}
|
|
|
|
$field->update(['deleted_by' => auth()->id()]);
|
|
$field->delete();
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => '커스텀 필드가 삭제되었습니다.',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 커스텀 필드 일괄 삭제
|
|
*/
|
|
public function deleteCustomFields(int $tenantId, array $fieldIds): array
|
|
{
|
|
$deletedCount = 0;
|
|
|
|
foreach ($fieldIds as $fieldId) {
|
|
$result = $this->deleteCustomField($tenantId, $fieldId);
|
|
if ($result['success']) {
|
|
$deletedCount++;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => "{$deletedCount}개의 커스텀 필드가 삭제되었습니다.",
|
|
'deleted_count' => $deletedCount,
|
|
];
|
|
}
|
|
}
|