505 lines
16 KiB
Dart
505 lines
16 KiB
Dart
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<ApprovalStepActionType, List<String>> _actionAliases = {
|
|
ApprovalStepActionType.approve: ['approve', '승인'],
|
|
ApprovalStepActionType.reject: ['reject', '반려'],
|
|
ApprovalStepActionType.comment: ['comment', '코멘트', '의견'],
|
|
};
|
|
|
|
const Map<ApprovalStatusFilter, String> _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<Approval>? _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<ApprovalAction> _actions = const [];
|
|
List<ApprovalTemplate> _templates = const [];
|
|
final Map<String, LookupItem> _statusLookup = {};
|
|
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 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;
|
|
}
|
|
|
|
Map<String, LookupItem> get statusLookup => _statusLookup;
|
|
|
|
/// 필터 조건과 페이지 정보를 기반으로 결재 목록을 조회한다.
|
|
///
|
|
/// [page]가 1보다 작으면 1페이지로 보정한다. 조회 실패 시 [_errorMessage]에
|
|
/// 예외 메시지를 기록하고, 선택된 상세가 목록에서 사라진 경우 자동으로 선택을 해제한다.
|
|
Future<void> 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<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();
|
|
_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.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<void> 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<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);
|
|
_errorMessage = failure.describe();
|
|
} finally {
|
|
_isLoadingDetail = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 선택된 결재 상세를 비우고 화면을 초기화한다.
|
|
void clearSelection() {
|
|
_selected = null;
|
|
_proceedStatus = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 결재 단계에 대해 승인/반려/코멘트 등 지정된 행위를 수행한다.
|
|
///
|
|
/// - 유효한 단계 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();
|
|
}
|
|
|
|
/// 액션 타입과 동일한 코드(또는 별칭)를 가진 결재 행위를 찾는다.
|
|
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();
|
|
}
|
|
}
|
|
}
|