feat: V/R 유지보수 시스템 전환 및 대시보드 테이블 형태 완성
- V/R 시스템 완전 전환: WARRANTY/CONTRACT/INSPECTION → V(방문)/R(원격) - 유지보수 대시보드 카드 → StandardDataTable 테이블 형태 전환 - "조회중..." 문제 해결: 백엔드 직접 필드 사용 (equipment_model, company_name) - MaintenanceDto 신규 필드 추가: company_id, company_name, equipment_serial, equipment_model - preloadEquipmentData 비활성화로 불필요한 equipment-history API 호출 제거 - CO-STAR 프레임워크 적용 및 CLAUDE.md v3.0 업데이트 - Flutter Analyze ERROR: 0 유지, 100% shadcn_ui 컴플라이언스 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -48,8 +48,9 @@ abstract class EquipmentHistoryRepository {
|
||||
|
||||
Future<EquipmentHistoryDto> createStockOut({
|
||||
required int equipmentsId,
|
||||
required int warehousesId,
|
||||
int? warehousesId, // 출고 시 null (다른 회사로 완전 이관)
|
||||
required int quantity,
|
||||
List<int>? companyIds, // 출고 대상 회사 ID 목록
|
||||
DateTime? transactedAt,
|
||||
String? remark,
|
||||
});
|
||||
@@ -146,15 +147,37 @@ class EquipmentHistoryRepositoryImpl implements EquipmentHistoryRepository {
|
||||
@override
|
||||
Future<List<StockStatusDto>> getStockStatus() async {
|
||||
try {
|
||||
print('[EquipmentHistoryRepository] Stock Status API 호출 시작');
|
||||
print('[EquipmentHistoryRepository] URL: ${_dio.options.baseUrl}${ApiEndpoints.equipmentHistoryStockStatus}');
|
||||
print('[EquipmentHistoryRepository] Headers: ${_dio.options.headers}');
|
||||
|
||||
final response = await _dio.get(ApiEndpoints.equipmentHistoryStockStatus);
|
||||
|
||||
print('[EquipmentHistoryRepository] API 응답 수신');
|
||||
print('[EquipmentHistoryRepository] Status Code: ${response.statusCode}');
|
||||
print('[EquipmentHistoryRepository] Response Type: ${response.data.runtimeType}');
|
||||
|
||||
final List<dynamic> data = response.data is List
|
||||
? response.data
|
||||
: response.data['data'] ?? [];
|
||||
|
||||
return data.map((json) => StockStatusDto.fromJson(json)).toList();
|
||||
|
||||
print('[EquipmentHistoryRepository] 파싱된 데이터 개수: ${data.length}');
|
||||
|
||||
final result = data.map((json) => StockStatusDto.fromJson(json)).toList();
|
||||
print('[EquipmentHistoryRepository] DTO 변환 완료: ${result.length}개 항목');
|
||||
|
||||
return result;
|
||||
} on DioException catch (e) {
|
||||
print('[EquipmentHistoryRepository] DioException 발생');
|
||||
print('[EquipmentHistoryRepository] Error Type: ${e.type}');
|
||||
print('[EquipmentHistoryRepository] Error Message: ${e.message}');
|
||||
print('[EquipmentHistoryRepository] Response Status: ${e.response?.statusCode}');
|
||||
throw _handleError(e);
|
||||
} catch (e, stackTrace) {
|
||||
print('[EquipmentHistoryRepository] 일반 Exception 발생');
|
||||
print('[EquipmentHistoryRepository] Error: $e');
|
||||
print('[EquipmentHistoryRepository] StackTrace: $stackTrace');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,12 +188,30 @@ class EquipmentHistoryRepositoryImpl implements EquipmentHistoryRepository {
|
||||
EquipmentHistoryRequestDto request,
|
||||
) async {
|
||||
try {
|
||||
print('[EquipmentHistoryRepository] Creating equipment history');
|
||||
print('[EquipmentHistoryRepository] URL: ${_dio.options.baseUrl}${ApiEndpoints.equipmentHistory}');
|
||||
print('[EquipmentHistoryRepository] Headers: ${_dio.options.headers}');
|
||||
print('[EquipmentHistoryRepository] Request data: ${request.toJson()}');
|
||||
|
||||
final response = await _dio.post(
|
||||
ApiEndpoints.equipmentHistory,
|
||||
data: request.toJson(),
|
||||
);
|
||||
|
||||
print('[EquipmentHistoryRepository] Response status: ${response.statusCode}');
|
||||
print('[EquipmentHistoryRepository] Response data: ${response.data}');
|
||||
|
||||
// 응답 데이터 타입 검증
|
||||
if (response.data is String) {
|
||||
print('[EquipmentHistoryRepository] Error: Received String response instead of JSON');
|
||||
throw Exception('서버에서 오류 응답을 받았습니다: ${response.data}');
|
||||
}
|
||||
|
||||
return EquipmentHistoryDto.fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
print('[EquipmentHistoryRepository] DioException occurred');
|
||||
print('[EquipmentHistoryRepository] Request URL: ${e.requestOptions.uri}');
|
||||
print('[EquipmentHistoryRepository] Request headers: ${e.requestOptions.headers}');
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
@@ -213,7 +254,7 @@ class EquipmentHistoryRepositoryImpl implements EquipmentHistoryRepository {
|
||||
warehousesId: warehousesId,
|
||||
quantity: quantity,
|
||||
transactionType: 'I', // 입고
|
||||
transactedAt: transactedAt ?? DateTime.now(),
|
||||
transactedAt: (transactedAt ?? DateTime.now()).toUtc(), // UTC로 변환하여 타임존 정보 포함
|
||||
remark: remark,
|
||||
);
|
||||
|
||||
@@ -223,8 +264,9 @@ class EquipmentHistoryRepositoryImpl implements EquipmentHistoryRepository {
|
||||
@override
|
||||
Future<EquipmentHistoryDto> createStockOut({
|
||||
required int equipmentsId,
|
||||
required int warehousesId,
|
||||
int? warehousesId, // 출고 시 null (다른 회사로 완전 이관)
|
||||
required int quantity,
|
||||
List<int>? companyIds, // 출고 대상 회사 ID 목록
|
||||
DateTime? transactedAt,
|
||||
String? remark,
|
||||
}) async {
|
||||
@@ -232,9 +274,10 @@ class EquipmentHistoryRepositoryImpl implements EquipmentHistoryRepository {
|
||||
final request = EquipmentHistoryRequestDto(
|
||||
equipmentsId: equipmentsId,
|
||||
warehousesId: warehousesId,
|
||||
companyIds: companyIds, // 백엔드 API에 회사 정보 전달
|
||||
quantity: quantity,
|
||||
transactionType: 'O', // 출고
|
||||
transactedAt: transactedAt ?? DateTime.now(),
|
||||
transactedAt: (transactedAt ?? DateTime.now()).toUtc(), // UTC로 변환하여 타임존 정보 포함
|
||||
remark: remark,
|
||||
);
|
||||
|
||||
@@ -242,9 +285,31 @@ class EquipmentHistoryRepositoryImpl implements EquipmentHistoryRepository {
|
||||
}
|
||||
|
||||
Exception _handleError(DioException e) {
|
||||
print('[EquipmentHistoryRepository] _handleError called');
|
||||
print('[EquipmentHistoryRepository] Error type: ${e.type}');
|
||||
print('[EquipmentHistoryRepository] Error message: ${e.message}');
|
||||
|
||||
if (e.response != null) {
|
||||
final statusCode = e.response!.statusCode;
|
||||
final message = e.response!.data['message'] ?? '오류가 발생했습니다.';
|
||||
print('[EquipmentHistoryRepository] Response status: $statusCode');
|
||||
print('[EquipmentHistoryRepository] Response data type: ${e.response!.data.runtimeType}');
|
||||
print('[EquipmentHistoryRepository] Response data: ${e.response!.data}');
|
||||
|
||||
// 안전한 메시지 추출
|
||||
String message = '오류가 발생했습니다.';
|
||||
try {
|
||||
final responseData = e.response!.data;
|
||||
if (responseData is Map<String, dynamic>) {
|
||||
message = responseData['message']?.toString() ?? message;
|
||||
} else if (responseData is String) {
|
||||
message = responseData;
|
||||
} else {
|
||||
message = responseData.toString();
|
||||
}
|
||||
} catch (messageError) {
|
||||
print('[EquipmentHistoryRepository] Message extraction error: $messageError');
|
||||
message = '응답 처리 중 오류가 발생했습니다.';
|
||||
}
|
||||
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
@@ -263,6 +328,6 @@ class EquipmentHistoryRepositoryImpl implements EquipmentHistoryRepository {
|
||||
return Exception('오류가 발생했습니다: $message');
|
||||
}
|
||||
}
|
||||
return Exception('네트워크 오류가 발생했습니다.');
|
||||
return Exception('네트워크 오류가 발생했습니다: ${e.message}');
|
||||
}
|
||||
}
|
||||
25
lib/data/repositories/maintenance_stats_repository.dart
Normal file
25
lib/data/repositories/maintenance_stats_repository.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:superport/data/models/maintenance_stats_dto.dart';
|
||||
|
||||
/// 유지보수 통계 데이터 리포지토리
|
||||
/// 기존 maintenance API를 활용하여 대시보드 통계를 계산합니다.
|
||||
abstract class MaintenanceStatsRepository {
|
||||
/// 유지보수 대시보드 통계 조회
|
||||
/// 60일내, 30일내, 7일내, 만료된 계약 등의 통계를 계산합니다.
|
||||
Future<MaintenanceStatsDto> getMaintenanceStats();
|
||||
|
||||
/// 특정 기간별 만료 예정 계약 통계
|
||||
/// [days] 일 내 만료 예정 계약 수를 반환합니다.
|
||||
Future<int> getExpiringContractsCount({required int days});
|
||||
|
||||
/// 계약 타입별 통계
|
||||
/// WARRANTY, CONTRACT, INSPECTION 별 계약 수를 반환합니다.
|
||||
Future<Map<String, int>> getContractsByType();
|
||||
|
||||
/// 만료된 계약 통계
|
||||
/// 현재 기준으로 만료된 계약 수를 반환합니다.
|
||||
Future<int> getExpiredContractsCount();
|
||||
|
||||
/// 전체 활성 계약 수
|
||||
/// 삭제되지 않은 활성 계약의 총 개수를 반환합니다.
|
||||
Future<int> getActiveContractsCount();
|
||||
}
|
||||
223
lib/data/repositories/maintenance_stats_repository_impl.dart
Normal file
223
lib/data/repositories/maintenance_stats_repository_impl.dart
Normal file
@@ -0,0 +1,223 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:superport/core/errors/exceptions.dart';
|
||||
import 'package:superport/core/errors/failures.dart';
|
||||
import 'package:superport/data/datasources/remote/maintenance_remote_datasource.dart';
|
||||
import 'package:superport/data/models/maintenance_dto.dart';
|
||||
import 'package:superport/data/models/maintenance_stats_dto.dart';
|
||||
import 'package:superport/data/repositories/maintenance_stats_repository.dart';
|
||||
|
||||
@LazySingleton(as: MaintenanceStatsRepository)
|
||||
class MaintenanceStatsRepositoryImpl implements MaintenanceStatsRepository {
|
||||
final MaintenanceRemoteDataSource _remoteDataSource;
|
||||
|
||||
MaintenanceStatsRepositoryImpl({
|
||||
required MaintenanceRemoteDataSource remoteDataSource,
|
||||
}) : _remoteDataSource = remoteDataSource;
|
||||
|
||||
@override
|
||||
Future<MaintenanceStatsDto> getMaintenanceStats() async {
|
||||
try {
|
||||
// 모든 활성 계약 조회 (대용량 데이터를 위해 페이지 크기 증가)
|
||||
final allMaintenances = await _getAllActiveMaintenances();
|
||||
|
||||
// 통계 계산
|
||||
final stats = _calculateStats(allMaintenances);
|
||||
|
||||
return stats.copyWith(updatedAt: DateTime.now());
|
||||
} on ServerException catch (e) {
|
||||
throw ServerFailure(
|
||||
message: e.message,
|
||||
statusCode: e.statusCode,
|
||||
);
|
||||
} catch (e) {
|
||||
throw ServerFailure(message: '유지보수 통계 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getExpiringContractsCount({required int days}) async {
|
||||
try {
|
||||
final expiringMaintenances = await _remoteDataSource.getExpiringMaintenances(days: days);
|
||||
return expiringMaintenances.length;
|
||||
} on ServerException catch (e) {
|
||||
throw ServerFailure(
|
||||
message: e.message,
|
||||
statusCode: e.statusCode,
|
||||
);
|
||||
} catch (e) {
|
||||
throw ServerFailure(message: '만료 예정 계약 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, int>> getContractsByType() async {
|
||||
try {
|
||||
final allMaintenances = await _getAllActiveMaintenances();
|
||||
|
||||
final contractsByType = <String, int>{
|
||||
MaintenanceType.visit: 0,
|
||||
MaintenanceType.remote: 0,
|
||||
};
|
||||
|
||||
for (final maintenance in allMaintenances) {
|
||||
final type = maintenance.maintenanceType;
|
||||
contractsByType[type] = (contractsByType[type] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return contractsByType;
|
||||
} on ServerException catch (e) {
|
||||
throw ServerFailure(
|
||||
message: e.message,
|
||||
statusCode: e.statusCode,
|
||||
);
|
||||
} catch (e) {
|
||||
throw ServerFailure(message: '계약 타입별 통계 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getExpiredContractsCount() async {
|
||||
try {
|
||||
// 만료된 계약 조회 (is_expired = true)
|
||||
final expiredResponse = await _remoteDataSource.getMaintenances(
|
||||
isExpired: true,
|
||||
perPage: 1000, // 충분히 큰 값으로 설정
|
||||
);
|
||||
|
||||
return expiredResponse.totalCount;
|
||||
} on ServerException catch (e) {
|
||||
throw ServerFailure(
|
||||
message: e.message,
|
||||
statusCode: e.statusCode,
|
||||
);
|
||||
} catch (e) {
|
||||
throw ServerFailure(message: '만료된 계약 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getActiveContractsCount() async {
|
||||
try {
|
||||
// 활성 계약만 조회 (is_expired = false)
|
||||
final activeResponse = await _remoteDataSource.getMaintenances(
|
||||
isExpired: false,
|
||||
perPage: 1, // 개수만 필요하므로 1개만 조회
|
||||
);
|
||||
|
||||
return activeResponse.totalCount;
|
||||
} on ServerException catch (e) {
|
||||
throw ServerFailure(
|
||||
message: e.message,
|
||||
statusCode: e.statusCode,
|
||||
);
|
||||
} catch (e) {
|
||||
throw ServerFailure(message: '활성 계약 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
}
|
||||
|
||||
/// 모든 활성 계약을 페이지네이션으로 조회
|
||||
Future<List<MaintenanceDto>> _getAllActiveMaintenances() async {
|
||||
final allMaintenances = <MaintenanceDto>[];
|
||||
int currentPage = 1;
|
||||
const int perPage = 100; // 한 번에 많은 데이터 조회로 API 호출 최소화
|
||||
|
||||
while (true) {
|
||||
final response = await _remoteDataSource.getMaintenances(
|
||||
page: currentPage,
|
||||
perPage: perPage,
|
||||
isExpired: false, // 활성 계약만 조회
|
||||
);
|
||||
|
||||
allMaintenances.addAll(response.items);
|
||||
|
||||
// 마지막 페이지 도달 시 종료
|
||||
if (currentPage >= response.totalPages) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentPage++;
|
||||
}
|
||||
|
||||
return allMaintenances;
|
||||
}
|
||||
|
||||
/// 유지보수 데이터를 기반으로 통계 계산
|
||||
MaintenanceStatsDto _calculateStats(List<MaintenanceDto> maintenances) {
|
||||
final now = DateTime.now();
|
||||
|
||||
// 기본 카운터 초기화
|
||||
int expiring60Days = 0;
|
||||
int expiring30Days = 0;
|
||||
int expiring7Days = 0;
|
||||
int expiredContracts = 0;
|
||||
|
||||
final contractsByType = <String, int>{
|
||||
MaintenanceType.visit: 0,
|
||||
MaintenanceType.remote: 0,
|
||||
};
|
||||
|
||||
// 각 유지보수 계약별 통계 계산
|
||||
for (final maintenance in maintenances) {
|
||||
// 계약 타입별 카운트
|
||||
final type = maintenance.maintenanceType;
|
||||
contractsByType[type] = (contractsByType[type] ?? 0) + 1;
|
||||
|
||||
// 만료 상태 체크
|
||||
if (maintenance.isExpired) {
|
||||
expiredContracts++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 만료일까지 남은 일수 계산
|
||||
final daysRemaining = maintenance.daysRemaining ?? 0;
|
||||
|
||||
if (daysRemaining <= 7) {
|
||||
expiring7Days++;
|
||||
} else if (daysRemaining <= 30) {
|
||||
expiring30Days++;
|
||||
} else if (daysRemaining <= 60) {
|
||||
expiring60Days++;
|
||||
}
|
||||
}
|
||||
|
||||
// 총 계약 수 계산
|
||||
final totalContracts = maintenances.length + expiredContracts;
|
||||
final activeContracts = maintenances.length;
|
||||
|
||||
// 위험 수준의 계약들의 매출 위험도 추정 (간단한 계산)
|
||||
final totalRevenueAtRisk = (expiring7Days * 500000.0) +
|
||||
(expiring30Days * 300000.0) +
|
||||
(expiredContracts * 1000000.0);
|
||||
|
||||
// 완료율 계산 (활성 계약 / 전체 계약)
|
||||
final completionRate = totalContracts > 0
|
||||
? (activeContracts / totalContracts)
|
||||
: 1.0;
|
||||
|
||||
return MaintenanceStatsDto(
|
||||
// 기본 통계
|
||||
activeContracts: activeContracts,
|
||||
totalContracts: totalContracts,
|
||||
|
||||
// 만료 기간별 통계 (사용자 요구사항)
|
||||
expiring60Days: expiring60Days,
|
||||
expiring30Days: expiring30Days,
|
||||
expiring7Days: expiring7Days,
|
||||
expiredContracts: expiredContracts,
|
||||
|
||||
// 타입별 통계 (V/R 시스템)
|
||||
visitContracts: contractsByType[MaintenanceType.visit] ?? 0,
|
||||
remoteContracts: contractsByType[MaintenanceType.remote] ?? 0,
|
||||
|
||||
// 예정 작업 (방문 계약과 동일하게 처리)
|
||||
upcomingVisits: contractsByType[MaintenanceType.visit] ?? 0,
|
||||
overdueMaintenances: expiredContracts,
|
||||
|
||||
// 추가 메트릭
|
||||
totalRevenueAtRisk: totalRevenueAtRisk,
|
||||
completionRate: completionRate,
|
||||
|
||||
updatedAt: now,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user