품목기준 필드 관리 기능 구현
- ItemField 모델 및 SystemFieldDefinitions 상수 클래스 추가 - ItemFieldSeedingService: 시스템 필드 시딩/초기화/커스텀 필드 CRUD - ItemFieldController (API): HTMX 기반 시딩 상태, 커스텀 필드 관리 - 커스텀 필드 수정 기능 (시스템 필드는 source_table/field_key 수정 불가) - 레거시 데이터 표시 개선: 소스 테이블 비어있으면 '미지정' 배지 - 필드 키 정책 변경: 숫자로 시작 허용 (영문/숫자/밑줄) - AI 문의하기: 시딩 오류 보고서 생성 기능 - 사이드바에 품목기준 필드 관리 메뉴 추가
This commit is contained in:
139
app/Constants/SystemFieldDefinitions.php
Normal file
139
app/Constants/SystemFieldDefinitions.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Constants;
|
||||
|
||||
/**
|
||||
* 테이블별 시스템 필드 정의
|
||||
*
|
||||
* 시딩 시 사용되는 테이블별 고정 컬럼 필드 정의입니다.
|
||||
*/
|
||||
class SystemFieldDefinitions
|
||||
{
|
||||
/**
|
||||
* products 테이블 시스템 필드 정의
|
||||
*/
|
||||
public const PRODUCTS = [
|
||||
['field_key' => 'code', 'field_name' => '품목코드', 'field_type' => 'textbox', 'is_required' => true, 'order_no' => 1],
|
||||
['field_key' => 'name', 'field_name' => '품목명', 'field_type' => 'textbox', 'is_required' => true, 'order_no' => 2],
|
||||
['field_key' => 'unit', 'field_name' => '단위', 'field_type' => 'dropdown', 'is_required' => true, 'order_no' => 3],
|
||||
['field_key' => 'product_type', 'field_name' => '제품유형', 'field_type' => 'dropdown', 'order_no' => 4, 'options' => [
|
||||
['label' => '완제품', 'value' => 'FG'],
|
||||
['label' => '부품', 'value' => 'PT'],
|
||||
]],
|
||||
['field_key' => 'category_id', 'field_name' => '카테고리', 'field_type' => 'dropdown', 'order_no' => 5],
|
||||
['field_key' => 'is_sellable', 'field_name' => '판매가능', 'field_type' => 'checkbox', 'order_no' => 6, 'default_value' => 'true'],
|
||||
['field_key' => 'is_purchasable', 'field_name' => '구매가능', 'field_type' => 'checkbox', 'order_no' => 7],
|
||||
['field_key' => 'is_producible', 'field_name' => '생산가능', 'field_type' => 'checkbox', 'order_no' => 8, 'default_value' => 'true'],
|
||||
['field_key' => 'is_active', 'field_name' => '활성화', 'field_type' => 'checkbox', 'order_no' => 9, 'default_value' => 'true'],
|
||||
['field_key' => 'certification_number', 'field_name' => '인증번호', 'field_type' => 'textbox', 'order_no' => 10],
|
||||
['field_key' => 'certification_start_date', 'field_name' => '인증시작일', 'field_type' => 'date', 'order_no' => 11],
|
||||
['field_key' => 'certification_end_date', 'field_name' => '인증만료일', 'field_type' => 'date', 'order_no' => 12],
|
||||
];
|
||||
|
||||
/**
|
||||
* materials 테이블 시스템 필드 정의
|
||||
*/
|
||||
public const MATERIALS = [
|
||||
['field_key' => 'material_code', 'field_name' => '자재코드', 'field_type' => 'textbox', 'is_required' => true, 'order_no' => 1],
|
||||
['field_key' => 'name', 'field_name' => '자재명', 'field_type' => 'textbox', 'is_required' => true, 'order_no' => 2],
|
||||
['field_key' => 'item_name', 'field_name' => '품목명', 'field_type' => 'textbox', 'order_no' => 3],
|
||||
['field_key' => 'specification', 'field_name' => '규격', 'field_type' => 'textbox', 'order_no' => 4],
|
||||
['field_key' => 'unit', 'field_name' => '단위', 'field_type' => 'dropdown', 'is_required' => true, 'order_no' => 5],
|
||||
['field_key' => 'category_id', 'field_name' => '카테고리', 'field_type' => 'dropdown', 'order_no' => 6],
|
||||
['field_key' => 'is_inspection', 'field_name' => '검수필요', 'field_type' => 'checkbox', 'order_no' => 7],
|
||||
['field_key' => 'search_tag', 'field_name' => '검색태그', 'field_type' => 'textarea', 'order_no' => 8],
|
||||
];
|
||||
|
||||
/**
|
||||
* product_components 테이블 (BOM) 시스템 필드 정의
|
||||
*/
|
||||
public const PRODUCT_COMPONENTS = [
|
||||
['field_key' => 'ref_type', 'field_name' => '참조유형', 'field_type' => 'dropdown', 'order_no' => 1, 'options' => [
|
||||
['label' => '제품', 'value' => 'product'],
|
||||
['label' => '자재', 'value' => 'material'],
|
||||
]],
|
||||
['field_key' => 'ref_id', 'field_name' => '참조품목', 'field_type' => 'dropdown', 'order_no' => 2],
|
||||
['field_key' => 'quantity', 'field_name' => '수량', 'field_type' => 'number', 'is_required' => true, 'order_no' => 3],
|
||||
['field_key' => 'formula', 'field_name' => '계산공식', 'field_type' => 'textbox', 'order_no' => 4],
|
||||
['field_key' => 'note', 'field_name' => '비고', 'field_type' => 'textarea', 'order_no' => 5],
|
||||
];
|
||||
|
||||
/**
|
||||
* material_inspections 테이블 시스템 필드 정의
|
||||
*/
|
||||
public const MATERIAL_INSPECTIONS = [
|
||||
['field_key' => 'inspection_date', 'field_name' => '검수일', 'field_type' => 'date', 'is_required' => true, 'order_no' => 1],
|
||||
['field_key' => 'inspector_id', 'field_name' => '검수자', 'field_type' => 'dropdown', 'order_no' => 2],
|
||||
['field_key' => 'status', 'field_name' => '검수상태', 'field_type' => 'dropdown', 'order_no' => 3, 'options' => [
|
||||
['label' => '대기', 'value' => 'pending'],
|
||||
['label' => '진행중', 'value' => 'in_progress'],
|
||||
['label' => '완료', 'value' => 'completed'],
|
||||
['label' => '불합격', 'value' => 'rejected'],
|
||||
]],
|
||||
['field_key' => 'lot_no', 'field_name' => 'LOT번호', 'field_type' => 'textbox', 'order_no' => 4],
|
||||
['field_key' => 'quantity', 'field_name' => '검수수량', 'field_type' => 'number', 'order_no' => 5],
|
||||
['field_key' => 'passed_quantity', 'field_name' => '합격수량', 'field_type' => 'number', 'order_no' => 6],
|
||||
['field_key' => 'rejected_quantity', 'field_name' => '불합격수량', 'field_type' => 'number', 'order_no' => 7],
|
||||
['field_key' => 'note', 'field_name' => '비고', 'field_type' => 'textarea', 'order_no' => 8],
|
||||
];
|
||||
|
||||
/**
|
||||
* material_receipts 테이블 시스템 필드 정의
|
||||
*/
|
||||
public const MATERIAL_RECEIPTS = [
|
||||
['field_key' => 'receipt_date', 'field_name' => '입고일', 'field_type' => 'date', 'is_required' => true, 'order_no' => 1],
|
||||
['field_key' => 'lot_no', 'field_name' => 'LOT번호', 'field_type' => 'textbox', 'order_no' => 2],
|
||||
['field_key' => 'quantity', 'field_name' => '입고수량', 'field_type' => 'number', 'is_required' => true, 'order_no' => 3],
|
||||
['field_key' => 'unit_price', 'field_name' => '단가', 'field_type' => 'number', 'order_no' => 4],
|
||||
['field_key' => 'total_price', 'field_name' => '금액', 'field_type' => 'number', 'order_no' => 5],
|
||||
['field_key' => 'supplier_id', 'field_name' => '공급업체', 'field_type' => 'dropdown', 'order_no' => 6],
|
||||
['field_key' => 'warehouse_id', 'field_name' => '입고창고', 'field_type' => 'dropdown', 'order_no' => 7],
|
||||
['field_key' => 'po_number', 'field_name' => '발주번호', 'field_type' => 'textbox', 'order_no' => 8],
|
||||
['field_key' => 'invoice_number', 'field_name' => '송장번호', 'field_type' => 'textbox', 'order_no' => 9],
|
||||
['field_key' => 'note', 'field_name' => '비고', 'field_type' => 'textarea', 'order_no' => 10],
|
||||
];
|
||||
|
||||
/**
|
||||
* 소스 테이블 목록
|
||||
*/
|
||||
public const SOURCE_TABLES = [
|
||||
'products' => '제품',
|
||||
'materials' => '자재',
|
||||
'product_components' => 'BOM',
|
||||
'material_inspections' => '자재검수',
|
||||
'material_receipts' => '자재입고',
|
||||
];
|
||||
|
||||
/**
|
||||
* 소스 테이블별 필드 정의 가져오기
|
||||
*/
|
||||
public static function getFieldsFor(string $sourceTable): array
|
||||
{
|
||||
return match ($sourceTable) {
|
||||
'products' => self::PRODUCTS,
|
||||
'materials' => self::MATERIALS,
|
||||
'product_components' => self::PRODUCT_COMPONENTS,
|
||||
'material_inspections' => self::MATERIAL_INSPECTIONS,
|
||||
'material_receipts' => self::MATERIAL_RECEIPTS,
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 테이블 필드 수 조회
|
||||
*/
|
||||
public static function getTotalFieldCount(string $sourceTable): int
|
||||
{
|
||||
return count(self::getFieldsFor($sourceTable));
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 소스 테이블의 시스템 필드 키 목록
|
||||
*/
|
||||
public static function getAllSystemFieldKeys(string $sourceTable): array
|
||||
{
|
||||
$fields = self::getFieldsFor($sourceTable);
|
||||
|
||||
return array_column($fields, 'field_key');
|
||||
}
|
||||
}
|
||||
366
app/Http/Controllers/Api/Admin/ItemFieldController.php
Normal file
366
app/Http/Controllers/Api/Admin/ItemFieldController.php
Normal file
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Constants\SystemFieldDefinitions;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ItemFieldSeedingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 품목기준 필드 관리 API 컨트롤러 (HTMX)
|
||||
*/
|
||||
class ItemFieldController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ItemFieldSeedingService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 시딩 상태 조회 (HTMX partial)
|
||||
*/
|
||||
public function seedingStatus(Request $request): View
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
Log::info('seedingStatus called', [
|
||||
'tenantId' => $tenantId,
|
||||
'session' => session()->all(),
|
||||
]);
|
||||
|
||||
if (! $tenantId || $tenantId === 'all') {
|
||||
return view('item-fields.partials.seeding-status', [
|
||||
'statuses' => [],
|
||||
'error' => '테넌트를 선택해주세요.',
|
||||
]);
|
||||
}
|
||||
|
||||
$statuses = $this->service->getSeedingStatus($tenantId);
|
||||
|
||||
Log::info('seedingStatus result', [
|
||||
'statuses_count' => count($statuses),
|
||||
'statuses' => $statuses,
|
||||
]);
|
||||
|
||||
return view('item-fields.partials.seeding-status', compact('statuses'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 테이블 시딩
|
||||
*/
|
||||
public function seed(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
if (! $tenantId || $tenantId === 'all') {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '테넌트를 선택해주세요.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$sourceTable = $request->input('source_table');
|
||||
|
||||
if (! $sourceTable) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '소스 테이블을 지정해주세요.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$result = $this->service->seedTable($tenantId, $sourceTable);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 테이블 시딩
|
||||
*/
|
||||
public function seedAll(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
if (! $tenantId || $tenantId === 'all') {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '테넌트를 선택해주세요.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$result = $this->service->seedAll($tenantId);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 테이블 초기화
|
||||
*/
|
||||
public function reset(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
if (! $tenantId || $tenantId === 'all') {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '테넌트를 선택해주세요.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$sourceTable = $request->input('source_table');
|
||||
|
||||
if (! $sourceTable) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '소스 테이블을 지정해주세요.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$result = $this->service->resetTable($tenantId, $sourceTable);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 테이블 초기화
|
||||
*/
|
||||
public function resetAll(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
if (! $tenantId || $tenantId === 'all') {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '테넌트를 선택해주세요.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$result = $this->service->resetAll($tenantId);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 필드 목록 (HTMX partial)
|
||||
*/
|
||||
public function customFields(Request $request): View
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
if (! $tenantId || $tenantId === 'all') {
|
||||
return view('item-fields.partials.custom-fields', [
|
||||
'fields' => collect([]),
|
||||
'sourceTables' => SystemFieldDefinitions::SOURCE_TABLES,
|
||||
'error' => '테넌트를 선택해주세요.',
|
||||
]);
|
||||
}
|
||||
|
||||
$fields = $this->service->getCustomFields($tenantId, $request->all());
|
||||
|
||||
return view('item-fields.partials.custom-fields', [
|
||||
'fields' => $fields,
|
||||
'sourceTables' => SystemFieldDefinitions::SOURCE_TABLES,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 필드 추가
|
||||
*/
|
||||
public function storeCustomField(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
if (! $tenantId || $tenantId === 'all') {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '테넌트를 선택해주세요.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'source_table' => 'required|string',
|
||||
'field_key' => 'required|string|max:100|regex:/^[a-zA-Z0-9_]+$/',
|
||||
'field_name' => 'required|string|max:255',
|
||||
'field_type' => 'required|string|in:textbox,number,dropdown,checkbox,date,textarea',
|
||||
'is_required' => 'nullable|boolean',
|
||||
'default_value' => 'nullable|string',
|
||||
'options' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$result = $this->service->createCustomField($tenantId, $validated);
|
||||
|
||||
return response()->json($result, $result['success'] ? 200 : 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 필드 수정
|
||||
*/
|
||||
public function updateCustomField(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
if (! $tenantId || $tenantId === 'all') {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '테넌트를 선택해주세요.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'source_table' => 'nullable|string',
|
||||
'field_key' => 'nullable|string|max:100|regex:/^[a-zA-Z0-9_]+$/',
|
||||
'field_name' => 'required|string|max:255',
|
||||
'field_type' => 'required|string|in:textbox,number,dropdown,checkbox,date,textarea',
|
||||
'is_required' => 'nullable|boolean',
|
||||
'default_value' => 'nullable|string',
|
||||
'options' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$result = $this->service->updateCustomField($tenantId, $id, $validated);
|
||||
|
||||
return response()->json($result, $result['success'] ? 200 : 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 필드 삭제
|
||||
*/
|
||||
public function destroyCustomField(int $id): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
if (! $tenantId || $tenantId === 'all') {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '테넌트를 선택해주세요.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$result = $this->service->deleteCustomField($tenantId, $id);
|
||||
|
||||
return response()->json($result, $result['success'] ? 200 : 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 필드 일괄 삭제
|
||||
*/
|
||||
public function destroyCustomFields(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
if (! $tenantId || $tenantId === 'all') {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '테넌트를 선택해주세요.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'ids' => 'required|array',
|
||||
'ids.*' => 'required|integer',
|
||||
]);
|
||||
|
||||
$result = $this->service->deleteCustomFields($tenantId, $validated['ids']);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스 테이블 목록 (JSON)
|
||||
*/
|
||||
public function sourceTables(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => SystemFieldDefinitions::SOURCE_TABLES,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 로그 조회 (HTMX partial)
|
||||
*/
|
||||
public function errorLogs(): View
|
||||
{
|
||||
$errorLogs = $this->service->getErrorLogs();
|
||||
|
||||
return view('item-fields.partials.error-logs', compact('errorLogs'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 로그 초기화
|
||||
*/
|
||||
public function clearErrorLogs(): JsonResponse
|
||||
{
|
||||
$this->service->clearErrorLogs();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '오류 로그가 초기화되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 문의용 오류 보고서 생성
|
||||
*/
|
||||
public function generateErrorReport(): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$errorLogs = $this->service->getErrorLogs();
|
||||
|
||||
if (empty($errorLogs)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '저장된 오류 로그가 없습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
$latestError = $errorLogs[0];
|
||||
|
||||
// 보고서 생성
|
||||
$report = $this->buildErrorReport($latestError, $tenantId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'report' => $report,
|
||||
'error_count' => count($latestError['errors'] ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 보고서 텍스트 생성
|
||||
*/
|
||||
protected function buildErrorReport(array $errorLog, ?int $tenantId): string
|
||||
{
|
||||
$errors = $errorLog['errors'] ?? [];
|
||||
$sourceTable = $errorLog['source_table'] ?? 'unknown';
|
||||
$timestamp = $errorLog['timestamp'] ?? now()->toDateTimeString();
|
||||
|
||||
$report = "## 품목기준 필드 시딩 오류 보고서\n\n";
|
||||
$report .= "### 기본 정보\n";
|
||||
$report .= "- **발생 시각**: {$timestamp}\n";
|
||||
$report .= "- **테넌트 ID**: {$tenantId}\n";
|
||||
$report .= "- **소스 테이블**: {$sourceTable}\n";
|
||||
$report .= '- **오류 수**: '.count($errors)."건\n\n";
|
||||
|
||||
$report .= "### 오류 상세\n";
|
||||
foreach ($errors as $index => $error) {
|
||||
$num = $index + 1;
|
||||
$report .= "\n#### 오류 #{$num}\n";
|
||||
$report .= "- **필드 키**: `{$error['field_key']}`\n";
|
||||
$report .= "- **필드명**: {$error['field_name']}\n";
|
||||
$report .= "- **오류 코드**: {$error['error_code']}\n";
|
||||
$report .= "- **오류 메시지**:\n```\n{$error['error_message']}\n```\n";
|
||||
}
|
||||
|
||||
$report .= "\n### 환경 정보\n";
|
||||
$report .= "- **애플리케이션**: MNG (품목기준 필드 관리)\n";
|
||||
$report .= "- **DB 테이블**: item_fields\n";
|
||||
$report .= "- **관련 파일**: `app/Services/ItemFieldSeedingService.php`\n";
|
||||
|
||||
$report .= "\n### 요청 사항\n";
|
||||
$report .= "위 오류의 원인을 분석하고 해결 방법을 제시해 주세요.\n";
|
||||
|
||||
return $report;
|
||||
}
|
||||
}
|
||||
19
app/Http/Controllers/ItemFieldController.php
Normal file
19
app/Http/Controllers/ItemFieldController.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 품목기준 필드 관리 컨트롤러 (Blade 화면)
|
||||
*/
|
||||
class ItemFieldController extends Controller
|
||||
{
|
||||
/**
|
||||
* 필드 관리 메인 화면
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
return view('item-fields.index');
|
||||
}
|
||||
}
|
||||
89
app/Models/ItemField.php
Normal file
89
app/Models/ItemField.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 품목기준 필드 모델
|
||||
*
|
||||
* API DB의 item_fields 테이블을 참조합니다.
|
||||
*/
|
||||
class ItemField extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'group_id',
|
||||
'field_name',
|
||||
'field_key',
|
||||
'field_type',
|
||||
'order_no',
|
||||
'is_required',
|
||||
'default_value',
|
||||
'placeholder',
|
||||
'display_condition',
|
||||
'validation_rules',
|
||||
'options',
|
||||
'properties',
|
||||
'category',
|
||||
'description',
|
||||
'is_common',
|
||||
'is_active',
|
||||
'is_locked',
|
||||
'locked_by',
|
||||
'locked_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
// 내부용 매핑 컬럼
|
||||
'source_table',
|
||||
'source_column',
|
||||
'storage_type',
|
||||
'json_path',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'group_id' => 'integer',
|
||||
'order_no' => 'integer',
|
||||
'is_required' => 'boolean',
|
||||
'is_common' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'is_locked' => 'boolean',
|
||||
'display_condition' => 'array',
|
||||
'validation_rules' => 'array',
|
||||
'options' => 'array',
|
||||
'properties' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
'locked_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 시스템 필드 여부 확인 (DB 컬럼과 매핑된 필드)
|
||||
*/
|
||||
public function isSystemField(): bool
|
||||
{
|
||||
return $this->storage_type === 'column' && ! is_null($this->source_column);
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 저장 방식 여부 확인
|
||||
*/
|
||||
public function isColumnStorage(): bool
|
||||
{
|
||||
return $this->storage_type === 'column';
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 저장 방식 여부 확인
|
||||
*/
|
||||
public function isJsonStorage(): bool
|
||||
{
|
||||
return $this->storage_type === 'json';
|
||||
}
|
||||
}
|
||||
509
app/Services/ItemFieldSeedingService.php
Normal file
509
app/Services/ItemFieldSeedingService.php
Normal file
@@ -0,0 +1,509 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user