import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/network/failure.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/common/utils/pagination_utils.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_draft.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'; import '../../domain/usecases/get_approval_draft_use_case.dart'; import '../../domain/usecases/list_approval_drafts_use_case.dart'; import '../../domain/usecases/save_approval_draft_use_case.dart'; enum ApprovalStatusFilter { all, draft, 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.draft: 'draft', ApprovalStatusFilter.pending: 'pending', ApprovalStatusFilter.inProgress: 'in_progress', ApprovalStatusFilter.onHold: 'on_hold', ApprovalStatusFilter.approved: 'approved', ApprovalStatusFilter.rejected: 'rejected', }; const List _pendingFallbackStatusCodes = [ 'draft', 'submitted', 'in_progress', ]; /// 결재 목록 및 상세 화면 상태 컨트롤러 /// /// - 목록 조회/필터 상태와 선택된 결재 상세 데이터를 관리한다. /// - 승인/반려 등의 후속 액션은 추후 구현 시 추가한다. class ApprovalController extends ChangeNotifier { ApprovalController({ required ApprovalRepository approvalRepository, required ApprovalTemplateRepository templateRepository, InventoryLookupRepository? lookupRepository, SaveApprovalDraftUseCase? saveDraftUseCase, GetApprovalDraftUseCase? getDraftUseCase, ListApprovalDraftsUseCase? listDraftsUseCase, }) : _repository = approvalRepository, _templateRepository = templateRepository, _lookupRepository = lookupRepository, _saveDraftUseCase = saveDraftUseCase, _getDraftUseCase = getDraftUseCase, _listDraftsUseCase = listDraftsUseCase; final ApprovalRepository _repository; final ApprovalTemplateRepository _templateRepository; final InventoryLookupRepository? _lookupRepository; final SaveApprovalDraftUseCase? _saveDraftUseCase; final GetApprovalDraftUseCase? _getDraftUseCase; final ListApprovalDraftsUseCase? _listDraftsUseCase; PaginatedResult? _result; Approval? _selected; bool _isLoadingList = false; bool _isLoadingDetail = false; bool _isLoadingActions = false; bool _isSubmitting = false; bool _isPerformingAction = false; int? _processingStepId; bool _isLoadingTemplates = false; bool _isApplyingTemplate = false; int? _applyingTemplateId; ApprovalProceedStatus? _proceedStatus; ApprovalSubmissionInput? _submissionDraft; String? _errorMessage; ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all; int? _transactionIdFilter; int? _requestedById; String? _requestedByName; String? _requestedByEmployeeNo; List _actions = const []; List _templates = const []; final Map _statusLookup = {}; List _statusOptions = const []; 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 isSubmitting => _isSubmitting; 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; } ApprovalSubmissionInput? get submissionDraft => _submissionDraft; bool get hasSubmissionDraft => _submissionDraft != null; List get approvalStatusOptions => _statusOptions; int? get defaultApprovalStatusId { if (_statusOptions.isEmpty) { return null; } final defaultItem = _statusOptions.firstWhere( (item) => item.isDefault, orElse: () => _statusOptions.first, ); return defaultItem.id; } LookupItem? approvalStatusById(int? id) { if (id == null) { return null; } final lookup = _statusLookup[id.toString()]; if (lookup != null) { return lookup; } for (final item in _statusOptions) { if (item.id == id) { return item; } } return null; } Map get statusLookup => _statusLookup; /// 필터 조건과 페이지 정보를 기반으로 결재 목록을 조회한다. /// /// [page]가 1보다 작으면 1페이지로 보정한다. 조회 실패 시 [_errorMessage]에 /// 예외 메시지를 기록하고, 선택된 상세가 목록에서 사라진 경우 자동으로 선택을 해제한다. Future fetch({int page = 1}) async { _isLoadingList = true; _errorMessage = null; notifyListeners(); try { final previous = _result; final int resolvedPage; if (page < 1) { resolvedPage = 1; } else if (previous != null && previous.pageSize > 0) { final calculated = (previous.total / previous.pageSize).ceil(); final maxPage = calculated < 1 ? 1 : calculated; resolvedPage = page > maxPage ? maxPage : page; } else { resolvedPage = page; } final statusId = _statusIdFor(_statusFilter); final statusCodes = _statusCodesFor(_statusFilter); final response = await _repository.list( page: resolvedPage, pageSize: _result?.pageSize ?? 20, transactionId: _transactionIdFilter, approvalStatusId: statusId, requestedById: _requestedById, statusCodes: statusCodes.isEmpty ? null : statusCodes, includePending: _statusFilter == ApprovalStatusFilter.all, 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(); _statusOptions = List.unmodifiable(items); _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.draft => '임시저장', 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; } List _statusCodesFor(ApprovalStatusFilter filter) { if (filter == ApprovalStatusFilter.all) { return const []; } final code = _statusCodeFor(filter); if (filter == ApprovalStatusFilter.pending) { if (code == null || code.toLowerCase() == 'pending') { return List.unmodifiable(_pendingFallbackStatusCodes); } } if (code == null || code.isEmpty) { return const []; } return List.unmodifiable([code]); } /// 활성화된 결재 템플릿 목록을 조회해 캐싱한다. /// /// 템플릿이 비어 있거나 [force]가 `true`이면 API를 다시 호출한다. Future loadTemplates({bool force = false}) async { if (_templates.isNotEmpty && !force) { return; } _isLoadingTemplates = true; _errorMessage = null; notifyListeners(); try { final templates = await fetchAllPaginatedItems( pageSize: 200, request: (page, pageSize) => _templateRepository.list( page: page, pageSize: pageSize, isActive: true, ), ); _templates = templates; } 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); debugPrint( '[ApprovalController] 결재 상세 조회 실패: ${failure.describe()}', ); // 에러 발생 시 콘솔에 남겨 즉시 파악할 수 있도록 한다. _errorMessage = failure.describe(); } finally { _isLoadingDetail = false; notifyListeners(); } } /// 선택된 결재 상세를 비우고 화면을 초기화한다. void clearSelection() { _selected = null; _proceedStatus = null; notifyListeners(); } /// 결재 상신 초안을 보관한다. void cacheSubmissionDraft(ApprovalSubmissionInput draft) { _submissionDraft = draft; notifyListeners(); _persistSubmissionDraft(draft); } /// 저장된 결재 상신 초안을 반환하고 초기화한다. ApprovalSubmissionInput? consumeSubmissionDraft() { final draft = _submissionDraft; if (draft == null) { return null; } _submissionDraft = null; notifyListeners(); return draft; } /// 결재 상신 초안을 초기화한다. void clearSubmissionDraft() { if (_submissionDraft == null) { return; } _submissionDraft = null; notifyListeners(); } Future restoreSubmissionDraft({ required int requesterId, int? transactionId, }) async { final listUseCase = _listDraftsUseCase; final getUseCase = _getDraftUseCase; if (listUseCase == null || getUseCase == null) { return null; } try { final filter = ApprovalDraftListFilter( requesterId: requesterId, transactionId: transactionId, pageSize: 10, ); final result = await listUseCase.call(filter); if (result.items.isEmpty) { return null; } final sessionKey = _submissionSessionKey(requesterId); final summary = result.items.firstWhere( (item) => item.sessionKey == sessionKey, orElse: () => result.items.first, ); final detail = await getUseCase.call( id: summary.id, requesterId: requesterId, ); if (detail == null) { return null; } final submission = detail.toSubmissionInput( defaultStatusId: _defaultSubmissionStatusId(), transactionIdOverride: transactionId ?? detail.transactionId, ); _submissionDraft = submission; notifyListeners(); return submission; } catch (error, stackTrace) { debugPrint('[ApprovalController] 초안 복구 실패: $error\n$stackTrace'); return null; } } void _persistSubmissionDraft(ApprovalSubmissionInput draft) { final useCase = _saveDraftUseCase; if (useCase == null) { return; } if (draft.steps.isEmpty) { return; } final input = _buildSubmissionDraftInput(draft); if (!input.hasSteps) { return; } unawaited( Future(() async { try { await useCase.call(input); } catch (error, stackTrace) { debugPrint('[ApprovalController] 초안 저장 실패: $error\n$stackTrace'); } }), ); } ApprovalDraftSaveInput _buildSubmissionDraftInput( ApprovalSubmissionInput draft, ) { final steps = draft.steps .map( (step) => ApprovalDraftStep( stepOrder: step.stepOrder, approverId: step.approverId, note: step.note, ), ) .toList(growable: false); return ApprovalDraftSaveInput( requesterId: draft.requesterId, transactionId: draft.transactionId, templateId: draft.templateId, title: draft.title, summary: draft.summary, note: draft.note, metadata: draft.metadata, sessionKey: _submissionSessionKey(draft.requesterId), statusId: draft.statusId, steps: steps, ); } int? _defaultSubmissionStatusId() { final pendingId = _statusIdFor(ApprovalStatusFilter.pending); if (pendingId != null && pendingId > 0) { return pendingId; } final draftId = _statusIdFor(ApprovalStatusFilter.draft); if (draftId != null && draftId > 0) { return draftId; } return null; } String _submissionSessionKey(int requesterId) => 'approval_submission_$requesterId'; /// 결재를 생성하고 목록/상세 상태를 최신화한다. Future createApproval(ApprovalCreateInput input) async { _setSubmitting(true); _errorMessage = null; try { final created = await _repository.create(input); await fetch(page: 1); _selected = created; if (created.id != null) { await _loadProceedStatus(created.id!); } notifyListeners(); return created; } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); notifyListeners(); return null; } finally { _setSubmitting(false); } } /// 결재 기본 정보를 수정하고 현재 페이지를 유지한다. Future updateApproval(ApprovalUpdateInput input) async { _setSubmitting(true); _errorMessage = null; try { final updated = await _repository.update(input); final currentPage = _result?.page ?? 1; await fetch(page: currentPage); _selected = updated; if (updated.id != null) { await _loadProceedStatus(updated.id!); } notifyListeners(); return updated; } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); notifyListeners(); return null; } finally { _setSubmitting(false); } } /// 결재 단계에 대해 승인/반려/코멘트 등 지정된 행위를 수행한다. /// /// - 유효한 단계 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(); } void _setSubmitting(bool value) { if (_isSubmitting == value) { return; } _isSubmitting = value; 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(); } } }