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:
202
lib/services/equipment_warehouse_cache_service.dart
Normal file
202
lib/services/equipment_warehouse_cache_service.dart
Normal file
@@ -0,0 +1,202 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:superport/data/models/stock_status_dto.dart';
|
||||
import 'package:superport/data/repositories/equipment_history_repository.dart';
|
||||
|
||||
/// 장비-창고 매핑 캐시 서비스
|
||||
///
|
||||
/// Stock Status API를 활용하여 장비별 현재 창고 정보를 캐싱하고
|
||||
/// 빠른 조회를 제공하는 싱글톤 서비스입니다.
|
||||
///
|
||||
/// 주요 기능:
|
||||
/// - 앱 시작 시 전체 장비-창고 매핑 로드 및 캐싱
|
||||
/// - 출고 처리 후 자동 캐시 갱신
|
||||
/// - 장비별 현재 창고 정보 빠른 조회
|
||||
/// - Fallback 전략으로 안정성 보장
|
||||
class EquipmentWarehouseCacheService {
|
||||
static final EquipmentWarehouseCacheService _instance =
|
||||
EquipmentWarehouseCacheService._internal();
|
||||
|
||||
factory EquipmentWarehouseCacheService() => _instance;
|
||||
EquipmentWarehouseCacheService._internal();
|
||||
|
||||
// 의존성 주입
|
||||
late final EquipmentHistoryRepository _repository = GetIt.instance<EquipmentHistoryRepository>();
|
||||
|
||||
// 캐시 저장소
|
||||
final Map<int, StockStatusDto> _cache = {};
|
||||
|
||||
// 상태 관리
|
||||
bool _isLoaded = false;
|
||||
bool _isLoading = false;
|
||||
DateTime? _lastUpdated;
|
||||
String? _lastError;
|
||||
|
||||
// 설정 상수
|
||||
static const int _cacheValidMinutes = 10; // 10분간 캐시 유효
|
||||
static const int _maxRetryCount = 3; // 최대 재시도 횟수
|
||||
|
||||
/// 캐시 로딩 상태
|
||||
bool get isLoaded => _isLoaded;
|
||||
bool get isLoading => _isLoading;
|
||||
DateTime? get lastUpdated => _lastUpdated;
|
||||
String? get lastError => _lastError;
|
||||
int get cachedCount => _cache.length;
|
||||
|
||||
/// 캐시 로드 (앱 시작 시 또는 필요 시 호출)
|
||||
///
|
||||
/// Returns:
|
||||
/// - true: 로드 성공
|
||||
/// - false: 로드 실패 (에러는 lastError에서 확인)
|
||||
Future<bool> loadCache() async {
|
||||
if (_isLoading) return _isLoaded; // 이미 로딩 중이면 현재 상태 반환
|
||||
|
||||
_isLoading = true;
|
||||
_lastError = null;
|
||||
|
||||
try {
|
||||
print('[EquipmentWarehouseCacheService] 재고 현황 로딩 시작...');
|
||||
print('[EquipmentWarehouseCacheService] Repository: ${_repository.runtimeType}');
|
||||
|
||||
// Stock Status API 호출
|
||||
final stocks = await _repository.getStockStatus();
|
||||
|
||||
print('[EquipmentWarehouseCacheService] API 응답 수신: ${stocks.length}개 항목');
|
||||
|
||||
// 캐시 업데이트
|
||||
_cache.clear();
|
||||
for (var stock in stocks) {
|
||||
print('[EquipmentWarehouseCacheService] 캐시 추가: 장비${stock.equipmentId} → ${stock.warehouseName}');
|
||||
_cache[stock.equipmentId] = stock;
|
||||
}
|
||||
|
||||
// 상태 업데이트
|
||||
_isLoaded = true;
|
||||
_lastUpdated = DateTime.now();
|
||||
|
||||
print('[EquipmentWarehouseCacheService] 재고 현황 로딩 완료: ${_cache.length}개 장비');
|
||||
print('[EquipmentWarehouseCacheService] 캐시된 장비 ID들: ${_cache.keys.toList()}');
|
||||
|
||||
return true;
|
||||
} catch (e, stackTrace) {
|
||||
_lastError = '재고 현황 로딩 실패: $e';
|
||||
print('[EquipmentWarehouseCacheService] $_lastError');
|
||||
print('[EquipmentWarehouseCacheService] StackTrace: $stackTrace');
|
||||
return false;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 장비의 현재 창고 정보 조회
|
||||
///
|
||||
/// [equipmentId]: 조회할 장비 ID
|
||||
///
|
||||
/// Returns:
|
||||
/// - StockStatusDto: 장비의 재고 현황 정보
|
||||
/// - null: 캐시에 없는 경우
|
||||
StockStatusDto? getEquipmentStock(int equipmentId) {
|
||||
return _cache[equipmentId];
|
||||
}
|
||||
|
||||
/// 장비의 현재 창고명 조회 (간편 메소드)
|
||||
///
|
||||
/// [equipmentId]: 조회할 장비 ID
|
||||
/// [fallbackName]: 캐시에 없을 때 반환할 기본값
|
||||
///
|
||||
/// Returns: 창고명 또는 fallbackName
|
||||
String getWarehouseName(int equipmentId, {String fallbackName = '위치 미확인'}) {
|
||||
return _cache[equipmentId]?.warehouseName ?? fallbackName;
|
||||
}
|
||||
|
||||
/// 장비의 현재 창고 ID 조회
|
||||
///
|
||||
/// [equipmentId]: 조회할 장비 ID
|
||||
///
|
||||
/// Returns: 창고 ID 또는 null
|
||||
int? getWarehouseId(int equipmentId) {
|
||||
return _cache[equipmentId]?.warehouseId;
|
||||
}
|
||||
|
||||
/// 장비가 특정 창고에 있는지 확인
|
||||
///
|
||||
/// [equipmentId]: 확인할 장비 ID
|
||||
/// [warehouseId]: 확인할 창고 ID
|
||||
///
|
||||
/// Returns: true if 장비가 해당 창고에 있음
|
||||
bool isEquipmentInWarehouse(int equipmentId, int warehouseId) {
|
||||
return _cache[equipmentId]?.warehouseId == warehouseId;
|
||||
}
|
||||
|
||||
/// 특정 창고에 있는 모든 장비 목록 조회
|
||||
///
|
||||
/// [warehouseId]: 조회할 창고 ID
|
||||
///
|
||||
/// Returns: 해당 창고에 있는 장비들의 StockStatusDto 목록
|
||||
List<StockStatusDto> getEquipmentsByWarehouse(int warehouseId) {
|
||||
return _cache.values
|
||||
.where((stock) => stock.warehouseId == warehouseId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 캐시 갱신 필요 여부 확인
|
||||
///
|
||||
/// Returns: true if 캐시 갱신이 필요함
|
||||
bool needsRefresh() {
|
||||
if (!_isLoaded || _lastUpdated == null) return true;
|
||||
|
||||
final difference = DateTime.now().difference(_lastUpdated!);
|
||||
return difference.inMinutes >= _cacheValidMinutes;
|
||||
}
|
||||
|
||||
/// 캐시 강제 갱신
|
||||
///
|
||||
/// 출고/입고 처리 후 호출하여 최신 재고 상태를 반영합니다.
|
||||
///
|
||||
/// Returns: true if 갱신 성공
|
||||
Future<bool> refreshCache() async {
|
||||
print('[EquipmentWarehouseCacheService] 강제 캐시 갱신 시작...');
|
||||
|
||||
// 강제로 갱신하도록 상태 초기화
|
||||
_isLoaded = false;
|
||||
_lastUpdated = null;
|
||||
|
||||
return await loadCache();
|
||||
}
|
||||
|
||||
/// 캐시 무효화 (메모리 정리)
|
||||
///
|
||||
/// 로그아웃 시 또는 메모리 절약이 필요할 때 호출
|
||||
void invalidateCache() {
|
||||
_cache.clear();
|
||||
_isLoaded = false;
|
||||
_isLoading = false;
|
||||
_lastUpdated = null;
|
||||
_lastError = null;
|
||||
|
||||
print('[EquipmentWarehouseCacheService] 캐시 무효화 완료');
|
||||
}
|
||||
|
||||
/// 캐시 통계 정보 조회 (디버깅용)
|
||||
///
|
||||
/// Returns: 캐시 상태 정보 맵
|
||||
Map<String, dynamic> getCacheStats() {
|
||||
return {
|
||||
'isLoaded': _isLoaded,
|
||||
'isLoading': _isLoading,
|
||||
'cachedCount': _cache.length,
|
||||
'lastUpdated': _lastUpdated?.toIso8601String(),
|
||||
'lastError': _lastError,
|
||||
'needsRefresh': needsRefresh(),
|
||||
'cacheValidMinutes': _cacheValidMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
/// 개발용: 캐시 상태 출력
|
||||
void printCacheStats() {
|
||||
final stats = getCacheStats();
|
||||
print('[EquipmentWarehouseCacheService] Cache Stats:');
|
||||
stats.forEach((key, value) {
|
||||
print(' $key: $value');
|
||||
});
|
||||
}
|
||||
}
|
||||
248
lib/services/inventory_history_service.dart
Normal file
248
lib/services/inventory_history_service.dart
Normal file
@@ -0,0 +1,248 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:superport/data/models/inventory_history_view_model.dart';
|
||||
import 'package:superport/data/models/equipment_history_dto.dart';
|
||||
import 'package:superport/data/models/equipment/equipment_dto.dart';
|
||||
import 'package:superport/data/repositories/equipment_history_repository.dart';
|
||||
import 'package:superport/domain/usecases/equipment/get_equipment_detail_usecase.dart';
|
||||
import 'package:superport/core/constants/app_constants.dart';
|
||||
|
||||
/// 재고 이력 관리 화면 전용 서비스
|
||||
/// 백엔드 여러 API를 조합하여 화면용 데이터 제공
|
||||
class InventoryHistoryService {
|
||||
final EquipmentHistoryRepository _historyRepository;
|
||||
final GetEquipmentDetailUseCase _equipmentDetailUseCase;
|
||||
|
||||
InventoryHistoryService({
|
||||
EquipmentHistoryRepository? historyRepository,
|
||||
GetEquipmentDetailUseCase? equipmentDetailUseCase,
|
||||
}) : _historyRepository = historyRepository ?? GetIt.instance<EquipmentHistoryRepository>(),
|
||||
_equipmentDetailUseCase = equipmentDetailUseCase ?? GetIt.instance<GetEquipmentDetailUseCase>();
|
||||
|
||||
/// 재고 이력 목록 로드 (여러 API 조합)
|
||||
Future<InventoryHistoryListResponse> loadInventoryHistories({
|
||||
int page = 1,
|
||||
int pageSize = AppConstants.historyPageSize,
|
||||
String? searchKeyword,
|
||||
String? transactionType,
|
||||
int? equipmentId,
|
||||
int? warehouseId,
|
||||
int? companyId,
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
}) async {
|
||||
try {
|
||||
// 1. Equipment History 기본 데이터 로드
|
||||
final historyResponse = await _historyRepository.getEquipmentHistories(
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
transactionType: transactionType,
|
||||
equipmentsId: equipmentId,
|
||||
warehousesId: warehouseId,
|
||||
startDate: dateFrom?.toIso8601String(),
|
||||
endDate: dateTo?.toIso8601String(),
|
||||
);
|
||||
|
||||
// 2. 각 이력에 대해 추가 정보 조합
|
||||
final List<InventoryHistoryViewModel> enrichedItems = [];
|
||||
|
||||
for (final history in historyResponse.items) {
|
||||
try {
|
||||
final viewModel = await _enrichHistoryWithDetails(history, searchKeyword);
|
||||
if (viewModel != null) {
|
||||
enrichedItems.add(viewModel);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[InventoryHistoryService] Failed to enrich history ${history.id}: $e');
|
||||
// 에러 발생해도 기본 정보로라도 표시
|
||||
final fallbackViewModel = _createFallbackViewModel(history);
|
||||
enrichedItems.add(fallbackViewModel);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 검색 키워드 필터링 (서버 검색 후 추가 로컬 필터링)
|
||||
List<InventoryHistoryViewModel> filteredItems = enrichedItems;
|
||||
if (searchKeyword != null && searchKeyword.isNotEmpty) {
|
||||
filteredItems = _applyLocalSearch(enrichedItems, searchKeyword);
|
||||
}
|
||||
|
||||
return InventoryHistoryListResponse(
|
||||
items: filteredItems,
|
||||
totalCount: historyResponse.totalCount,
|
||||
currentPage: historyResponse.currentPage,
|
||||
totalPages: historyResponse.totalPages,
|
||||
pageSize: historyResponse.pageSize,
|
||||
);
|
||||
} catch (e) {
|
||||
print('[InventoryHistoryService] Error loading inventory histories: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 특정 장비의 전체 이력 로드 (상세보기용)
|
||||
Future<List<InventoryHistoryViewModel>> loadEquipmentHistory(int equipmentId) async {
|
||||
try {
|
||||
// 해당 장비의 모든 이력을 시간순(최신순)으로 로드
|
||||
final historyResponse = await _historyRepository.getEquipmentHistories(
|
||||
equipmentsId: equipmentId,
|
||||
page: 1,
|
||||
pageSize: AppConstants.maxBulkPageSize, // 모든 이력 로드
|
||||
);
|
||||
|
||||
final List<InventoryHistoryViewModel> items = [];
|
||||
for (final history in historyResponse.items) {
|
||||
try {
|
||||
final viewModel = await _enrichHistoryWithDetails(history);
|
||||
if (viewModel != null) {
|
||||
items.add(viewModel);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[InventoryHistoryService] Failed to enrich equipment history ${history.id}: $e');
|
||||
final fallbackViewModel = _createFallbackViewModel(history);
|
||||
items.add(fallbackViewModel);
|
||||
}
|
||||
}
|
||||
|
||||
// 시간순 정렬 (최신순)
|
||||
items.sort((a, b) => b.changedDate.compareTo(a.changedDate));
|
||||
|
||||
return items;
|
||||
} catch (e) {
|
||||
print('[InventoryHistoryService] Error loading equipment history: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// History 데이터에 추가 정보를 조합하여 ViewModel 생성
|
||||
Future<InventoryHistoryViewModel?> _enrichHistoryWithDetails(
|
||||
EquipmentHistoryDto history,
|
||||
[String? searchKeyword]
|
||||
) async {
|
||||
try {
|
||||
// Equipment 상세 정보 로드
|
||||
EquipmentDto? equipmentDetail;
|
||||
if (history.equipmentsId != null) {
|
||||
final equipmentResult = await _equipmentDetailUseCase(history.equipmentsId);
|
||||
equipmentResult.fold(
|
||||
(failure) {
|
||||
print('[InventoryHistoryService] Failed to load equipment ${history.equipmentsId}: ${failure.message}');
|
||||
},
|
||||
(equipment) {
|
||||
equipmentDetail = equipment;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 장비명 결정 (Equipment API에서 가져오거나 fallback)
|
||||
final String equipmentName = _determineEquipmentName(equipmentDetail, history);
|
||||
|
||||
// 시리얼번호 결정
|
||||
final String serialNumber = _determineSerialNumber(equipmentDetail, history);
|
||||
|
||||
// 위치 결정 (transaction_type에 따라 다르게)
|
||||
final String location = _determineLocation(history);
|
||||
|
||||
return InventoryHistoryViewModel(
|
||||
historyId: history.id ?? 0,
|
||||
equipmentId: history.equipmentsId,
|
||||
equipmentName: equipmentName,
|
||||
serialNumber: serialNumber,
|
||||
location: location,
|
||||
changedDate: history.transactedAt,
|
||||
remark: history.remark,
|
||||
transactionType: history.transactionType,
|
||||
quantity: history.quantity,
|
||||
originalHistory: history,
|
||||
);
|
||||
} catch (e) {
|
||||
print('[InventoryHistoryService] Error enriching history ${history.id}: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 장비명 결정 로직
|
||||
String _determineEquipmentName(EquipmentDto? equipment, EquipmentHistoryDto history) {
|
||||
if (equipment != null) {
|
||||
// Equipment API에서 가져온 정보 우선 사용
|
||||
if (equipment.modelName != null && equipment.vendorName != null) {
|
||||
return '${equipment.vendorName} ${equipment.modelName}';
|
||||
} else if (equipment.modelName != null) {
|
||||
return equipment.modelName!;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: History의 equipment_serial 사용
|
||||
if (history.equipmentSerial != null) {
|
||||
return history.equipmentSerial!;
|
||||
}
|
||||
|
||||
return 'Unknown Equipment';
|
||||
}
|
||||
|
||||
/// 시리얼번호 결정 로직
|
||||
String _determineSerialNumber(EquipmentDto? equipment, EquipmentHistoryDto history) {
|
||||
if (equipment != null && equipment.serialNumber != null) {
|
||||
return equipment.serialNumber!;
|
||||
}
|
||||
|
||||
if (history.equipmentSerial != null) {
|
||||
return history.equipmentSerial!;
|
||||
}
|
||||
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
/// 위치 결정 로직 (transaction_type에 따라 다르게)
|
||||
String _determineLocation(EquipmentHistoryDto history) {
|
||||
switch (history.transactionType) {
|
||||
case 'O': // 출고
|
||||
case 'R': // 대여
|
||||
// 고객사 정보 사용
|
||||
if (history.companies.isNotEmpty) {
|
||||
final company = history.companies.first;
|
||||
return company['name']?.toString() ?? 'Unknown Company';
|
||||
}
|
||||
return 'Unknown Company';
|
||||
|
||||
case 'I': // 입고
|
||||
case 'D': // 폐기
|
||||
// 창고 정보 사용
|
||||
return history.warehouseName ?? 'Unknown Warehouse';
|
||||
|
||||
default:
|
||||
return 'N/A';
|
||||
}
|
||||
}
|
||||
|
||||
/// 에러 발생 시 fallback ViewModel 생성
|
||||
InventoryHistoryViewModel _createFallbackViewModel(EquipmentHistoryDto history) {
|
||||
return InventoryHistoryViewModel(
|
||||
historyId: history.id ?? 0,
|
||||
equipmentId: history.equipmentsId,
|
||||
equipmentName: history.equipmentSerial ?? 'Unknown Equipment',
|
||||
serialNumber: history.equipmentSerial ?? 'N/A',
|
||||
location: _determineLocation(history),
|
||||
changedDate: history.transactedAt,
|
||||
remark: history.remark,
|
||||
transactionType: history.transactionType,
|
||||
quantity: history.quantity,
|
||||
originalHistory: history,
|
||||
);
|
||||
}
|
||||
|
||||
/// 로컬 검색 필터링 적용
|
||||
List<InventoryHistoryViewModel> _applyLocalSearch(
|
||||
List<InventoryHistoryViewModel> items,
|
||||
String searchKeyword
|
||||
) {
|
||||
final keyword = searchKeyword.toLowerCase();
|
||||
return items.where((item) {
|
||||
return [
|
||||
item.equipmentName,
|
||||
item.serialNumber,
|
||||
item.location,
|
||||
item.remark ?? '',
|
||||
item.transactionTypeDisplay,
|
||||
].any((field) => field.toLowerCase().contains(keyword));
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user