사용하지 않는 파일 정리 전 백업 (Phase 10 완료 후 상태)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/// 커스텀 예외 클래스들 정의
|
||||
library;
|
||||
|
||||
/// 서버 예외
|
||||
class ServerException implements Exception {
|
||||
|
||||
@@ -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 '모든 라이선스가 정상입니다';
|
||||
}
|
||||
|
||||
326
lib/core/migrations/execute_migration.dart
Normal file
326
lib/core/migrations/execute_migration.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
324
lib/core/migrations/license_to_maintenance_migration.dart
Normal file
324
lib/core/migrations/license_to_maintenance_migration.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
467
lib/core/migrations/maintenance_data_validator.dart
Normal file
467
lib/core/migrations/maintenance_data_validator.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
243
lib/core/theme/shadcn_theme.dart
Normal file
243
lib/core/theme/shadcn_theme.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
345
lib/core/utils/hierarchy_validator.dart
Normal file
345
lib/core/utils/hierarchy_validator.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user