feat: 장비 관리 API 연동 구현

- Equipment 관련 DTO 모델 생성 (Request/Response/List/History/In/Out/IO)
- EquipmentRemoteDataSource 구현 (10개 API 엔드포인트)
- EquipmentService 비즈니스 로직 구현
- Controller를 ChangeNotifier 패턴으로 개선
- 장비 목록 화면에 Provider 패턴 및 무한 스크롤 적용
- 장비 입고 화면 API 연동 및 비동기 처리
- DI 컨테이너에 Equipment 관련 의존성 등록
- API/Mock 데이터 소스 전환 가능 (Feature Flag)
- API 통합 진행 상황 문서 업데이트
This commit is contained in:
JiWoong Sul
2025-07-24 16:26:04 +09:00
parent a13c485302
commit 1d1e38bcfa
30 changed files with 4920 additions and 80 deletions

View File

@@ -1,15 +1,29 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/services/equipment_service.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/core/errors/failures.dart';
/// 장비 입고 폼 컨트롤러
///
/// 폼의 전체 상태, 유효성, 저장, 데이터 로딩 등 비즈니스 로직을 담당한다.
class EquipmentInFormController {
class EquipmentInFormController extends ChangeNotifier {
final MockDataService dataService;
final EquipmentService _equipmentService = GetIt.instance<EquipmentService>();
final int? equipmentInId;
bool _isLoading = false;
String? _error;
bool _isSaving = false;
bool _useApi = true; // Feature flag
// Getters
bool get isLoading => _isLoading;
String? get error => _error;
bool get isSaving => _isSaving;
// 폼 키
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
@@ -167,11 +181,17 @@ class EquipmentInFormController {
}
// 저장 처리
bool save() {
Future<bool> save() async {
if (!formKey.currentState!.validate()) {
return false;
}
formKey.currentState!.save();
_isSaving = true;
_error = null;
notifyListeners();
try {
// 입력값이 리스트에 없으면 추가
if (partnerCompany != null &&
@@ -221,31 +241,63 @@ class EquipmentInFormController {
warrantyEndDate: warrantyEndDate,
// 워런티 코드 저장 필요시 여기에 추가
);
if (isEditMode) {
final equipmentIn = dataService.getEquipmentInById(equipmentInId!);
if (equipmentIn != null) {
final updatedEquipmentIn = EquipmentIn(
id: equipmentIn.id,
if (_useApi) {
// API 호출
if (isEditMode) {
// 수정 모드 - API로 장비 정보 업데이트
await _equipmentService.updateEquipment(equipmentInId!, equipment);
} else {
// 생성 모드
// 1. 먼저 장비 생성
final createdEquipment = await _equipmentService.createEquipment(equipment);
// 2. 입고 처리 (warehouse location ID 필요)
int? warehouseLocationId;
if (warehouseLocation != null) {
// TODO: 창고 위치 ID 가져오기 - 현재는 목 데이터에서 찾기
final warehouse = dataService.getAllWarehouseLocations().firstWhere(
(w) => w.name == warehouseLocation,
orElse: () => null,
);
warehouseLocationId = warehouse?.id;
}
await _equipmentService.equipmentIn(
equipmentId: createdEquipment.id!,
quantity: quantity,
warehouseLocationId: warehouseLocationId,
notes: remarkController.text.trim(),
);
}
} else {
// Mock 데이터 사용
if (isEditMode) {
final equipmentIn = dataService.getEquipmentInById(equipmentInId!);
if (equipmentIn != null) {
final updatedEquipmentIn = EquipmentIn(
id: equipmentIn.id,
equipment: equipment,
inDate: inDate,
status: equipmentIn.status,
type: equipmentType,
warehouseLocation: warehouseLocation,
partnerCompany: partnerCompany,
remark: remarkController.text.trim(),
);
dataService.updateEquipmentIn(updatedEquipmentIn);
}
} else {
final newEquipmentIn = EquipmentIn(
equipment: equipment,
inDate: inDate,
status: equipmentIn.status,
type: equipmentType,
warehouseLocation: warehouseLocation,
partnerCompany: partnerCompany,
remark: remarkController.text.trim(),
);
dataService.updateEquipmentIn(updatedEquipmentIn);
dataService.addEquipmentIn(newEquipmentIn);
}
} else {
final newEquipmentIn = EquipmentIn(
equipment: equipment,
inDate: inDate,
type: equipmentType,
warehouseLocation: warehouseLocation,
partnerCompany: partnerCompany,
remark: remarkController.text.trim(),
);
dataService.addEquipmentIn(newEquipmentIn);
}
// 저장 후 리스트 재로딩 (중복 방지 및 최신화)
@@ -259,9 +311,35 @@ class EquipmentInFormController {
_loadWarrantyLicenses();
return true;
} on Failure catch (e) {
_error = e.message;
notifyListeners();
return false;
} catch (e) {
_error = 'An unexpected error occurred: $e';
notifyListeners();
return false;
} finally {
_isSaving = false;
notifyListeners();
}
}
// 에러 처리
void clearError() {
_error = null;
notifyListeners();
}
// API 사용 여부 토글 (테스트용)
void toggleApiUsage() {
_useApi = !_useApi;
notifyListeners();
}
@override
void dispose() {
remarkController.dispose();
super.dispose();
}
}

View File

@@ -1,6 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/services/equipment_service.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/models/equipment_unified_model.dart' as legacy;
// companyTypeToString 함수 import
import 'package:superport/utils/constants.dart'
@@ -9,28 +14,98 @@ import 'package:superport/models/company_model.dart';
import 'package:superport/models/address_model.dart';
// 장비 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class EquipmentListController {
class EquipmentListController extends ChangeNotifier {
final MockDataService dataService;
final EquipmentService _equipmentService = GetIt.instance<EquipmentService>();
List<UnifiedEquipment> equipments = [];
String? selectedStatusFilter;
final Set<String> selectedEquipmentIds = {}; // 'id:status' 형식
bool _isLoading = false;
String? _error;
bool _useApi = true; // Feature flag for API usage
// 페이지네이션
int _currentPage = 1;
final int _perPage = 20;
bool _hasMore = true;
// Getters
bool get isLoading => _isLoading;
String? get error => _error;
bool get hasMore => _hasMore;
int get currentPage => _currentPage;
EquipmentListController({required this.dataService});
// 데이터 로드 및 상태 필터 적용
void loadData() {
equipments = dataService.getAllEquipments();
if (selectedStatusFilter != null) {
equipments =
equipments.where((e) => e.status == selectedStatusFilter).toList();
Future<void> loadData({bool isRefresh = false}) async {
if (isRefresh) {
_currentPage = 1;
_hasMore = true;
equipments.clear();
}
if (_isLoading || (!_hasMore && !isRefresh)) return;
_isLoading = true;
_error = null;
notifyListeners();
try {
if (_useApi) {
// API 호출
final apiEquipments = await _equipmentService.getEquipments(
page: _currentPage,
perPage: _perPage,
status: selectedStatusFilter,
);
// API 모델을 UnifiedEquipment로 변환
final unifiedEquipments = apiEquipments.map((equipment) {
return UnifiedEquipment(
id: equipment.id,
equipment: equipment,
quantity: equipment.quantity,
status: EquipmentStatus.in_, // 기본값, 실제로는 API에서 가져와야 함
locationTrack: LocationTrack.inStock,
);
}).toList();
if (isRefresh) {
equipments = unifiedEquipments;
} else {
equipments.addAll(unifiedEquipments);
}
_hasMore = unifiedEquipments.length == _perPage;
if (_hasMore) _currentPage++;
} else {
// Mock 데이터 사용
equipments = dataService.getAllEquipments();
if (selectedStatusFilter != null) {
equipments =
equipments.where((e) => e.status == selectedStatusFilter).toList();
}
_hasMore = false;
}
selectedEquipmentIds.clear();
} on Failure catch (e) {
_error = e.message;
} catch (e) {
_error = 'An unexpected error occurred: $e';
} finally {
_isLoading = false;
notifyListeners();
}
selectedEquipmentIds.clear();
}
// 상태 필터 변경
void changeStatusFilter(String? status) {
Future<void> changeStatusFilter(String? status) async {
selectedStatusFilter = status;
loadData();
await loadData(isRefresh: true);
}
// 장비 선택/해제 (모든 상태 지원)
@@ -42,6 +117,7 @@ class EquipmentListController {
} else {
selectedEquipmentIds.remove(key);
}
notifyListeners();
}
// 선택된 입고 장비 수 반환
@@ -167,4 +243,21 @@ class EquipmentListController {
}
return '-';
}
// API 사용 여부 토글 (테스트용)
void toggleApiUsage() {
_useApi = !_useApi;
loadData(isRefresh: true);
}
// 에러 처리
void clearError() {
_error = null;
notifyListeners();
}
@override
void dispose() {
super.dispose();
}
}

View File

@@ -1,14 +1,28 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/services/equipment_service.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/core/errors/failures.dart';
// 장비 출고 폼의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class EquipmentOutFormController {
class EquipmentOutFormController extends ChangeNotifier {
final MockDataService dataService;
final EquipmentService _equipmentService = GetIt.instance<EquipmentService>();
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
final TextEditingController remarkController = TextEditingController();
bool _isLoading = false;
String? _error;
bool _isSaving = false;
bool _useApi = true; // Feature flag
// Getters
bool get isLoading => _isLoading;
String? get error => _error;
bool get isSaving => _isSaving;
// 상태 변수
bool isEditMode = false;
@@ -361,12 +375,18 @@ class EquipmentOutFormController {
}
// 출고 정보 저장 (UI에서 호출)
void saveEquipmentOut(Function(String) onSuccess, Function(String) onError) {
Future<void> saveEquipmentOut(Function(String) onSuccess, Function(String) onError) async {
if (formKey.currentState?.validate() != true) {
onError('폼 유효성 검사 실패');
return;
}
formKey.currentState?.save();
_isSaving = true;
_error = null;
notifyListeners();
try {
// 선택된 회사가 없는지 확인
bool hasAnySelectedCompany = selectedCompanies.any(
@@ -400,25 +420,70 @@ class EquipmentOutFormController {
return;
}
if (isEditMode && equipmentOutId != null) {
final equipmentOut = dataService.getEquipmentOutById(equipmentOutId!);
if (equipmentOut != null) {
final updatedEquipmentOut = EquipmentOut(
id: equipmentOut.id,
equipment: equipmentOut.equipment,
outDate: equipmentOut.outDate,
status: returnType == '재입고' ? 'I' : 'R',
company: companyName,
manager: equipmentOut.manager,
license: equipmentOut.license,
returnDate: returnDate,
returnType: returnType,
remark: remarkController.text.trim(),
);
dataService.updateEquipmentOut(updatedEquipmentOut);
onSuccess('장비 출고 상태 변경 완료');
if (_useApi) {
// API 호출 방식
if (isEditMode && equipmentOutId != null) {
// TODO: 출고 정보 업데이트 API 호출
throw UnimplementedError('Equipment out update API not implemented yet');
} else {
onError('출고 정보를 찾을 수 없습니다');
// 장비 출고 처리
if (selectedEquipments != null && selectedEquipments!.isNotEmpty) {
for (var equipmentData in selectedEquipments!) {
final equipment = equipmentData['equipment'] as Equipment;
if (equipment.id != null) {
// 회사 ID 가져오기 - 현재는 목 데이터에서 찾기
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
selectedCompanies[0]!,
);
int? companyId = companyInfo?.companyId;
int? branchId = companyInfo?.branchId;
if (companyId == null) {
// 목 데이터에서 회사 ID 찾기
final company = dataService.getAllCompanies().firstWhere(
(c) => c.name == companyName,
orElse: () => null,
);
companyId = company?.id;
}
if (companyId != null) {
await _equipmentService.equipmentOut(
equipmentId: equipment.id!,
quantity: equipment.quantity,
companyId: companyId,
branchId: branchId,
notes: remarkController.text.trim(),
);
}
}
}
onSuccess('장비 출고 완료');
}
}
} else {
// Mock 데이터 사용
if (isEditMode && equipmentOutId != null) {
final equipmentOut = dataService.getEquipmentOutById(equipmentOutId!);
if (equipmentOut != null) {
final updatedEquipmentOut = EquipmentOut(
id: equipmentOut.id,
equipment: equipmentOut.equipment,
outDate: equipmentOut.outDate,
status: returnType == '재입고' ? 'I' : 'R',
company: companyName,
manager: equipmentOut.manager,
license: equipmentOut.license,
returnDate: returnDate,
returnType: returnType,
remark: remarkController.text.trim(),
);
dataService.updateEquipmentOut(updatedEquipmentOut);
onSuccess('장비 출고 상태 변경 완료');
} else {
onError('출고 정보를 찾을 수 없습니다');
}
}
} else {
if (selectedEquipments != null && selectedEquipments!.isNotEmpty) {
@@ -609,15 +674,39 @@ class EquipmentOutFormController {
}
}
}
} on Failure catch (e) {
_error = e.message;
onError(e.message);
} catch (e) {
_error = 'An unexpected error occurred: $e';
onError(_error!);
} finally {
_isSaving = false;
notifyListeners();
}
}
// 날짜 포맷 유틸리티
String formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
// 에러 처리
void clearError() {
_error = null;
notifyListeners();
}
// API 사용 여부 토글 (테스트용)
void toggleApiUsage() {
_useApi = !_useApi;
notifyListeners();
}
@override
void dispose() {
remarkController.dispose();
super.dispose();
}
}