feat(approval): 결재 최종 승인 시 전표 상태 자동 전이
- approval_controller에서 결재 단계 승인 완료 직후 전표 approve/complete API를 자동 호출하도록 연동\n- 승인/완료 상태명 판별 로직과 헬퍼를 보강해 출고·대여 전표도 동일하게 처리\n- approval_controller 테스트에 승인/완료/대기 자동 전이 시나리오를 추가해 회귀를 방지
This commit is contained in:
@@ -17,6 +17,8 @@ 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';
|
||||
import '../../../inventory/transactions/domain/entities/stock_transaction.dart';
|
||||
import '../../../inventory/transactions/domain/repositories/stock_transaction_repository.dart';
|
||||
|
||||
enum ApprovalStatusFilter {
|
||||
all,
|
||||
@@ -59,12 +61,14 @@ class ApprovalController extends ChangeNotifier {
|
||||
ApprovalController({
|
||||
required ApprovalRepository approvalRepository,
|
||||
required ApprovalTemplateRepository templateRepository,
|
||||
required StockTransactionRepository transactionRepository,
|
||||
InventoryLookupRepository? lookupRepository,
|
||||
SaveApprovalDraftUseCase? saveDraftUseCase,
|
||||
GetApprovalDraftUseCase? getDraftUseCase,
|
||||
ListApprovalDraftsUseCase? listDraftsUseCase,
|
||||
}) : _repository = approvalRepository,
|
||||
_templateRepository = templateRepository,
|
||||
_transactionRepository = transactionRepository,
|
||||
_lookupRepository = lookupRepository,
|
||||
_saveDraftUseCase = saveDraftUseCase,
|
||||
_getDraftUseCase = getDraftUseCase,
|
||||
@@ -72,6 +76,7 @@ class ApprovalController extends ChangeNotifier {
|
||||
|
||||
final ApprovalRepository _repository;
|
||||
final ApprovalTemplateRepository _templateRepository;
|
||||
final StockTransactionRepository _transactionRepository;
|
||||
final InventoryLookupRepository? _lookupRepository;
|
||||
final SaveApprovalDraftUseCase? _saveDraftUseCase;
|
||||
final GetApprovalDraftUseCase? _getDraftUseCase;
|
||||
@@ -79,8 +84,10 @@ class ApprovalController extends ChangeNotifier {
|
||||
|
||||
PaginatedResult<Approval>? _result;
|
||||
Approval? _selected;
|
||||
StockTransaction? _selectedTransaction;
|
||||
bool _isLoadingList = false;
|
||||
bool _isLoadingDetail = false;
|
||||
bool _isLoadingTransactionDetail = false;
|
||||
bool _isLoadingActions = false;
|
||||
bool _isSubmitting = false;
|
||||
bool _isPerformingAction = false;
|
||||
@@ -91,6 +98,7 @@ class ApprovalController extends ChangeNotifier {
|
||||
ApprovalProceedStatus? _proceedStatus;
|
||||
ApprovalSubmissionInput? _submissionDraft;
|
||||
String? _errorMessage;
|
||||
String? _transactionError;
|
||||
bool _accessDenied = false;
|
||||
String? _accessDeniedMessage;
|
||||
ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all;
|
||||
@@ -110,13 +118,16 @@ class ApprovalController extends ChangeNotifier {
|
||||
|
||||
PaginatedResult<Approval>? get result => _result;
|
||||
Approval? get selected => _selected;
|
||||
StockTransaction? get selectedTransaction => _selectedTransaction;
|
||||
bool get isLoadingList => _isLoadingList;
|
||||
bool get isLoadingDetail => _isLoadingDetail;
|
||||
bool get isLoadingTransactionDetail => _isLoadingTransactionDetail;
|
||||
bool get isLoadingActions => _isLoadingActions;
|
||||
bool get isSubmitting => _isSubmitting;
|
||||
bool get isPerformingAction => _isPerformingAction;
|
||||
int? get processingStepId => _processingStepId;
|
||||
String? get errorMessage => _errorMessage;
|
||||
String? get transactionError => _transactionError;
|
||||
bool get isAccessDenied => _accessDenied;
|
||||
String? get accessDeniedMessage => _accessDeniedMessage;
|
||||
ApprovalStatusFilter get statusFilter => _statusFilter;
|
||||
@@ -398,6 +409,9 @@ class ApprovalController extends ChangeNotifier {
|
||||
_isLoadingDetail = true;
|
||||
_errorMessage = null;
|
||||
_proceedStatus = null;
|
||||
_selectedTransaction = null;
|
||||
_transactionError = null;
|
||||
_isLoadingTransactionDetail = false;
|
||||
notifyListeners();
|
||||
try {
|
||||
final detail = await _repository.fetchDetail(
|
||||
@@ -409,12 +423,18 @@ class ApprovalController extends ChangeNotifier {
|
||||
if (detail.id != null) {
|
||||
await _loadProceedStatus(detail.id!);
|
||||
}
|
||||
final transactionId = detail.transactionId;
|
||||
if (transactionId != null) {
|
||||
unawaited(_loadTransactionDetail(transactionId));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error is ApprovalAccessDeniedException) {
|
||||
_accessDenied = true;
|
||||
_accessDeniedMessage = error.message;
|
||||
_selected = null;
|
||||
_proceedStatus = null;
|
||||
_selectedTransaction = null;
|
||||
_transactionError = null;
|
||||
} else {
|
||||
final failure = Failure.from(error);
|
||||
debugPrint(
|
||||
@@ -428,10 +448,40 @@ class ApprovalController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTransactionDetail(int transactionId) async {
|
||||
_isLoadingTransactionDetail = true;
|
||||
_transactionError = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final transaction = await _transactionRepository.fetchDetail(
|
||||
transactionId,
|
||||
include: const ['lines', 'customers', 'approval'],
|
||||
);
|
||||
if (_selected?.transactionId == transactionId) {
|
||||
_selectedTransaction = transaction;
|
||||
}
|
||||
} catch (error) {
|
||||
if (_selected?.transactionId != transactionId) {
|
||||
return;
|
||||
}
|
||||
final failure = Failure.from(error);
|
||||
_transactionError = failure.describe();
|
||||
_selectedTransaction = null;
|
||||
} finally {
|
||||
if (_selected?.transactionId == transactionId) {
|
||||
_isLoadingTransactionDetail = false;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 선택된 결재 상세를 비우고 화면을 초기화한다.
|
||||
void clearSelection() {
|
||||
_selected = null;
|
||||
_proceedStatus = null;
|
||||
_selectedTransaction = null;
|
||||
_transactionError = null;
|
||||
_isLoadingTransactionDetail = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -462,6 +512,122 @@ class ApprovalController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _syncTransactionStatusAfterFinalApproval({
|
||||
required Approval approval,
|
||||
required ApprovalStepActionType actionType,
|
||||
String? note,
|
||||
}) async {
|
||||
if (actionType != ApprovalStepActionType.approve) {
|
||||
return;
|
||||
}
|
||||
final transactionId = approval.transactionId;
|
||||
if (transactionId == null) {
|
||||
return;
|
||||
}
|
||||
final syncAction = _resolveTransactionSyncAction(approval);
|
||||
if (syncAction == null) {
|
||||
return;
|
||||
}
|
||||
final sanitized = note?.trim();
|
||||
final payloadNote = sanitized != null && sanitized.isNotEmpty
|
||||
? sanitized
|
||||
: '결재 최종 승인 자동 처리';
|
||||
try {
|
||||
switch (syncAction) {
|
||||
case _TransactionSyncAction.approve:
|
||||
await _transactionRepository.approve(
|
||||
transactionId,
|
||||
note: payloadNote,
|
||||
);
|
||||
break;
|
||||
case _TransactionSyncAction.complete:
|
||||
await _transactionRepository.complete(
|
||||
transactionId,
|
||||
note: payloadNote,
|
||||
);
|
||||
break;
|
||||
}
|
||||
unawaited(_loadTransactionDetail(transactionId));
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint(
|
||||
'[ApprovalController] 전표 상태 연동 실패(tx=$transactionId, action=$syncAction): $error',
|
||||
);
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
_TransactionSyncAction? _resolveTransactionSyncAction(Approval approval) {
|
||||
final normalizedName = approval.status.name.toLowerCase();
|
||||
final statusCode = _statusCodeForId(approval.status.id);
|
||||
if (_isApprovedStatus(statusCode, normalizedName)) {
|
||||
return _TransactionSyncAction.approve;
|
||||
}
|
||||
if (_isCompletedStatus(statusCode, normalizedName)) {
|
||||
return _TransactionSyncAction.complete;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _statusCodeForId(int id) {
|
||||
final lookup = _statusLookup[id.toString()];
|
||||
final code = lookup?.code?.trim();
|
||||
if (code != null && code.isNotEmpty) {
|
||||
return code.toLowerCase();
|
||||
}
|
||||
final name = lookup?.name.trim();
|
||||
if (name != null && name.isNotEmpty) {
|
||||
return name.toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isApprovedStatus(String? code, String normalizedName) {
|
||||
if (code != null) {
|
||||
final normalizedCode = code.toLowerCase();
|
||||
if (normalizedCode == 'approved' || normalizedCode == 'approve') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (_containsAny(normalizedName, const ['반려', '취소'])) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedName.contains('승인')) {
|
||||
if (_containsAny(normalizedName, const ['대기', '요청', '진행'])) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (normalizedName.contains('approved')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _isCompletedStatus(String? code, String normalizedName) {
|
||||
if (code != null) {
|
||||
final normalizedCode = code.toLowerCase();
|
||||
if (normalizedCode == 'completed' || normalizedCode == 'complete') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (normalizedName.contains('완료') && !normalizedName.contains('승인')) {
|
||||
return true;
|
||||
}
|
||||
if (normalizedName.contains('completed')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _containsAny(String source, List<String> keywords) {
|
||||
for (final keyword in keywords) {
|
||||
if (source.contains(keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<ApprovalSubmissionInput?> restoreSubmissionDraft({
|
||||
required int requesterId,
|
||||
int? transactionId,
|
||||
@@ -651,8 +817,10 @@ class ApprovalController extends ChangeNotifier {
|
||||
try {
|
||||
final proceedStatus = await _repository.canProceed(approvalId);
|
||||
_proceedStatus = proceedStatus;
|
||||
if (!proceedStatus.canProceed) {
|
||||
final actingOnCurrentStep = _isCurrentStep(step.id);
|
||||
if (!proceedStatus.canProceed && !actingOnCurrentStep) {
|
||||
_errorMessage = proceedStatus.reason ?? '결재 단계가 현재 상태에서 진행될 수 없습니다.';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -676,6 +844,11 @@ class ApprovalController extends ChangeNotifier {
|
||||
} else {
|
||||
await _loadProceedStatus(approvalId);
|
||||
}
|
||||
await _syncTransactionStatusAfterFinalApproval(
|
||||
approval: updated,
|
||||
actionType: type,
|
||||
note: sanitizedNote,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
@@ -815,4 +988,24 @@ class ApprovalController extends ChangeNotifier {
|
||||
_errorMessage ??= failure.describe();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCurrentStep(int? stepId) {
|
||||
final current = _selected?.currentStep;
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
if (stepId != null && current.id != null) {
|
||||
return current.id == stepId;
|
||||
}
|
||||
if (stepId != null) {
|
||||
for (final step in _selected?.steps ?? const <ApprovalStep>[]) {
|
||||
if (step.id == stepId) {
|
||||
return step.stepOrder == current.stepOrder;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
enum _TransactionSyncAction { approve, complete }
|
||||
|
||||
Reference in New Issue
Block a user