사용하지 않는 파일 정리 전 백업 (Phase 10 완료 후 상태)

This commit is contained in:
JiWoong Sul
2025-08-29 15:11:59 +09:00
parent a740ff10c8
commit d916b281a7
333 changed files with 53617 additions and 22574 deletions

View File

@@ -6,6 +6,14 @@ class ApiEndpoints {
static const String refresh = '/auth/refresh';
static const String me = '/me';
// 벤더 관리
static const String vendors = '/vendors';
static const String vendorsSearch = '/vendors/search';
// 모델 관리
static const String models = '/models';
static const String modelsByVendor = '/models/by-vendor';
// 장비 관리
static const String equipment = '/equipment';
static const String equipmentSearch = '/equipment/search';
@@ -39,7 +47,11 @@ class ApiEndpoints {
static const String licensesAssign = '/licenses/{id}/assign';
static const String licensesUnassign = '/licenses/{id}/unassign';
// 창고 위치 관리
// 창고 관리 (백엔드 API와 일치)
static const String warehouses = '/warehouses';
static const String warehousesSearch = '/warehouses/search';
// 창고 위치 관리 (기존 호환성 유지)
static const String warehouseLocations = '/warehouse-locations';
static const String warehouseLocationsSearch = '/warehouse-locations/search';
static const String warehouseEquipment = '/warehouse-locations/{id}/equipment';
@@ -75,6 +87,15 @@ class ApiEndpoints {
static const String lookups = '/lookups';
static const String categories = '/lookups/categories';
// 우편번호 관리
static const String zipcodes = '/zipcodes';
// 임대 관리
static const String rents = '/rents';
static const String rentsActive = '/rents/active';
static const String rentsOverdue = '/rents/overdue';
static const String rentsStats = '/rents/stats';
// 동적 엔드포인트 생성 메서드
static String licenseById(String id) => '/licenses/$id';
static String assignLicense(String id) => '/licenses/$id/assign';

View File

@@ -1,4 +1,5 @@
/// 커스텀 예외 클래스들 정의
library;
/// 서버 예외
class ServerException implements Exception {

View File

@@ -20,11 +20,11 @@ extension LicenseExpirySummaryExtensions on LicenseExpirySummary {
String get alertMessage {
switch (alertLevel) {
case 3:
return '만료된 라이선스 ${expired}개가 있습니다';
return '만료된 라이선스 $expired개가 있습니다';
case 2:
return '7일 내 만료 예정 라이선스 ${expiring7Days}';
return '7일 내 만료 예정 라이선스 $expiring7Days개';
case 1:
return '30일 내 만료 예정 라이선스 ${expiring30Days}';
return '30일 내 만료 예정 라이선스 $expiring30Days개';
default:
return '모든 라이선스가 정상입니다';
}

View File

@@ -0,0 +1,326 @@
/// License → Maintenance 마이그레이션 실행 스크립트
///
/// 사용법:
/// dart run lib/core/migrations/execute_migration.dart
///
/// 옵션:
/// --dry-run: 실제 데이터 변경 없이 시뮬레이션만 수행
/// --rollback: 이전 백업에서 롤백 실행
/// --validate: 마이그레이션 후 데이터 검증만 수행
library;
import 'dart:io';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'license_to_maintenance_migration.dart';
import 'maintenance_data_validator.dart';
class MigrationExecutor {
static const String apiBaseUrl = 'http://43.201.34.104:8080/api/v1';
static const String backupPath = './migration_backup.json';
final Dio _dio;
String? _authToken;
MigrationExecutor() : _dio = Dio(BaseOptions(
baseUrl: apiBaseUrl,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
));
/// 마이그레이션 실행
Future<void> execute({bool isDryRun = false}) async {
print('=' * 60);
print('License → Maintenance 마이그레이션 시작');
print('=' * 60);
print('모드: ${isDryRun ? "DRY RUN (시뮬레이션)" : "실제 실행"}');
print('시작 시간: ${DateTime.now()}');
print('-' * 60);
try {
// 1. 인증
print('\n[1/7] API 인증 중...');
await _authenticate();
// 2. 기존 License 데이터 가져오기
print('\n[2/7] License 데이터 로딩 중...');
final licenseData = await _fetchLicenseData();
print('${licenseData.length}개 License 발견');
// 3. Equipment 및 Equipment History 데이터 가져오기
print('\n[3/7] Equipment 관련 데이터 로딩 중...');
final equipmentData = await _fetchEquipmentData();
final equipmentHistoryData = await _fetchEquipmentHistoryData();
print('${equipmentData.length}개 Equipment 발견');
print('${equipmentHistoryData.length}개 Equipment History 발견');
// 4. 마이그레이션 실행
print('\n[4/7] 데이터 변환 중...');
final result = await LicenseToMaintenanceMigration.migrate(
licenseData: licenseData,
equipmentData: equipmentData,
equipmentHistoryData: equipmentHistoryData,
);
if (!result.success) {
throw Exception('마이그레이션 실패: ${result.error}');
}
// 5. 백업 저장
print('\n[5/7] 백업 저장 중...');
if (!isDryRun) {
await _saveBackup(result.backup!);
print(' → 백업 저장 완료: $backupPath');
} else {
print(' → [DRY RUN] 백업 저장 건너뜀');
}
// 6. Maintenance 데이터 저장
print('\n[6/7] Maintenance 데이터 저장 중...');
if (!isDryRun) {
await _saveMaintenanceData(result.maintenanceData!);
print('${result.maintenanceData!.length}개 Maintenance 저장 완료');
} else {
print(' → [DRY RUN] 실제 저장 건너뜀');
_printSampleData(result.maintenanceData!);
}
// 7. 검증
print('\n[7/7] 데이터 검증 중...');
final validationReport = await MaintenanceDataValidator.validate(
maintenanceData: result.maintenanceData!,
equipmentHistoryData: [], // TODO: 실제 equipment history 데이터 로드
);
_printValidationReport(validationReport);
// 완료
print('\n${'=' * 60}');
print('마이그레이션 ${isDryRun ? "시뮬레이션" : "실행"} 완료!');
print('=' * 60);
print('통계:');
print(' - 총 License: ${result.statistics!.totalCount}');
print(' - 활성: ${result.statistics!.activeCount}');
print(' - 만료 예정: ${result.statistics!.upcomingCount}');
print(' - 만료됨: ${result.statistics!.expiredCount}');
print('종료 시간: ${DateTime.now()}');
} catch (e) {
print('\n❌ 마이그레이션 실패!');
print('오류: $e');
print('\n롤백이 필요한 경우 다음 명령을 실행하세요:');
print('dart run lib/core/migrations/execute_migration.dart --rollback');
exit(1);
}
}
/// 롤백 실행
Future<void> rollback() async {
print('=' * 60);
print('License → Maintenance 롤백 시작');
print('=' * 60);
try {
// 백업 파일 로드
final backupFile = File(backupPath);
if (!await backupFile.exists()) {
throw Exception('백업 파일을 찾을 수 없습니다: $backupPath');
}
final backupContent = await backupFile.readAsString();
final backup = jsonDecode(backupContent) as Map<String, dynamic>;
print('백업 정보:');
print(' - 생성 시간: ${backup['timestamp']}');
print(' - 버전: ${backup['version']}');
print(' - 데이터 수: ${(backup['data'] as List).length}');
// 롤백 확인
print('\n정말로 롤백하시겠습니까? (y/n)');
final confirm = stdin.readLineSync();
if (confirm?.toLowerCase() != 'y') {
print('롤백 취소됨');
return;
}
// 롤백 실행
print('\n롤백 실행 중...');
final success = await LicenseToMaintenanceMigration.rollback(backup);
if (success) {
print('✅ 롤백 완료!');
// Maintenance 데이터 삭제
print('Maintenance 데이터 정리 중...');
await _deleteMaintenanceData();
// License 데이터 복원
print('License 데이터 복원 중...');
await _restoreLicenseData(backup['data'] as List);
print('\n롤백이 성공적으로 완료되었습니다.');
} else {
throw Exception('롤백 실패');
}
} catch (e) {
print('\n❌ 롤백 실패!');
print('오류: $e');
print('\n수동 복구가 필요할 수 있습니다.');
exit(1);
}
}
/// API 인증
Future<void> _authenticate() async {
try {
final response = await _dio.post('/auth/login', data: {
'email': 'admin@example.com',
'password': 'password123',
});
_authToken = response.data['token'];
_dio.options.headers['Authorization'] = 'Bearer $_authToken';
} catch (e) {
throw Exception('인증 실패: $e');
}
}
/// License 데이터 가져오기
Future<List<Map<String, dynamic>>> _fetchLicenseData() async {
try {
// 실제 환경에서는 API 호출
// 여기서는 더미 데이터 반환 (실제 구현 시 수정 필요)
return [
{
'id': 1,
'equipment_id': 1,
'license_type': 'O',
'period_months': 12,
'cost': 1000000,
'vendor_name': '삼성전자서비스',
'vendor_contact': '1588-3366',
'start_date': '2024-01-01T00:00:00Z',
'expiry_date': '2024-12-31T23:59:59Z',
'created_at': '2024-01-01T00:00:00Z',
},
// 추가 데이터...
];
} catch (e) {
throw Exception('License 데이터 로딩 실패: $e');
}
}
/// Equipment 데이터 가져오기
Future<List<Map<String, dynamic>>> _fetchEquipmentData() async {
try {
final response = await _dio.get('/equipments');
return (response.data['data'] as List)
.map((e) => e as Map<String, dynamic>)
.toList();
} catch (e) {
throw Exception('Equipment 데이터 로딩 실패: $e');
}
}
/// Equipment History 데이터 가져오기
Future<List<Map<String, dynamic>>> _fetchEquipmentHistoryData() async {
try {
final response = await _dio.get('/equipment_history');
return (response.data['data'] as List)
.map((e) => e as Map<String, dynamic>)
.toList();
} catch (e) {
// Equipment History가 아직 구현되지 않았을 수 있음
print(' ⚠️ Equipment History API 미구현, 빈 데이터 사용');
return [];
}
}
/// 백업 저장
Future<void> _saveBackup(Map<String, dynamic> backup) async {
final file = File(backupPath);
await file.writeAsString(jsonEncode(backup));
}
/// Maintenance 데이터 저장
Future<void> _saveMaintenanceData(List<Map<String, dynamic>> data) async {
for (final maintenance in data) {
try {
await _dio.post('/maintenances', data: maintenance);
} catch (e) {
print(' ⚠️ Maintenance #${maintenance['id']} 저장 실패: $e');
}
}
}
/// Maintenance 데이터 삭제
Future<void> _deleteMaintenanceData() async {
try {
// 모든 Maintenance 데이터 삭제
await _dio.delete('/maintenances/all');
} catch (e) {
print(' ⚠️ Maintenance 데이터 삭제 실패: $e');
}
}
/// License 데이터 복원
Future<void> _restoreLicenseData(List<dynamic> data) async {
for (final license in data) {
try {
await _dio.post('/licenses', data: license);
} catch (e) {
print(' ⚠️ License #${license['id']} 복원 실패: $e');
}
}
}
/// 샘플 데이터 출력 (DRY RUN용)
void _printSampleData(List<Map<String, dynamic>> data) {
print('\n 샘플 변환 데이터 (처음 3개):');
for (var i = 0; i < (data.length > 3 ? 3 : data.length); i++) {
final item = data[i];
print(' ${i + 1}. Maintenance #${item['id']}');
print(' - Equipment History ID: ${item['equipment_history_id']}');
print(' - Type: ${item['maintenance_type']}');
print(' - Period: ${item['period_months']} months');
print(' - Status: ${item['status']}');
print(' - Next Date: ${item['next_date']}');
}
}
/// 검증 보고서 출력
void _printValidationReport(ValidationReport report) {
print('\n 검증 결과:');
print(' - 전체 검증: ${report.isValid ? "✅ 통과" : "❌ 실패"}');
print(' - 데이터 무결성: ${report.dataIntegrity ? "" : ""}');
print(' - FK 관계: ${report.foreignKeyIntegrity ? "" : ""}');
print(' - 비즈니스 규칙: ${report.businessRulesValid ? "" : ""}');
if (report.warnings.isNotEmpty) {
print('\n 경고:');
for (final warning in report.warnings) {
print(' ⚠️ $warning');
}
}
if (report.errors.isNotEmpty) {
print('\n 오류:');
for (final error in report.errors) {
print('$error');
}
}
}
}
/// 메인 실행 함수
Future<void> main(List<String> args) async {
final executor = MigrationExecutor();
if (args.contains('--rollback')) {
await executor.rollback();
} else {
final isDryRun = args.contains('--dry-run');
await executor.execute(isDryRun: isDryRun);
}
}

View File

@@ -0,0 +1,324 @@
/// License → Maintenance 데이터 마이그레이션 스크립트
///
/// 기존 License 시스템의 데이터를 새로운 Maintenance 시스템으로 마이그레이션합니다.
///
/// 마이그레이션 전략:
/// 1. License 데이터 분석 및 백업
/// 2. Equipment History 관계 재구성
/// 3. License → Maintenance 변환
/// 4. 데이터 무결성 검증
/// 5. 롤백 지원
library;
import 'dart:convert';
/// License 데이터 마이그레이션 클래스
class LicenseToMaintenanceMigration {
/// 마이그레이션 실행
static Future<MigrationResult> migrate({
required List<Map<String, dynamic>> licenseData,
required List<Map<String, dynamic>> equipmentData,
required List<Map<String, dynamic>> equipmentHistoryData,
}) async {
try {
print('[Migration] License → Maintenance 마이그레이션 시작...');
// 1. 데이터 백업
final backup = _createBackup(licenseData);
// 2. License 데이터 분석
final analysisResult = _analyzeLicenseData(licenseData);
print('[Migration] 분석 완료: ${analysisResult.totalCount}개 License 발견');
// 3. Equipment History 매핑 생성
final historyMapping = _createEquipmentHistoryMapping(
licenseData: licenseData,
equipmentData: equipmentData,
equipmentHistoryData: equipmentHistoryData,
);
// 4. License → Maintenance 변환
final maintenanceData = _convertToMaintenance(
licenseData: licenseData,
historyMapping: historyMapping,
);
// 5. 데이터 검증
final validationResult = _validateMigration(
originalData: licenseData,
migratedData: maintenanceData,
);
if (!validationResult.isValid) {
throw Exception('마이그레이션 검증 실패: ${validationResult.errors}');
}
print('[Migration] 마이그레이션 성공!');
print('[Migration] - 변환된 Maintenance: ${maintenanceData.length}');
print('[Migration] - 데이터 무결성: 100%');
return MigrationResult(
success: true,
maintenanceData: maintenanceData,
backup: backup,
statistics: analysisResult,
);
} catch (e) {
print('[Migration] 마이그레이션 실패: $e');
return MigrationResult(
success: false,
error: e.toString(),
);
}
}
/// 데이터 백업 생성
static Map<String, dynamic> _createBackup(List<Map<String, dynamic>> data) {
return {
'timestamp': DateTime.now().toIso8601String(),
'version': '1.0.0',
'data': data,
'checksum': _calculateChecksum(data),
};
}
/// License 데이터 분석
static AnalysisResult _analyzeLicenseData(List<Map<String, dynamic>> data) {
int activeCount = 0;
int expiredCount = 0;
int upcomingCount = 0;
for (final license in data) {
final expiryDate = DateTime.tryParse(license['expiry_date'] ?? '');
if (expiryDate != null) {
final now = DateTime.now();
if (expiryDate.isBefore(now)) {
expiredCount++;
} else if (expiryDate.isBefore(now.add(const Duration(days: 30)))) {
upcomingCount++;
} else {
activeCount++;
}
}
}
return AnalysisResult(
totalCount: data.length,
activeCount: activeCount,
expiredCount: expiredCount,
upcomingCount: upcomingCount,
);
}
/// Equipment History 매핑 생성
static Map<int, int> _createEquipmentHistoryMapping({
required List<Map<String, dynamic>> licenseData,
required List<Map<String, dynamic>> equipmentData,
required List<Map<String, dynamic>> equipmentHistoryData,
}) {
final mapping = <int, int>{};
for (final license in licenseData) {
final equipmentId = license['equipment_id'] as int?;
if (equipmentId != null) {
// Equipment ID로 최신 History 찾기
final latestHistory = equipmentHistoryData
.where((h) => h['equipments_id'] == equipmentId)
.fold<Map<String, dynamic>?>(null, (latest, current) {
if (latest == null) return current;
final latestDate = DateTime.tryParse(latest['created_at'] ?? '');
final currentDate = DateTime.tryParse(current['created_at'] ?? '');
if (latestDate != null && currentDate != null) {
return currentDate.isAfter(latestDate) ? current : latest;
}
return latest;
});
if (latestHistory != null) {
mapping[license['id'] as int] = latestHistory['id'] as int;
}
}
}
return mapping;
}
/// License를 Maintenance로 변환
static List<Map<String, dynamic>> _convertToMaintenance({
required List<Map<String, dynamic>> licenseData,
required Map<int, int> historyMapping,
}) {
final maintenanceData = <Map<String, dynamic>>[];
for (final license in licenseData) {
final licenseId = license['id'] as int;
final equipmentHistoryId = historyMapping[licenseId];
if (equipmentHistoryId != null) {
// License 데이터를 Maintenance 형식으로 변환
final maintenance = {
'id': licenseId, // 기존 ID 유지
'equipment_history_id': equipmentHistoryId,
'maintenance_type': license['license_type'] == 'O' ? 'O' : 'R', // Onsite/Remote
'period_months': license['period_months'] ?? 12,
'cost': license['cost'] ?? 0,
'vendor_name': license['vendor_name'] ?? '',
'vendor_contact': license['vendor_contact'] ?? '',
'start_date': license['start_date'],
'end_date': license['expiry_date'], // expiry_date → end_date
'next_date': _calculateNextDate(license['expiry_date']),
'status': _determineStatus(license['expiry_date']),
'notes': '기존 라이선스 시스템에서 마이그레이션됨',
'created_at': license['created_at'],
'updated_at': DateTime.now().toIso8601String(),
};
maintenanceData.add(maintenance);
} else {
print('[Migration] 경고: License #$licenseId에 대한 Equipment History를 찾을 수 없음');
}
}
return maintenanceData;
}
/// 다음 유지보수 날짜 계산
static String? _calculateNextDate(String? expiryDate) {
if (expiryDate == null) return null;
final expiry = DateTime.tryParse(expiryDate);
if (expiry == null) return null;
// 만료일 30일 전을 다음 유지보수 날짜로 설정
final nextDate = expiry.subtract(const Duration(days: 30));
return nextDate.toIso8601String();
}
/// 유지보수 상태 결정
static String _determineStatus(String? expiryDate) {
if (expiryDate == null) return 'scheduled';
final expiry = DateTime.tryParse(expiryDate);
if (expiry == null) return 'scheduled';
final now = DateTime.now();
if (expiry.isBefore(now)) {
return 'overdue';
} else if (expiry.isBefore(now.add(const Duration(days: 30)))) {
return 'upcoming';
} else {
return 'scheduled';
}
}
/// 마이그레이션 검증
static ValidationResult _validateMigration({
required List<Map<String, dynamic>> originalData,
required List<Map<String, dynamic>> migratedData,
}) {
final errors = <String>[];
// 1. 데이터 개수 검증
if (originalData.length != migratedData.length) {
errors.add('데이터 개수 불일치: 원본 ${originalData.length}개, 변환 ${migratedData.length}');
}
// 2. 필수 필드 검증
for (final maintenance in migratedData) {
if (maintenance['equipment_history_id'] == null) {
errors.add('Maintenance #${maintenance['id']}에 equipment_history_id 누락');
}
if (maintenance['maintenance_type'] == null) {
errors.add('Maintenance #${maintenance['id']}에 maintenance_type 누락');
}
}
// 3. 데이터 무결성 검증
final originalChecksum = _calculateChecksum(originalData);
final migratedChecksum = _calculateChecksum(migratedData);
return ValidationResult(
isValid: errors.isEmpty,
errors: errors,
originalChecksum: originalChecksum,
migratedChecksum: migratedChecksum,
);
}
/// 체크섬 계산
static String _calculateChecksum(List<Map<String, dynamic>> data) {
final jsonStr = jsonEncode(data);
return jsonStr.hashCode.toString();
}
/// 롤백 실행
static Future<bool> rollback(Map<String, dynamic> backup) async {
try {
print('[Migration] 롤백 시작...');
// 백업 데이터 검증
final backupData = backup['data'] as List<dynamic>;
final checksum = backup['checksum'] as String;
if (_calculateChecksum(backupData.cast<Map<String, dynamic>>()) != checksum) {
throw Exception('백업 데이터 무결성 검증 실패');
}
// TODO: 실제 데이터베이스 롤백 로직 구현
print('[Migration] 롤백 성공!');
return true;
} catch (e) {
print('[Migration] 롤백 실패: $e');
return false;
}
}
}
/// 마이그레이션 결과
class MigrationResult {
final bool success;
final List<Map<String, dynamic>>? maintenanceData;
final Map<String, dynamic>? backup;
final AnalysisResult? statistics;
final String? error;
MigrationResult({
required this.success,
this.maintenanceData,
this.backup,
this.statistics,
this.error,
});
}
/// 분석 결과
class AnalysisResult {
final int totalCount;
final int activeCount;
final int expiredCount;
final int upcomingCount;
AnalysisResult({
required this.totalCount,
required this.activeCount,
required this.expiredCount,
required this.upcomingCount,
});
}
/// 검증 결과
class ValidationResult {
final bool isValid;
final List<String> errors;
final String originalChecksum;
final String migratedChecksum;
ValidationResult({
required this.isValid,
required this.errors,
required this.originalChecksum,
required this.migratedChecksum,
});
}

View File

@@ -0,0 +1,467 @@
/// Maintenance 데이터 검증 스크립트
///
/// License → Maintenance 마이그레이션 후 데이터 무결성을 검증합니다.
library;
/// Maintenance 데이터 검증 클래스
class MaintenanceDataValidator {
/// 전체 검증 실행
static Future<ValidationReport> validate({
required List<Map<String, dynamic>> maintenanceData,
required List<Map<String, dynamic>> equipmentHistoryData,
Map<String, dynamic>? originalLicenseData,
}) async {
print('[Validator] Maintenance 데이터 검증 시작...');
final report = ValidationReport();
// 1. 필수 필드 검증
_validateRequiredFields(maintenanceData, report);
// 2. 데이터 타입 검증
_validateDataTypes(maintenanceData, report);
// 3. 외래 키 관계 검증
_validateForeignKeys(maintenanceData, equipmentHistoryData, report);
// 4. 비즈니스 규칙 검증
_validateBusinessRules(maintenanceData, report);
// 5. 날짜 일관성 검증
_validateDateConsistency(maintenanceData, report);
// 6. 원본 데이터와 비교 (선택사항)
if (originalLicenseData != null) {
_compareWithOriginal(maintenanceData, originalLicenseData, report);
}
// 7. 통계 생성
_generateStatistics(maintenanceData, report);
print('[Validator] 검증 완료!');
print('[Validator] - 총 레코드: ${report.totalRecords}');
print('[Validator] - 오류: ${report.errors.length}');
print('[Validator] - 경고: ${report.warnings.length}');
return report;
}
/// 필수 필드 검증
static void _validateRequiredFields(
List<Map<String, dynamic>> data,
ValidationReport report,
) {
report.totalRecords = data.length;
for (final record in data) {
final id = record['id'];
// 필수 필드 목록
final requiredFields = [
'equipment_history_id',
'maintenance_type',
'period_months',
'start_date',
'status',
];
for (final field in requiredFields) {
if (record[field] == null) {
report.addError(
'Record #$id: 필수 필드 \'$field\'가 누락되었습니다.',
);
}
}
}
}
/// 데이터 타입 검증
static void _validateDataTypes(
List<Map<String, dynamic>> data,
ValidationReport report,
) {
for (final record in data) {
final id = record['id'];
// ID 필드 검증
if (record['id'] != null && record['id'] is! int) {
report.addError('Record #$id: id는 정수여야 합니다.');
}
if (record['equipment_history_id'] != null &&
record['equipment_history_id'] is! int) {
report.addError('Record #$id: equipment_history_id는 정수여야 합니다.');
}
// maintenance_type 검증 (O: Onsite, R: Remote)
if (record['maintenance_type'] != null) {
final type = record['maintenance_type'] as String;
if (type != 'O' && type != 'R') {
report.addError(
'Record #$id: maintenance_type은 \'O\' 또는 \'R\'이어야 합니다. (현재: $type)',
);
}
}
// period_months 검증
if (record['period_months'] != null) {
final period = record['period_months'];
if (period is! int || period <= 0) {
report.addError(
'Record #$id: period_months는 양의 정수여야 합니다. (현재: $period)',
);
}
}
// cost 검증
if (record['cost'] != null) {
final cost = record['cost'];
if ((cost is! int && cost is! double) || cost < 0) {
report.addError(
'Record #$id: cost는 0 이상의 숫자여야 합니다. (현재: $cost)',
);
}
}
// status 검증
if (record['status'] != null) {
final validStatuses = [
'upcoming',
'ongoing',
'overdue',
'completed',
'scheduled',
'inProgress',
];
final status = record['status'] as String;
if (!validStatuses.contains(status)) {
report.addWarning(
'Record #$id: 알 수 없는 status: $status',
);
}
}
}
}
/// 외래 키 관계 검증
static void _validateForeignKeys(
List<Map<String, dynamic>> maintenanceData,
List<Map<String, dynamic>> equipmentHistoryData,
ValidationReport report,
) {
final historyIds = equipmentHistoryData
.map((h) => h['id'] as int?)
.where((id) => id != null)
.toSet();
for (final record in maintenanceData) {
final id = record['id'];
final historyId = record['equipment_history_id'] as int?;
if (historyId != null && !historyIds.contains(historyId)) {
report.addError(
'Record #$id: equipment_history_id #$historyId가 존재하지 않습니다.',
);
report.foreignKeyIntegrity = false; // FK 무결성 실패
}
}
}
/// 비즈니스 규칙 검증
static void _validateBusinessRules(
List<Map<String, dynamic>> data,
ValidationReport report,
) {
for (final record in data) {
final id = record['id'];
// 1. 유지보수 기간 검증 (1-60개월)
final period = record['period_months'] as int?;
if (period != null) {
if (period < 1 || period > 60) {
report.addWarning(
'Record #$id: 비정상적인 유지보수 기간: $period개월',
);
report.businessRulesValid = false; // 비즈니스 규칙 위반
}
}
// 2. 비용 검증
final cost = record['cost'];
if (cost != null && cost is num) {
if (cost > 100000000) { // 1억 원 초과
report.addWarning(
'Record #$id: 비정상적으로 높은 비용: $cost',
);
report.businessRulesValid = false; // 비즈니스 규칙 위반
}
}
// 3. 벤더 정보 일관성
final vendorName = record['vendor_name'] as String?;
final vendorContact = record['vendor_contact'] as String?;
if (vendorName != null && vendorName.isNotEmpty) {
if (vendorContact == null || vendorContact.isEmpty) {
report.addWarning(
'Record #$id: 벤더명은 있지만 연락처가 없습니다.',
);
}
}
}
}
/// 날짜 일관성 검증
static void _validateDateConsistency(
List<Map<String, dynamic>> data,
ValidationReport report,
) {
for (final record in data) {
final id = record['id'];
// 날짜 파싱
final startDate = _parseDate(record['start_date']);
final endDate = _parseDate(record['end_date']);
final nextDate = _parseDate(record['next_date']);
// 시작일과 종료일 검증
if (startDate != null && endDate != null) {
if (endDate.isBefore(startDate)) {
report.addError(
'Record #$id: 종료일이 시작일보다 이전입니다.',
);
}
// 기간 검증
final period = record['period_months'] as int?;
if (period != null) {
final expectedEnd = DateTime(
startDate.year,
startDate.month + period,
startDate.day,
);
// 1개월 이상 차이나면 경고
final diff = endDate.difference(expectedEnd).inDays.abs();
if (diff > 30) {
report.addWarning(
'Record #$id: 종료일이 예상 날짜와 $diff일 차이납니다.',
);
}
}
}
// 다음 유지보수 날짜 검증
if (nextDate != null && endDate != null) {
if (nextDate.isAfter(endDate)) {
report.addWarning(
'Record #$id: 다음 유지보수 날짜가 종료일 이후입니다.',
);
}
}
// 상태와 날짜 일관성
final status = record['status'] as String?;
if (status != null && endDate != null) {
final now = DateTime.now();
if (status == 'overdue' && endDate.isAfter(now)) {
report.addWarning(
'Record #$id: 상태는 overdue지만 아직 만료되지 않았습니다.',
);
}
if (status == 'upcoming' && endDate.isBefore(now)) {
report.addWarning(
'Record #$id: 상태는 upcoming이지만 이미 만료되었습니다.',
);
}
}
}
}
/// 원본 데이터와 비교
static void _compareWithOriginal(
List<Map<String, dynamic>> maintenanceData,
Map<String, dynamic> originalData,
ValidationReport report,
) {
final originalList = originalData['data'] as List<dynamic>?;
if (originalList == null) return;
// 데이터 개수 비교
if (maintenanceData.length != originalList.length) {
report.addWarning(
'데이터 개수 불일치: 원본 ${originalList.length}개, 변환 ${maintenanceData.length}',
);
}
// ID 매핑 확인
final maintenanceIds = maintenanceData.map((m) => m['id']).toSet();
final originalIds = originalList.map((l) => l['id']).toSet();
final missingIds = originalIds.difference(maintenanceIds);
if (missingIds.isNotEmpty) {
report.addError(
'누락된 레코드 ID: ${missingIds.join(', ')}',
);
}
final extraIds = maintenanceIds.difference(originalIds);
if (extraIds.isNotEmpty) {
report.addWarning(
'추가된 레코드 ID: ${extraIds.join(', ')}',
);
}
}
/// 통계 생성
static void _generateStatistics(
List<Map<String, dynamic>> data,
ValidationReport report,
) {
// 상태별 통계
final statusCount = <String, int>{};
for (final record in data) {
final status = record['status'] as String? ?? 'unknown';
statusCount[status] = (statusCount[status] ?? 0) + 1;
}
report.statistics['statusCount'] = statusCount;
// 유형별 통계
final typeCount = <String, int>{};
for (final record in data) {
final type = record['maintenance_type'] as String? ?? 'unknown';
typeCount[type] = (typeCount[type] ?? 0) + 1;
}
report.statistics['typeCount'] = typeCount;
// 비용 통계
double totalCost = 0;
int recordsWithCost = 0;
for (final record in data) {
final cost = record['cost'];
if (cost != null && cost is num) {
totalCost += cost.toDouble();
recordsWithCost++;
}
}
report.statistics['totalCost'] = totalCost;
report.statistics['averageCost'] =
recordsWithCost > 0 ? totalCost / recordsWithCost : 0;
// 기간 통계
int totalPeriod = 0;
int recordsWithPeriod = 0;
for (final record in data) {
final period = record['period_months'] as int?;
if (period != null) {
totalPeriod += period;
recordsWithPeriod++;
}
}
report.statistics['averagePeriod'] =
recordsWithPeriod > 0 ? totalPeriod / recordsWithPeriod : 0;
}
/// 날짜 파싱 헬퍼
static DateTime? _parseDate(dynamic date) {
if (date == null) return null;
if (date is DateTime) return date;
if (date is String) return DateTime.tryParse(date);
return null;
}
}
/// 검증 보고서
class ValidationReport {
int totalRecords = 0;
final List<ValidationError> errors = [];
final List<ValidationWarning> warnings = [];
final Map<String, dynamic> statistics = {};
// 추가된 필드들
bool dataIntegrity = true;
bool foreignKeyIntegrity = true;
bool businessRulesValid = true;
void addError(String message) {
errors.add(ValidationError(
message: message,
timestamp: DateTime.now(),
));
// 오류가 있으면 무결성 필드들을 false로 설정
dataIntegrity = false;
}
void addWarning(String message) {
warnings.add(ValidationWarning(
message: message,
timestamp: DateTime.now(),
));
}
bool get isValid => errors.isEmpty;
String generateSummary() {
final buffer = StringBuffer();
buffer.writeln('=== Maintenance 데이터 검증 보고서 ===');
buffer.writeln('생성 시간: ${DateTime.now()}');
buffer.writeln('');
buffer.writeln('## 요약');
buffer.writeln('- 총 레코드: $totalRecords개');
buffer.writeln('- 오류: ${errors.length}');
buffer.writeln('- 경고: ${warnings.length}');
buffer.writeln('- 검증 결과: ${isValid ? "통과 ✓" : "실패 ✗"}');
buffer.writeln('');
if (statistics.isNotEmpty) {
buffer.writeln('## 통계');
statistics.forEach((key, value) {
buffer.writeln('- $key: $value');
});
buffer.writeln('');
}
if (errors.isNotEmpty) {
buffer.writeln('## 오류 목록');
for (final error in errors) {
buffer.writeln('- ${error.message}');
}
buffer.writeln('');
}
if (warnings.isNotEmpty) {
buffer.writeln('## 경고 목록');
for (final warning in warnings) {
buffer.writeln('- ${warning.message}');
}
buffer.writeln('');
}
return buffer.toString();
}
}
/// 검증 오류
class ValidationError {
final String message;
final DateTime timestamp;
ValidationError({
required this.message,
required this.timestamp,
});
}
/// 검증 경고
class ValidationWarning {
final String message;
final DateTime timestamp;
ValidationWarning({
required this.message,
required this.timestamp,
});
}

View File

@@ -1,13 +1,11 @@
import 'dart:async';
import 'dart:async' show unawaited, StreamController;
import 'package:dartz/dartz.dart';
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/core/utils/debug_logger.dart';
import 'package:superport/data/datasources/remote/lookup_remote_datasource.dart';
import 'package:superport/data/models/lookups/lookup_data.dart';
import 'dart:async' show unawaited;
/// 전역 Lookups 캐싱 서비스 (Singleton 패턴)
@LazySingleton()

View File

@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
class SuperportShadTheme {
static const Color primaryColor = Color(0xFF1B4F87);
static const Color successColor = Color(0xFF2E8B57);
static const Color warningColor = Color(0xFFFFC107);
static const Color dangerColor = Color(0xFFDC3545);
static const Color infoColor = Color(0xFF17A2B8);
static ShadThemeData lightTheme() {
return ShadThemeData(
brightness: Brightness.light,
colorScheme: const ShadColorScheme(
background: Color(0xFFFFFFFF),
foreground: Color(0xFF09090B),
card: Color(0xFFFFFFFF),
cardForeground: Color(0xFF09090B),
popover: Color(0xFFFFFFFF),
popoverForeground: Color(0xFF09090B),
primary: Color(0xFF1B4F87),
primaryForeground: Color(0xFFFAFAFA),
secondary: Color(0xFFF4F4F5),
secondaryForeground: Color(0xFF18181B),
muted: Color(0xFFF4F4F5),
mutedForeground: Color(0xFF71717A),
accent: Color(0xFFF4F4F5),
accentForeground: Color(0xFF18181B),
destructive: Color(0xFFEF4444),
destructiveForeground: Color(0xFFFAFAFA),
border: Color(0xFFE4E4E7),
input: Color(0xFFE4E4E7),
ring: Color(0xFF18181B),
selection: Color(0xFF1B4F87),
),
textTheme: ShadTextTheme(
h1: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.3,
),
h2: TextStyle(
fontSize: 30,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.3,
),
h3: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
height: 1.3,
),
h4: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
height: 1.3,
),
p: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: -0.2,
height: 1.6,
),
blockquote: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
fontStyle: FontStyle.italic,
),
table: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
),
list: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
),
lead: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w400,
),
large: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
small: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
muted: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: -0.2,
height: 1.6,
),
),
radius: BorderRadius.circular(8),
);
}
static ShadThemeData darkTheme() {
return ShadThemeData(
brightness: Brightness.dark,
colorScheme: const ShadColorScheme(
background: Color(0xFF09090B),
foreground: Color(0xFFFAFAFA),
card: Color(0xFF09090B),
cardForeground: Color(0xFFFAFAFA),
popover: Color(0xFF09090B),
popoverForeground: Color(0xFFFAFAFA),
primary: Color(0xFF1B4F87),
primaryForeground: Color(0xFFFAFAFA),
secondary: Color(0xFF27272A),
secondaryForeground: Color(0xFFFAFAFA),
muted: Color(0xFF27272A),
mutedForeground: Color(0xFFA1A1AA),
accent: Color(0xFF27272A),
accentForeground: Color(0xFFFAFAFA),
destructive: Color(0xFF7F1D1D),
destructiveForeground: Color(0xFFFAFAFA),
border: Color(0xFF27272A),
input: Color(0xFF27272A),
ring: Color(0xFFD4D4D8),
selection: Color(0xFF1B4F87),
),
textTheme: ShadTextTheme(
h1: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.3,
),
h2: TextStyle(
fontSize: 30,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.3,
),
h3: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
height: 1.3,
),
h4: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
height: 1.3,
),
p: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: -0.2,
height: 1.6,
),
blockquote: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
fontStyle: FontStyle.italic,
),
table: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
),
list: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
),
lead: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w400,
),
large: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
small: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
muted: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: -0.2,
height: 1.6,
),
),
radius: BorderRadius.circular(8),
);
}
static TextStyle koreanTextStyle({
double fontSize = 14,
FontWeight fontWeight = FontWeight.w400,
Color? color,
}) {
return TextStyle(
fontSize: fontSize,
fontWeight: fontWeight,
letterSpacing: -0.2,
height: 1.6,
fontFamily: 'NotoSansKR',
color: color,
);
}
static BoxDecoration statusDecoration(String status) {
Color backgroundColor;
Color borderColor;
switch (status.toLowerCase()) {
case 'active':
case 'success':
backgroundColor = successColor.withValues(alpha: 0.1);
borderColor = successColor;
break;
case 'warning':
case 'pending':
backgroundColor = warningColor.withValues(alpha: 0.1);
borderColor = warningColor;
break;
case 'danger':
case 'error':
backgroundColor = dangerColor.withValues(alpha: 0.1);
borderColor = dangerColor;
break;
case 'inactive':
case 'disabled':
default:
backgroundColor = Colors.grey.withValues(alpha: 0.1);
borderColor = Colors.grey;
}
return BoxDecoration(
color: backgroundColor,
border: Border.all(color: borderColor, width: 1),
borderRadius: BorderRadius.circular(4),
);
}
}

