import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/network/failure.dart'; import '../../../inventory/lookups/domain/entities/lookup_item.dart'; import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import '../../domain/entities/approval.dart'; import '../../domain/entities/approval_proceed_status.dart'; import '../../domain/entities/approval_template.dart'; import '../../domain/repositories/approval_repository.dart'; import '../../domain/repositories/approval_template_repository.dart'; enum ApprovalStatusFilter { all, pending, inProgress, onHold, approved, rejected, } typedef DateRange = ({DateTime from, DateTime to}); const Map> _actionAliases = { ApprovalStepActionType.approve: ['approve', '승인'], ApprovalStepActionType.reject: ['reject', '반려'], ApprovalStepActionType.comment: ['comment', '코멘트', '의견'], }; const Map _defaultStatusCodes = { ApprovalStatusFilter.pending: 'pending', ApprovalStatusFilter.inProgress: 'in_progress', ApprovalStatusFilter.onHold: 'on_hold', ApprovalStatusFilter.approved: 'approved', ApprovalStatusFilter.rejected: 'rejected', }; /// 결재 목록 및 상세 화면 상태 컨트롤러 /// /// - 목록 조회/필터 상태와 선택된 결재 상세 데이터를 관리한다. /// - 승인/반려 등의 후속 액션은 추후 구현 시 추가한다. class ApprovalController extends ChangeNotifier { ApprovalController({ required ApprovalRepository approvalRepository, required ApprovalTemplateRepository templateRepository, InventoryLookupRepository? lookupRepository, }) : _repository = approvalRepository, _templateRepository = templateRepository, _lookupRepository = lookupRepository; final ApprovalRepository _repository; final ApprovalTemplateRepository _templateRepository; final InventoryLookupRepository? _lookupRepository; PaginatedResult? _result; Approval? _selected; bool _isLoadingList = false; bool _isLoadingDetail = false; bool _isLoadingActions = false; bool _isPerformingAction = false; int? _processingStepId; bool _isLoadingTemplates = false; bool _isApplyingTemplate = false; int? _applyingTemplateId; ApprovalProceedStatus? _proceedStatus; String? _errorMessage; ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all; int? _transactionIdFilter; int? _requestedById; String? _requestedByName; String? _requestedByEmployeeNo; List _actions = const []; List _templates = const []; final Map _statusLookup = {}; final Map _statusCodeAliases = Map.fromEntries( _defaultStatusCodes.entries.map( (entry) => MapEntry(entry.value, entry.value), ), ); PaginatedResult? get result => _result; Approval? get selected => _selected; bool get isLoadingList => _isLoadingList; bool get isLoadingDetail => _isLoadingDetail; bool get isLoadingActions => _isLoadingActions; bool get isPerformingAction => _isPerformingAction; int? get processingStepId => _processingStepId; String? get errorMessage => _errorMessage; ApprovalStatusFilter get statusFilter => _statusFilter; int? get transactionIdFilter => _transactionIdFilter; int? get requestedById => _requestedById; String? get requestedByName => _requestedByName; String? get requestedByEmployeeNo => _requestedByEmployeeNo; List get actionOptions => _actions; bool get hasActionOptions => _actions.isNotEmpty; List get templates => _templates; bool get isLoadingTemplates => _isLoadingTemplates; bool get isApplyingTemplate => _isApplyingTemplate; int? get applyingTemplateId => _applyingTemplateId; ApprovalProceedStatus? get proceedStatus => _proceedStatus; bool get canProceedSelected => _proceedStatus?.canProceed ?? true; String? get cannotProceedReason { final reason = _proceedStatus?.reason?.trim(); if (reason == null || reason.isEmpty) { return null; } return reason; } Map get statusLookup => _statusLookup; /// 필터 조건과 페이지 정보를 기반으로 결재 목록을 조회한다. /// /// [page]가 1보다 작으면 1페이지로 보정한다. 조회 실패 시 [_errorMessage]에 /// 예외 메시지를 기록하고, 선택된 상세가 목록에서 사라진 경우 자동으로 선택을 해제한다. Future fetch({int page = 1}) async { _isLoadingList = true; _errorMessage = null; notifyListeners(); try { final statusId = _statusIdFor(_statusFilter); final response = await _repository.list( page: page, pageSize: _result?.pageSize ?? 20, transactionId: _transactionIdFilter, approvalStatusId: statusId, requestedById: _requestedById, includeSteps: false, includeHistories: false, ); _result = response; if (_selected != null) { final exists = response.items.any((item) => item.id == _selected?.id); if (!exists) { _selected = null; _proceedStatus = null; } } } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); } finally { _isLoadingList = false; notifyListeners(); } } /// 결재 단계에서 사용할 수 있는 행위 목록을 로드한다. /// /// 이미 데이터가 존재하면 [force]가 `true`일 때만 재조회한다. Future loadActionOptions({bool force = false}) async { if (_actions.isNotEmpty && !force) { return; } _isLoadingActions = true; _errorMessage = null; notifyListeners(); try { final items = await _repository.listActions(activeOnly: true); _actions = items; } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); } finally { _isLoadingActions = false; notifyListeners(); } } Future loadStatusLookups() async { final repository = _lookupRepository; if (repository == null) { return; } try { final items = await repository.fetchApprovalStatuses(); _statusLookup ..clear() ..addEntries( items.expand((item) { final keys = {}; final code = item.code?.trim(); if (code != null && code.isNotEmpty) { keys.add(code.toLowerCase()); } final name = item.name.trim(); if (name.isNotEmpty) { keys.add(name.toLowerCase()); } keys.add(item.id.toString()); return keys.map((key) => MapEntry(key, item)); }), ); for (final entry in _defaultStatusCodes.entries) { final defaultCode = entry.value; final normalized = defaultCode.toLowerCase(); final lookup = _statusLookup[normalized]; if (lookup != null) { final alias = lookup.code?.toLowerCase() ?? normalized; _statusCodeAliases[defaultCode] = alias; } else { _statusCodeAliases[defaultCode] = defaultCode; } } notifyListeners(); } catch (_) { // 실패 시 기본 라벨 사용 } } String statusLabel(ApprovalStatusFilter filter) { if (filter == ApprovalStatusFilter.all) { return '전체 상태'; } final code = _statusCodeFor(filter); if (code != null) { final normalized = code.toLowerCase(); final lookup = _statusLookup[normalized]; if (lookup != null && lookup.name.isNotEmpty) { return lookup.name; } } return switch (filter) { ApprovalStatusFilter.pending => '승인대기', ApprovalStatusFilter.inProgress => '진행중', ApprovalStatusFilter.onHold => '보류', ApprovalStatusFilter.approved => '승인완료', ApprovalStatusFilter.rejected => '반려', ApprovalStatusFilter.all => '전체 상태', }; } String? _statusCodeFor(ApprovalStatusFilter filter) { if (filter == ApprovalStatusFilter.all) { return null; } final defaultCode = _defaultStatusCodes[filter]; if (defaultCode == null) { return null; } return _statusCodeAliases[defaultCode] ?? defaultCode; } int? _statusIdFor(ApprovalStatusFilter filter) { final code = _statusCodeFor(filter); if (code == null) { return null; } final lookup = _statusLookup[code.toLowerCase()]; return lookup?.id; } /// 활성화된 결재 템플릿 목록을 조회해 캐싱한다. /// /// 템플릿이 비어 있거나 [force]가 `true`이면 API를 다시 호출한다. Future loadTemplates({bool force = false}) async { if (_templates.isNotEmpty && !force) { return; } _isLoadingTemplates = true; _errorMessage = null; notifyListeners(); try { final result = await _templateRepository.list( page: 1, pageSize: 100, isActive: true, ); _templates = result.items; } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); } finally { _isLoadingTemplates = false; notifyListeners(); } } /// 특정 결재를 선택하고 상세 정보를 로드한다. /// /// 상세와 이력, 단계 정보를 모두 포함해 최신 상태를 유지하며, 실패 시 /// [_errorMessage]로 사용자에게 전달할 메시지를 구성한다. Future selectApproval(int id) async { _isLoadingDetail = true; _errorMessage = null; _proceedStatus = null; notifyListeners(); try { final detail = await _repository.fetchDetail( id, includeSteps: true, includeHistories: true, ); _selected = detail; if (detail.id != null) { await _loadProceedStatus(detail.id!); } } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); } finally { _isLoadingDetail = false; notifyListeners(); } } /// 선택된 결재 상세를 비우고 화면을 초기화한다. void clearSelection() { _selected = null; _proceedStatus = null; notifyListeners(); } /// 결재 단계에 대해 승인/반려/코멘트 등 지정된 행위를 수행한다. /// /// - 유효한 단계 ID와 액션이 존재해야 하며, 실행 중에는 중복 호출을 방지한다. /// - API 호출이 성공하면 목록과 상세 상태를 동기화하고, 실패 시 오류 메시지를 기록한다. Future performStepAction({ required ApprovalStep step, required ApprovalStepActionType type, String? note, }) async { final approvalId = _selected?.id; if (approvalId == null) { _errorMessage = '선택한 결재 정보가 없어 단계를 처리할 수 없습니다.'; notifyListeners(); return false; } if (step.id == null) { _errorMessage = '단계 식별자가 없어 행위를 수행할 수 없습니다.'; notifyListeners(); return false; } final action = _findActionByType(type); if (action == null) { _errorMessage = '사용 가능한 결재 행위를 찾을 수 없습니다.'; notifyListeners(); return false; } _isPerformingAction = true; _processingStepId = step.id; _errorMessage = null; notifyListeners(); try { final proceedStatus = await _repository.canProceed(approvalId); _proceedStatus = proceedStatus; if (!proceedStatus.canProceed) { _errorMessage = proceedStatus.reason ?? '결재 단계가 현재 상태에서 진행될 수 없습니다.'; return false; } final sanitizedNote = note?.trim(); final updated = await _repository.performStepAction( ApprovalStepActionInput( stepId: step.id!, actionId: action.id, note: sanitizedNote?.isEmpty ?? true ? null : sanitizedNote, ), ); _selected = updated; if (_result != null && updated.id != null) { final items = _result!.items .map((item) => item.id == updated.id ? updated : item) .toList(); _result = _result!.copyWith(items: items); } if (updated.id != null) { await _loadProceedStatus(updated.id!); } else { await _loadProceedStatus(approvalId); } return true; } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); return false; } finally { _isPerformingAction = false; _processingStepId = null; notifyListeners(); } } /// 템플릿 단계 정보를 현재 결재에 덮어씌운다. /// /// 선택된 결재가 없거나 템플릿에 등록된 단계가 없으면 즉시 실패로 처리한다. Future applyTemplate(int templateId) async { final approvalId = _selected?.id; if (approvalId == null) { _errorMessage = '선택된 결재가 없어 템플릿을 적용할 수 없습니다.'; notifyListeners(); return false; } _isApplyingTemplate = true; _applyingTemplateId = templateId; _errorMessage = null; notifyListeners(); try { final template = await _templateRepository.fetchDetail( templateId, includeSteps: true, ); if (template.steps.isEmpty) { _errorMessage = '선택한 템플릿에 등록된 단계가 없습니다.'; return false; } final sortedSteps = List.of(template.steps) ..sort((a, b) => a.stepOrder.compareTo(b.stepOrder)); final input = ApprovalStepAssignmentInput( approvalId: approvalId, steps: sortedSteps .map( (step) => ApprovalStepAssignmentItem( stepOrder: step.stepOrder, approverId: step.approver.id, note: step.note, ), ) .toList(), ); final updated = await _repository.assignSteps(input); _selected = updated; if (_result != null && updated.id != null) { final items = _result!.items .map((item) => item.id == updated.id ? updated : item) .toList(); _result = _result!.copyWith(items: items); } return true; } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); return false; } finally { _isApplyingTemplate = false; _applyingTemplateId = null; notifyListeners(); } } /// 상태 필터 값을 변경한다. void updateStatusFilter(ApprovalStatusFilter filter) { _statusFilter = filter; notifyListeners(); } /// 트랜잭션 ID 필터를 갱신한다. null이면 조건을 제거한다. void updateTransactionFilter(int? transactionId) { _transactionIdFilter = transactionId; notifyListeners(); } /// 상신자(요청자) 필터를 갱신한다. null 값을 전달하면 조건을 제거한다. void updateRequestedByFilter({int? id, String? name, String? employeeNo}) { _requestedById = id; _requestedByName = name; _requestedByEmployeeNo = employeeNo; notifyListeners(); } /// 상태/트랜잭션/상신자 필터를 초기값으로 되돌린다. void clearFilters() { _statusFilter = ApprovalStatusFilter.all; _transactionIdFilter = null; _requestedById = null; _requestedByName = null; _requestedByEmployeeNo = null; notifyListeners(); } /// 사용자에게 노출 중인 오류 메시지를 초기화한다. void clearError() { _errorMessage = null; notifyListeners(); } /// 액션 타입과 동일한 코드(또는 별칭)를 가진 결재 행위를 찾는다. ApprovalAction? _findActionByType(ApprovalStepActionType type) { final aliases = _actionAliases[type] ?? [type.code]; for (final action in _actions) { final normalized = action.name.toLowerCase(); for (final alias in aliases) { if (normalized == alias.toLowerCase()) { return action; } } } return null; } Future _loadProceedStatus(int approvalId) async { try { final status = await _repository.canProceed(approvalId); _proceedStatus = status; } catch (error) { _proceedStatus = null; final failure = Failure.from(error); _errorMessage ??= failure.describe(); } } }