Files
superport_v2/lib/features/approvals/presentation/controllers/approval_controller.dart
JiWoong Sul d76f765814 feat(approvals): Approval Flow v2 프런트엔드 전면 개편
- 환경/라우터 모듈에 approval_flow_v2 토글을 추가하고 FeatureFlags 초기화를 연결 (.env*, lib/core/**)
- ApiClient 빌더·ApiRoutes 확장과 ApprovalRepositoryRemote 리팩터링으로 include·액션 시그니처를 정합화
- ApprovalFlow·ApprovalDraft 엔티티/레포/유즈케이스를 도입해 서버 초안과 단계 액션(승인·회수·재상신)을 지원
- Approval 컨트롤러·히스토리·템플릿 페이지와 공유 위젯을 재작성해 감사 로그·회수 UX·템플릿 CRUD를 반영
- Inbound/Outbound/Rental 컨트롤러·페이지에 결재 섹션을 삽입하고 대시보드 pending 카드 요약을 갱신
- SuperportDialog·FormField 등 공통 위젯을 보강하고 승인 위젯 가이드를 추가해 UI 가이드를 정리
- 결재/재고 테스트 픽스처와 단위·위젯·통합 테스트를 확장하고 flutter_test_config로 스테이징 호스트를 허용
- Approval Flow 레포트/플랜 문서를 업데이트하고 ApprovalFlow_System_Integration_and_ChangePlan.md를 추가
- 실행: flutter analyze, flutter test
2025-10-31 01:05:39 +09:00

793 lines
25 KiB
Dart

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<ApprovalStepActionType, List<String>> _actionAliases = {
ApprovalStepActionType.approve: ['approve', '승인'],
ApprovalStepActionType.reject: ['reject', '반려'],
ApprovalStepActionType.comment: ['comment', '코멘트', '의견'],
};
const Map<ApprovalStatusFilter, String> _defaultStatusCodes = {
ApprovalStatusFilter.draft: 'draft',
ApprovalStatusFilter.pending: 'pending',
ApprovalStatusFilter.inProgress: 'in_progress',
ApprovalStatusFilter.onHold: 'on_hold',
ApprovalStatusFilter.approved: 'approved',
ApprovalStatusFilter.rejected: 'rejected',
};
const List<String> _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<Approval>? _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<ApprovalAction> _actions = const [];
List<ApprovalTemplate> _templates = const [];
final Map<String, LookupItem> _statusLookup = {};
List<LookupItem> _statusOptions = const [];
final Map<String, String> _statusCodeAliases = Map.fromEntries(
_defaultStatusCodes.entries.map(
(entry) => MapEntry(entry.value, entry.value),
),
);
PaginatedResult<Approval>? 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<ApprovalAction> get actionOptions => _actions;
bool get hasActionOptions => _actions.isNotEmpty;
List<ApprovalTemplate> 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<LookupItem> 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<String, LookupItem> get statusLookup => _statusLookup;
/// 필터 조건과 페이지 정보를 기반으로 결재 목록을 조회한다.
///
/// [page]가 1보다 작으면 1페이지로 보정한다. 조회 실패 시 [_errorMessage]에
/// 예외 메시지를 기록하고, 선택된 상세가 목록에서 사라진 경우 자동으로 선택을 해제한다.
Future<void> 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<void> 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<void> 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 = <String>{};
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<String> _statusCodesFor(ApprovalStatusFilter filter) {
if (filter == ApprovalStatusFilter.all) {
return const <String>[];
}
final code = _statusCodeFor(filter);
if (filter == ApprovalStatusFilter.pending) {
if (code == null || code.toLowerCase() == 'pending') {
return List<String>.unmodifiable(_pendingFallbackStatusCodes);
}
}
if (code == null || code.isEmpty) {
return const <String>[];
}
return List<String>.unmodifiable(<String>[code]);
}
/// 활성화된 결재 템플릿 목록을 조회해 캐싱한다.
///
/// 템플릿이 비어 있거나 [force]가 `true`이면 API를 다시 호출한다.
Future<void> loadTemplates({bool force = false}) async {
if (_templates.isNotEmpty && !force) {
return;
}
_isLoadingTemplates = true;
_errorMessage = null;
notifyListeners();
try {
final templates = await fetchAllPaginatedItems<ApprovalTemplate>(
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<void> 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<ApprovalSubmissionInput?> 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<void>(() 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<Approval?> 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<Approval?> 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<bool> 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<bool> 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<void> _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();
}
}
}