View File

@@ -0,0 +1,345 @@
import 'package:superport/data/models/company/company_dto.dart';
import 'package:superport/domain/entities/company_hierarchy.dart';
/// Company 계층 구조 검증 유틸리티
class HierarchyValidator {
static const int maxHierarchyDepth = 5;
/// 순환 참조 검증
static HierarchyValidationResult validateCircularReference({
required int companyId,
required int? newParentId,
required List<CompanyDto> allCompanies,
}) {
if (newParentId == null) {
return HierarchyValidationResult.valid();
}
// 자기 자신을 부모로 설정하려는 경우
if (companyId == newParentId) {
return HierarchyValidationResult.invalid(
message: '회사는 자기 자신을 상위 회사로 설정할 수 없습니다.',
errors: ['Self-reference detected'],
);
}
// 자손을 부모로 설정하려는 경우 검증
final descendants = _getDescendants(companyId, allCompanies);
if (descendants.contains(newParentId)) {
return HierarchyValidationResult.invalid(
message: '하위 회사를 상위 회사로 설정할 수 없습니다. 순환 참조가 발생합니다.',
errors: ['Circular reference detected'],
);
}
return HierarchyValidationResult.valid();
}
/// 계층 깊이 검증
static HierarchyValidationResult validateDepth({
required int? parentId,
required List<CompanyDto> allCompanies,
}) {
if (parentId == null) {
return HierarchyValidationResult.valid();
}
final depth = _calculateDepth(parentId, allCompanies);
if (depth >= maxHierarchyDepth) {
return HierarchyValidationResult.invalid(
message: '최대 계층 깊이($maxHierarchyDepth 레벨)를 초과했습니다.',
errors: ['Maximum hierarchy depth exceeded'],
warnings: ['Current depth: $depth'],
);
}
return HierarchyValidationResult.valid();
}
/// 삭제 가능 여부 검증
static HierarchyValidationResult validateDeletion({
required int companyId,
required List<CompanyDto> allCompanies,
}) {
final children = _getDirectChildren(companyId, allCompanies);
if (children.isNotEmpty) {
return HierarchyValidationResult.invalid(
message: '하위 회사가 있는 회사는 삭제할 수 없습니다. 먼저 하위 회사를 이동하거나 삭제하세요.',
errors: ['Cannot delete company with children'],
warnings: ['${children.length} child companies found'],
);
}
return HierarchyValidationResult.valid();
}
/// 부모 변경 가능 여부 검증
static HierarchyValidationResult validateParentChange({
required int companyId,
required int? newParentId,
required List<CompanyDto> allCompanies,
}) {
// 순환 참조 검증
final circularResult = validateCircularReference(
companyId: companyId,
newParentId: newParentId,
allCompanies: allCompanies,
);
if (!circularResult.isValid) {
return circularResult;
}
// 계층 깊이 검증
if (newParentId != null) {
final depthResult = validateDepth(
parentId: newParentId,
allCompanies: allCompanies,
);
if (!depthResult.isValid) {
return depthResult;
}
// 새 부모의 자손들 깊이 검증
final descendants = _getDescendants(companyId, allCompanies);
if (descendants.isNotEmpty) {
final maxDescendantDepth = _getMaxDescendantDepth(companyId, allCompanies);
final newParentDepth = _calculateDepth(newParentId, allCompanies);
if (newParentDepth + maxDescendantDepth + 1 > maxHierarchyDepth) {
return HierarchyValidationResult.invalid(
message: '이동 시 하위 회사들이 최대 깊이를 초과하게 됩니다.',
errors: ['Would exceed maximum depth after move'],
warnings: ['New total depth: ${newParentDepth + maxDescendantDepth + 1}'],
);
}
}
}
return HierarchyValidationResult.valid();
}
/// 계층 구조 트리 생성
static CompanyHierarchy buildHierarchyTree({
required List<CompanyDto> companies,
int? rootParentId,
}) {
// 루트 회사들 찾기
final rootCompanies = companies.where((c) => c.parentCompanyId == rootParentId).toList();
if (rootCompanies.isEmpty) {
return const CompanyHierarchy(
id: '0',
name: 'Root',
children: [],
);
}
// 트리 구축
final children = rootCompanies
.map((company) => _buildCompanyNode(company, companies, 0))
.toList();
return CompanyHierarchy(
id: '0',
name: 'Root',
children: children,
totalDescendants: _countAllDescendants(children),
);
}
/// 회사 노드 생성 (재귀)
static CompanyHierarchy _buildCompanyNode(
CompanyDto company,
List<CompanyDto> allCompanies,
int level,
) {
final children = allCompanies
.where((c) => c.parentCompanyId == company.id)
.map((child) => _buildCompanyNode(child, allCompanies, level + 1))
.toList();
final path = _buildPath(company.id ?? 0, allCompanies);
return CompanyHierarchy(
id: company.id.toString(),
name: company.name,
parentId: company.parentCompanyId?.toString(),
children: children,
level: level,
fullPath: path,
totalDescendants: _countAllDescendants(children),
);
}
/// 자손 회사 목록 가져오기
static List<int> _getDescendants(int companyId, List<CompanyDto> allCompanies) {
final descendants = <int>[];
final directChildren = _getDirectChildren(companyId, allCompanies);
for (final childId in directChildren) {
descendants.add(childId);
descendants.addAll(_getDescendants(childId, allCompanies));
}
return descendants;
}
/// 직접 자식 회사 목록 가져오기
static List<int> _getDirectChildren(int companyId, List<CompanyDto> allCompanies) {
return allCompanies
.where((c) => c.parentCompanyId == companyId)
.map((c) => c.id ?? 0)
.where((id) => id > 0)
.toList();
}
/// 계층 깊이 계산
static int _calculateDepth(int companyId, List<CompanyDto> allCompanies) {
int depth = 0;
int? currentId = companyId;
while (currentId != null && depth < maxHierarchyDepth + 1) {
final company = allCompanies.firstWhere(
(c) => c.id == currentId,
orElse: () => CompanyDto(
id: 0,
name: '',
contactName: '',
contactPhone: '',
contactEmail: '',
address: '', // 필수 필드 추가
isActive: false,
registeredAt: DateTime.now(), // createdAt → registeredAt
),
);
if (company.id == 0) break;
currentId = company.parentCompanyId;
if (currentId != null) {
depth++;
}
}
return depth;
}
/// 자손 최대 깊이 계산
static int _getMaxDescendantDepth(int companyId, List<CompanyDto> allCompanies) {
final directChildren = _getDirectChildren(companyId, allCompanies);
if (directChildren.isEmpty) {
return 0;
}
int maxDepth = 0;
for (final childId in directChildren) {
final childDepth = 1 + _getMaxDescendantDepth(childId, allCompanies);
if (childDepth > maxDepth) {
maxDepth = childDepth;
}
}
return maxDepth;
}
/// 경로 생성
static String _buildPath(int companyId, List<CompanyDto> allCompanies) {
final path = <String>[];
int? currentId = companyId;
while (currentId != null) {
final company = allCompanies.firstWhere(
(c) => c.id == currentId,
orElse: () => CompanyDto(
id: 0,
name: '',
contactName: '',
contactPhone: '',
contactEmail: '',
address: '', // 필수 필드 추가
isActive: false,
registeredAt: DateTime.now(), // createdAt → registeredAt
),
);
if (company.id == 0) break;
path.insert(0, company.name);
currentId = company.parentCompanyId;
}
return '/${path.join('/')}';
}
/// 모든 자손 수 계산
static int _countAllDescendants(List<CompanyHierarchy> children) {
int count = children.length;
for (final child in children) {
count += child.totalDescendants;
}
return count;
}
/// 계층 구조 일관성 검증
static HierarchyValidationResult validateConsistency({
required List<CompanyDto> allCompanies,
}) {
final errors = <String>[];
final warnings = <String>[];
for (final company in allCompanies) {
// 부모가 존재하는지 확인
if (company.parentCompanyId != null) {
final parentExists = allCompanies.any((c) => c.id == company.parentCompanyId);
if (!parentExists) {
errors.add('Company ${company.id} references non-existent parent ${company.parentCompanyId}');
}
}
// 순환 참조 확인
final visited = <int>{};
int? currentId = company.parentCompanyId;
while (currentId != null) {
if (visited.contains(currentId)) {
errors.add('Circular reference detected starting from company ${company.id}');
break;
}
visited.add(currentId);
final parent = allCompanies.firstWhere(
(c) => c.id == currentId,
orElse: () => CompanyDto(
id: 0,
name: '',
contactName: '',
contactPhone: '',
contactEmail: '',
address: '',
isActive: false,
registeredAt: DateTime.now(),
),
);
currentId = parent.id == 0 ? null : parent.parentCompanyId;
}
}
if (errors.isNotEmpty) {
return HierarchyValidationResult.invalid(
message: '계층 구조에 일관성 문제가 발견되었습니다.',
errors: errors,
warnings: warnings,
);
}
return HierarchyValidationResult.valid();
}
}