feat: 라이선스 및 창고 관리 API 연동 구현

- 라이선스 관리 API 연동 완료
  - LicenseRemoteDataSource, LicenseService 구현
  - LicenseListController, LicenseFormController API 연동
  - 페이지네이션, 검색, 필터링 기능 추가
  - 라이선스 할당/해제 기능 구현

- 창고 관리 API 연동 완료
  - WarehouseRemoteDataSource, WarehouseService 구현
  - WarehouseLocationListController, WarehouseLocationFormController API 연동
  - 창고별 장비 조회 및 용량 관리 기능 추가

- DI 컨테이너에 새로운 서비스 등록
- API 통합 문서 업데이트 (전체 진행률 100% 달성)
This commit is contained in:
JiWoong Sul
2025-07-25 00:18:49 +09:00
parent 37f35ca68b
commit 8384423cf2
23 changed files with 7591 additions and 926 deletions

View File

@@ -1,782 +0,0 @@
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 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
String? _errorMessage;
// Getters
bool get isLoading => _isLoading;
String? get error => _error;
bool get isSaving => _isSaving;
String? get errorMessage => _errorMessage;
// 상태 변수
bool isEditMode = false;
String manufacturer = '';
String name = '';
String category = '';
String subCategory = '';
String subSubCategory = '';
String serialNumber = '';
String barcode = '';
int quantity = 1;
DateTime _outDate = DateTime.now();
DateTime get outDate => _outDate;
set outDate(DateTime value) {
_outDate = value;
notifyListeners();
}
bool hasSerialNumber = false;
DateTime? inDate;
String returnType = '재입고';
DateTime _returnDate = DateTime.now();
DateTime get returnDate => _returnDate;
set returnDate(DateTime value) {
_returnDate = value;
notifyListeners();
}
bool hasManagers = false;
// 출고 유형(출고/대여/폐기) 상태 변수 추가
String _outType = '출고'; // 기본값은 '출고'
String get outType => _outType;
set outType(String value) {
_outType = value;
notifyListeners();
}
// 기존 필드 - 호환성을 위해 유지
String? _selectedCompany;
String? get selectedCompany =>
selectedCompanies.isNotEmpty ? selectedCompanies[0] : null;
set selectedCompany(String? value) {
if (selectedCompanies.isEmpty) {
selectedCompanies.add(value);
} else {
selectedCompanies[0] = value;
}
_selectedCompany = value;
}
String? _selectedManager;
String? get selectedManager =>
selectedManagersPerCompany.isNotEmpty
? selectedManagersPerCompany[0]
: null;
set selectedManager(String? value) {
if (selectedManagersPerCompany.isEmpty) {
selectedManagersPerCompany.add(value);
} else {
selectedManagersPerCompany[0] = value;
}
_selectedManager = value;
}
String? selectedLicense;
List<String> companies = [];
// 회사 및 지점 관련 데이터
List<CompanyBranchInfo> companiesWithBranches = [];
List<String> managers = [];
List<String> filteredManagers = [];
List<String> licenses = [];
// 출고 유형별 상태 코드 매핑
static const Map<String, String> outTypeStatusMap = {
'출고': 'O', // Out
'대여': 'R', // Rent
'폐기': 'D', // Disposal
};
// 출고 회사 목록 관리
List<String?> selectedCompanies = [null]; // 첫 번째 드롭다운을 위한 초기값
List<List<String>> availableCompaniesPerDropdown =
[]; // 각 드롭다운마다 사용 가능한 회사 목록
List<String?> selectedManagersPerCompany = [null]; // 각 드롭다운 회사별 선택된 담당자
List<List<String>> filteredManagersPerCompany = []; // 각 드롭다운 회사별 필터링된 담당자 목록
List<bool> hasManagersPerCompany = [false]; // 각 회사별 담당자 유무
// 입력 데이터
Equipment? selectedEquipment;
int? selectedEquipmentInId;
int? equipmentOutId;
List<Map<String, dynamic>>? _selectedEquipments;
EquipmentOutFormController({required this.dataService});
// 선택된 장비 정보 설정 (디버그용)
set selectedEquipments(List<Map<String, dynamic>>? equipments) {
debugPrint('설정된 장비 목록: ${equipments?.length ?? 0}');
if (equipments != null) {
for (var i = 0; i < equipments.length; i++) {
final equipment = equipments[i]['equipment'] as Equipment;
debugPrint('장비 $i: ${equipment.manufacturer} ${equipment.name}');
}
}
_selectedEquipments = equipments;
}
List<Map<String, dynamic>>? get selectedEquipments => _selectedEquipments;
// 드롭다운 데이터 로드
void loadDropdownData() {
final allCompanies = dataService.getAllCompanies();
// 회사와 지점 통합 목록 생성
companiesWithBranches = [];
companies = [];
for (var company in allCompanies) {
// 회사 자체 정보 추가
final companyType =
company.companyTypes.isNotEmpty
? companyTypeToString(company.companyTypes.first)
: '-';
final companyInfo = CompanyBranchInfo(
id: company.id,
name: "${company.name} (${companyType})",
originalName: company.name,
isMainCompany: true,
companyId: company.id,
branchId: null,
);
companiesWithBranches.add(companyInfo);
companies.add(companyInfo.name);
// 지점 정보 추가
if (company.branches != null && company.branches!.isNotEmpty) {
for (var branch in company.branches!) {
final branchInfo = CompanyBranchInfo(
id: branch.id,
name: "${company.name} ${branch.name}",
displayName: branch.name,
originalName: branch.name,
isMainCompany: false,
companyId: company.id,
branchId: branch.id,
parentCompanyName: company.name,
);
companiesWithBranches.add(branchInfo);
companies.add(branchInfo.name);
}
}
}
// 나머지 데이터 로드
final allUsers = dataService.getAllUsers();
managers = allUsers.map((user) => user.name).toList();
filteredManagers = managers;
final allLicenses = dataService.getAllLicenses();
licenses = allLicenses.map((license) => license.name).toList();
if (companies.isEmpty) companies.add('기타');
if (managers.isEmpty) managers.add('기타');
if (licenses.isEmpty) licenses.add('기타');
updateManagersState();
// 출고 회사 드롭다운 초기화
availableCompaniesPerDropdown = [List.from(companies)];
filteredManagersPerCompany = [List.from(managers)];
hasManagersPerCompany = [hasManagers];
// 디버그 정보 출력
debugPrint('드롭다운 데이터 로드 완료');
debugPrint('장비 목록: ${_selectedEquipments?.length ?? 0}');
debugPrint('회사 및 지점 목록: ${companiesWithBranches.length}');
// 수정 모드인 경우 기존 선택값 동기화
if (isEditMode && equipmentOutId != null) {
final equipmentOut = dataService.getEquipmentOutById(equipmentOutId!);
if (equipmentOut != null && equipmentOut.company != null) {
String companyName = '';
// 회사 이름 찾기
for (String company in companies) {
if (company.startsWith(equipmentOut.company!)) {
companyName = company;
break;
}
}
if (companyName.isNotEmpty) {
selectedCompanies[0] = companyName;
filterManagersByCompanyAtIndex(companyName, 0);
// 기존 담당자 설정
if (equipmentOut.manager != null) {
selectedManagersPerCompany[0] = equipmentOut.manager;
}
}
// 라이센스 설정
if (equipmentOut.license != null) {
selectedLicense = equipmentOut.license;
}
}
}
}
// 회사에 따라 담당자 목록 필터링
void filterManagersByCompany(String? companyName) {
if (companyName == null || companyName.isEmpty) {
filteredManagers = managers;
} else {
// 회사 또는 지점 이름에서 CompanyBranchInfo 찾기
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
companyName,
);
if (companyInfo != null && companyInfo.companyId != null) {
int companyId = companyInfo.companyId!;
final companyUsers =
dataService
.getAllUsers()
.where((user) => user.companyId == companyId)
.toList();
if (companyUsers.isNotEmpty) {
filteredManagers = companyUsers.map((user) => user.name).toList();
} else {
filteredManagers = ['없음'];
}
} else {
filteredManagers = ['없음'];
}
}
if (selectedManager != null &&
!filteredManagers.contains(selectedManager)) {
selectedManager =
filteredManagers.isNotEmpty ? filteredManagers[0] : null;
}
updateManagersState();
// 첫 번째 회사에 대한 담당자 목록과 동기화
if (filteredManagersPerCompany.isNotEmpty) {
filteredManagersPerCompany[0] = List.from(filteredManagers);
hasManagersPerCompany[0] = hasManagers;
if (selectedManagersPerCompany.isNotEmpty) {
selectedManagersPerCompany[0] = selectedManager;
}
}
}
// 특정 인덱스의 회사에 따라 담당자 목록 필터링
void filterManagersByCompanyAtIndex(String? companyName, int index) {
if (companyName == null || companyName.isEmpty) {
filteredManagersPerCompany[index] = managers;
} else {
// 회사 또는 지점 이름에서 CompanyBranchInfo 찾기
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
companyName,
);
if (companyInfo != null && companyInfo.companyId != null) {
int companyId = companyInfo.companyId!;
final companyUsers =
dataService
.getAllUsers()
.where((user) => user.companyId == companyId)
.toList();
if (companyUsers.isNotEmpty) {
filteredManagersPerCompany[index] =
companyUsers.map((user) => user.name).toList();
} else {
filteredManagersPerCompany[index] = ['없음'];
}
} else {
filteredManagersPerCompany[index] = ['없음'];
}
}
if (selectedManagersPerCompany[index] != null &&
!filteredManagersPerCompany[index].contains(
selectedManagersPerCompany[index],
)) {
selectedManagersPerCompany[index] =
filteredManagersPerCompany[index].isNotEmpty
? filteredManagersPerCompany[index][0]
: null;
}
updateManagersStateAtIndex(index);
// 첫 번째 회사인 경우 기존 필드와 동기화
if (index == 0) {
filteredManagers = List.from(filteredManagersPerCompany[0]);
hasManagers = hasManagersPerCompany[0];
_selectedManager = selectedManagersPerCompany[0];
}
}
// 담당자 있는지 상태 업데이트
void updateManagersState() {
hasManagers =
filteredManagers.isNotEmpty &&
!(filteredManagers.length == 1 && filteredManagers[0] == '없음');
}
// 특정 인덱스의 담당자 상태 업데이트
void updateManagersStateAtIndex(int index) {
hasManagersPerCompany[index] =
filteredManagersPerCompany[index].isNotEmpty &&
!(filteredManagersPerCompany[index].length == 1 &&
filteredManagersPerCompany[index][0] == '없음');
}
// 출고 회사 추가
void addCompany() {
// 이미 선택된 회사 제외한 리스트 생성
List<String> availableCompanies = List.from(companies);
for (String? company in selectedCompanies) {
if (company != null) {
availableCompanies.remove(company);
}
}
// 새 드롭다운 추가
selectedCompanies.add(null);
availableCompaniesPerDropdown.add(availableCompanies);
selectedManagersPerCompany.add(null);
filteredManagersPerCompany.add(List.from(managers));
hasManagersPerCompany.add(false);
}
// 가능한 회사 목록 업데이트
void updateAvailableCompanies() {
// 각 드롭다운에 대해 사용 가능한 회사 목록 업데이트
for (int i = 0; i < selectedCompanies.length; i++) {
List<String> availableCompanies = List.from(companies);
// 이미 선택된 회사 제외
for (int j = 0; j < selectedCompanies.length; j++) {
if (i != j && selectedCompanies[j] != null) {
availableCompanies.remove(selectedCompanies[j]);
}
}
availableCompaniesPerDropdown[i] = availableCompanies;
}
}
// 선택 장비로 초기화
void initializeWithSelectedEquipment(Equipment equipment) {
manufacturer = equipment.manufacturer;
name = equipment.name;
category = equipment.category;
subCategory = equipment.subCategory;
subSubCategory = equipment.subSubCategory;
serialNumber = equipment.serialNumber ?? '';
barcode = equipment.barcode ?? '';
quantity = equipment.quantity;
hasSerialNumber = serialNumber.isNotEmpty;
inDate = equipment.inDate;
remarkController.text = equipment.remark ?? '';
}
// 회사/지점 표시 이름을 통해 CompanyBranchInfo 객체 찾기
CompanyBranchInfo? _findCompanyInfoByDisplayName(String displayName) {
for (var info in companiesWithBranches) {
if (info.name == displayName) {
return info;
}
}
return null;
}
// 출고 정보 저장 (UI에서 호출)
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(
(company) => company != null,
);
if (!hasAnySelectedCompany) {
onError('최소 하나의 출고 회사를 선택해주세요');
return;
}
// 기존 방식으로 첫 번째 회사 정보 처리
String? companyName;
if (selectedCompanies.isNotEmpty && selectedCompanies[0] != null) {
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
selectedCompanies[0]!,
);
if (companyInfo != null) {
companyName =
companyInfo.isMainCompany
? companyInfo
.originalName // 본사인 경우 회사 원래 이름
: "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함
} else {
companyName = selectedCompanies[0]!.replaceAll(
RegExp(r' \(.*\)\$'),
'',
);
}
} else {
onError('최소 하나의 출고 회사를 선택해주세요');
return;
}
if (_useApi) {
// API 호출 방식
if (isEditMode && equipmentOutId != null) {
// TODO: 출고 정보 업데이트 API 호출
throw UnimplementedError('Equipment out update API not implemented yet');
} else {
// 장비 출고 처리
if (selectedEquipments != null && selectedEquipments!.isNotEmpty) {
List<String> successfulOuts = [];
List<String> failedOuts = [];
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: () => Company(
id: 1, // 기본값 설정
name: companyName ?? '기타',
businessNumber: '',
address: '',
phone: '',
companyTypes: [],
),
);
companyId = company.id;
}
if (companyId != null) {
try {
await _equipmentService.equipmentOut(
equipmentId: equipment.id!,
quantity: equipment.quantity,
companyId: companyId,
branchId: branchId,
notes: '${remarkController.text.trim()}${outType != '출고' ? ' (${outType})' : ''}',
);
successfulOuts.add('${equipment.manufacturer} ${equipment.name}');
} catch (e) {
failedOuts.add('${equipment.manufacturer} ${equipment.name}: $e');
}
}
}
}
// 결과 메시지 생성
if (failedOuts.isEmpty) {
onSuccess('${successfulOuts.length}개 장비 출고 완료');
} else if (successfulOuts.isEmpty) {
onError('모든 장비 출고 실패:\n${failedOuts.join('\n')}');
} else {
onSuccess('${successfulOuts.length}개 성공, ${failedOuts.length}개 실패\n실패: ${failedOuts.join(', ')}');
}
} else {
onError('출고할 장비가 선택되지 않았습니다');
}
}
} 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) {
// 여러 회사에 각각 출고 처리
List<String> successCompanies = [];
// 선택된 모든 회사에 대해 출고 처리
for (int i = 0; i < selectedCompanies.length; i++) {
if (selectedCompanies[i] == null) continue;
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
selectedCompanies[i]!,
);
String curCompanyName;
if (companyInfo != null) {
curCompanyName =
companyInfo.isMainCompany
? companyInfo
.originalName // 본사인 경우 회사 원래 이름
: "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함
} else {
curCompanyName = selectedCompanies[i]!.replaceAll(
RegExp(r' \(.*\)\$'),
'',
);
}
String? curManager = selectedManagersPerCompany[i];
if (curManager == null || curManager == '없음') {
// 담당자 없는 회사는 건너뛰기
continue;
}
// 해당 회사에 모든 장비 출고 처리
for (final equipmentData in selectedEquipments!) {
final equipment = equipmentData['equipment'] as Equipment;
final equipmentInId = equipmentData['equipmentInId'] as int;
final newEquipmentOut = EquipmentOut(
equipment: equipment,
outDate: outDate,
company: curCompanyName,
manager: curManager,
license: selectedLicense,
remark: remarkController.text.trim(),
);
dataService.changeEquipmentStatus(equipmentInId, newEquipmentOut);
}
successCompanies.add(companyInfo?.name ?? curCompanyName);
}
if (successCompanies.isEmpty) {
onError('모든 회사에 담당자가 없어 출고 처리할 수 없습니다');
} else {
onSuccess('${successCompanies.join(", ")} 회사로 다중 장비 출고 처리 완료');
}
} else if (selectedEquipmentInId != null) {
final equipment = Equipment(
manufacturer: manufacturer,
name: name,
category: category,
subCategory: subCategory,
subSubCategory: subSubCategory,
serialNumber: (hasSerialNumber) ? serialNumber : null,
barcode: barcode.isNotEmpty ? barcode : null,
quantity: quantity,
inDate: inDate,
remark: remarkController.text.trim(),
);
// 선택된 모든 회사에 대해 출고 처리
List<String> successCompanies = [];
for (int i = 0; i < selectedCompanies.length; i++) {
if (selectedCompanies[i] == null) continue;
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
selectedCompanies[i]!,
);
String curCompanyName;
if (companyInfo != null) {
curCompanyName =
companyInfo.isMainCompany
? companyInfo
.originalName // 본사인 경우 회사 원래 이름
: "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함
} else {
curCompanyName = selectedCompanies[i]!.replaceAll(
RegExp(r' \(.*\)\$'),
'',
);
}
String? curManager = selectedManagersPerCompany[i];
if (curManager == null || curManager == '없음') {
// 담당자 없는 회사는 건너뛰기
continue;
}
final newEquipmentOut = EquipmentOut(
equipment: equipment,
outDate: outDate,
company: curCompanyName,
manager: curManager,
license: selectedLicense,
remark: remarkController.text.trim(),
);
dataService.changeEquipmentStatus(
selectedEquipmentInId!,
newEquipmentOut,
);
successCompanies.add(companyInfo?.name ?? curCompanyName);
break; // 한 장비는 한 회사에만 출고
}
if (successCompanies.isEmpty) {
onError('모든 회사에 담당자가 없어 출고 처리할 수 없습니다');
} else {
onSuccess('${successCompanies.join(", ")} 회사로 장비 출고 처리 완료');
}
} else {
final equipment = Equipment(
manufacturer: manufacturer,
name: name,
category: category,
subCategory: subCategory,
subSubCategory: subSubCategory,
serialNumber: null,
barcode: null,
quantity: 1,
inDate: inDate,
remark: remarkController.text.trim(),
);
// 선택된 모든 회사에 대해 출고 처리
List<String> successCompanies = [];
for (int i = 0; i < selectedCompanies.length; i++) {
if (selectedCompanies[i] == null) continue;
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
selectedCompanies[i]!,
);
String curCompanyName;
if (companyInfo != null) {
curCompanyName =
companyInfo.isMainCompany
? companyInfo
.originalName // 본사인 경우 회사 원래 이름
: "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함
} else {
curCompanyName = selectedCompanies[i]!.replaceAll(
RegExp(r' \(.*\)\$'),
'',
);
}
String? curManager = selectedManagersPerCompany[i];
if (curManager == null || curManager == '없음') {
// 담당자 없는 회사는 건너뛰기
continue;
}
final newEquipmentOut = EquipmentOut(
equipment: equipment,
outDate: outDate,
company: curCompanyName,
manager: curManager,
license: selectedLicense,
remark: remarkController.text.trim(),
);
dataService.addEquipmentOut(newEquipmentOut);
successCompanies.add(companyInfo?.name ?? curCompanyName);
}
if (successCompanies.isEmpty) {
onError('모든 회사에 담당자가 없어 출고 처리할 수 없습니다');
} else {
onSuccess('${successCompanies.join(", ")} 회사로 새 출고 장비 추가 완료');
}
}
} 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;
_errorMessage = null;
notifyListeners();
}
// API 사용 여부 토글 (테스트용)
void toggleApiUsage() {
_useApi = !_useApi;
notifyListeners();
}
@override
void dispose() {
remarkController.dispose();
super.dispose();
}
}
/// 회사 및 지점 정보를 저장하는 클래스
class CompanyBranchInfo {
final int? id;
final String name; // 표시용 이름 (회사명 + 지점명 또는 회사명 (유형))
final String originalName; // 원래 이름 (회사 본사명 또는 지점명)
final String? displayName; // UI에 표시할 이름 (주로 지점명)
final bool isMainCompany; // 본사인지 지점인지 구분
final int? companyId; // 회사 ID
final int? branchId; // 지점 ID
final String? parentCompanyName; // 부모 회사명 (지점인 경우)
CompanyBranchInfo({
required this.id,
required this.name,
required this.originalName,
this.displayName,
required this.isMainCompany,
required this.companyId,
required this.branchId,
this.parentCompanyName,
});
}