- ItemField 모델 및 SystemFieldDefinitions 상수 클래스 추가 - ItemFieldSeedingService: 시스템 필드 시딩/초기화/커스텀 필드 CRUD - ItemFieldController (API): HTMX 기반 시딩 상태, 커스텀 필드 관리 - 커스텀 필드 수정 기능 (시스템 필드는 source_table/field_key 수정 불가) - 레거시 데이터 표시 개선: 소스 테이블 비어있으면 '미지정' 배지 - 필드 키 정책 변경: 숫자로 시작 허용 (영문/숫자/밑줄) - AI 문의하기: 시딩 오류 보고서 생성 기능 - 사이드바에 품목기준 필드 관리 메뉴 추가
510 lines
17 KiB
PHP
510 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Constants\SystemFieldDefinitions;
|
|
use App\Models\ItemField;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* 품목기준 필드 시딩 서비스
|
|
*
|
|
* 테넌트별 시스템 필드 시딩 및 커스텀 필드 관리
|
|
*/
|
|
class ItemFieldSeedingService
|
|
{
|
|
/**
|
|
* 최근 오류 목록 (세션 저장용)
|
|
*/
|
|
protected array $errors = [];
|
|
|
|
/**
|
|
* 테이블별 시딩 상태 조회
|
|
*/
|
|
public function getSeedingStatus(int $tenantId): array
|
|
{
|
|
$statuses = [];
|
|
|
|
foreach (SystemFieldDefinitions::SOURCE_TABLES as $sourceTable => $label) {
|
|
$totalCount = SystemFieldDefinitions::getTotalFieldCount($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;
|
|
}
|
|
|
|
/**
|
|
* 단일 테이블 시스템 필드 시딩
|
|
*/
|
|
public function seedTable(int $tenantId, string $sourceTable): array
|
|
{
|
|
$fields = SystemFieldDefinitions::getFieldsFor($sourceTable);
|
|
|
|
if (empty($fields)) {
|
|
return [
|
|
'success' => false,
|
|
'message' => '알 수 없는 소스 테이블입니다.',
|
|
'seeded_count' => 0,
|
|
];
|
|
}
|
|
|
|
$seededCount = 0;
|
|
$skippedCount = 0;
|
|
$errorCount = 0;
|
|
$this->errors = [];
|
|
|
|
foreach ($fields as $fieldData) {
|
|
try {
|
|
// 이미 존재하는지 확인 (field_key + source_table 조합)
|
|
$exists = ItemField::where('tenant_id', $tenantId)
|
|
->where('source_table', $sourceTable)
|
|
->where('field_key', $fieldData['field_key'])
|
|
->exists();
|
|
|
|
if ($exists) {
|
|
$skippedCount++;
|
|
|
|
continue;
|
|
}
|
|
|
|
// DB 유니크 제약조건 충돌 방지: tenant_id + field_key 조합 체크
|
|
// (DB에 source_table 없이 유니크 키가 있는 경우를 대비)
|
|
$existsGlobal = ItemField::where('tenant_id', $tenantId)
|
|
->where('field_key', $fieldData['field_key'])
|
|
->exists();
|
|
|
|
if ($existsGlobal) {
|
|
// 다른 source_table에 같은 field_key가 있으면 고유한 키로 변환
|
|
$fieldKey = "{$sourceTable}_{$fieldData['field_key']}";
|
|
|
|
// 변환된 키도 이미 존재하는지 확인
|
|
$existsTransformed = ItemField::where('tenant_id', $tenantId)
|
|
->where('field_key', $fieldKey)
|
|
->exists();
|
|
|
|
if ($existsTransformed) {
|
|
$skippedCount++;
|
|
|
|
continue;
|
|
}
|
|
} else {
|
|
$fieldKey = $fieldData['field_key'];
|
|
}
|
|
|
|
// 필드 생성
|
|
ItemField::create([
|
|
'tenant_id' => $tenantId,
|
|
'group_id' => 1, // 품목관리 그룹
|
|
'field_key' => $fieldKey,
|
|
'field_name' => $fieldData['field_name'],
|
|
'field_type' => $fieldData['field_type'],
|
|
'order_no' => $fieldData['order_no'] ?? 0,
|
|
'is_required' => $fieldData['is_required'] ?? false,
|
|
'default_value' => $fieldData['default_value'] ?? null,
|
|
'options' => $fieldData['options'] ?? null,
|
|
'source_table' => $sourceTable,
|
|
'source_column' => $fieldData['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' => $fieldData['field_key'],
|
|
'field_name' => $fieldData['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' => $fieldData['field_key'],
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
} catch (\Exception $e) {
|
|
$errorCount++;
|
|
$this->errors[] = [
|
|
'field_key' => $fieldData['field_key'],
|
|
'field_name' => $fieldData['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' => $fieldData['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;
|
|
|
|
foreach (array_keys(SystemFieldDefinitions::SOURCE_TABLES) 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;
|
|
|
|
foreach (array_keys(SystemFieldDefinitions::SOURCE_TABLES) 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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 커스텀 필드 목록 조회
|
|
*/
|
|
public function getCustomFields(int $tenantId, array $filters = []): Collection
|
|
{
|
|
$query = ItemField::where('tenant_id', $tenantId)
|
|
->where('storage_type', 'json');
|
|
|
|
// 소스 테이블 필터
|
|
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}%");
|
|
});
|
|
}
|
|
|
|
return $query->orderBy('source_table')
|
|
->orderBy('order_no')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* 커스텀 필드 추가
|
|
*/
|
|
public function createCustomField(int $tenantId, array $data): array
|
|
{
|
|
$sourceTable = $data['source_table'];
|
|
$fieldKey = $data['field_key'];
|
|
|
|
// 시스템 예약어 체크
|
|
$systemFieldKeys = SystemFieldDefinitions::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,
|
|
];
|
|
}
|
|
}
|