From e3cf068bf84cb238f4648558a5830c0957b4369d Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Fri, 14 Nov 2025 01:47:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(approval):=20=EA=B2=B0=EC=9E=AC=20?= =?UTF-8?q?=EC=B5=9C=EC=A2=85=20=EC=8A=B9=EC=9D=B8=20=EC=8B=9C=20=EC=A0=84?= =?UTF-8?q?=ED=91=9C=20=EC=83=81=ED=83=9C=20=EC=9E=90=EB=8F=99=20=EC=A0=84?= =?UTF-8?q?=EC=9D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - approval_controller에서 결재 단계 승인 완료 직후 전표 approve/complete API를 자동 호출하도록 연동\n- 승인/완료 상태명 판별 로직과 헬퍼를 보강해 출고·대여 전표도 동일하게 처리\n- approval_controller 테스트에 승인/완료/대기 자동 전이 시나리오를 추가해 회귀를 방지 --- .../controllers/approval_controller.dart | 195 ++++++++++++++++- .../controllers/approval_controller_test.dart | 205 ++++++++++++++++++ 2 files changed, 399 insertions(+), 1 deletion(-) diff --git a/lib/features/approvals/presentation/controllers/approval_controller.dart b/lib/features/approvals/presentation/controllers/approval_controller.dart index a621b57..684fcc8 100644 --- a/lib/features/approvals/presentation/controllers/approval_controller.dart +++ b/lib/features/approvals/presentation/controllers/approval_controller.dart @@ -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? _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? 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 _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 _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 keywords) { + for (final keyword in keywords) { + if (source.contains(keyword)) { + return true; + } + } + return false; + } + Future 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 []) { + if (step.id == stepId) { + return step.stepOrder == current.stepOrder; + } + } + } + return false; + } } + +enum _TransactionSyncAction { approve, complete } diff --git a/test/features/approvals/presentation/controllers/approval_controller_test.dart b/test/features/approvals/presentation/controllers/approval_controller_test.dart index 003308f..c2d227e 100644 --- a/test/features/approvals/presentation/controllers/approval_controller_test.dart +++ b/test/features/approvals/presentation/controllers/approval_controller_test.dart @@ -17,6 +17,8 @@ import 'package:superport_v2/features/approvals/domain/usecases/save_approval_dr import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart'; import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart'; import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; +import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart'; +import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart'; import '../../../../helpers/fixture_loader.dart'; @@ -36,6 +38,9 @@ class _FakeStepActionInput extends Fake implements ApprovalStepActionInput {} class _MockApprovalTemplateRepository extends Mock implements ApprovalTemplateRepository {} +class _MockStockTransactionRepository extends Mock + implements StockTransactionRepository {} + /// 템플릿 단계 할당 요청을 대체하기 위한 가짜 입력. class _FakeStepAssignmentInput extends Fake implements ApprovalStepAssignmentInput {} @@ -56,12 +61,54 @@ void main() { late ApprovalController controller; late _MockApprovalRepository repository; late _MockApprovalTemplateRepository templateRepository; + late _MockStockTransactionRepository transactionRepository; final sampleApproval = ApprovalDto.fromJson( loadJsonFixture('approvals/approval_five_step_pending.json'), ).toEntity(); final sampleStep = sampleApproval.currentStep ?? sampleApproval.steps.first; + final sampleTransaction = StockTransaction( + id: 91001, + transactionNo: 'TRX-202511100001', + transactionDate: DateTime(2025, 9, 18), + type: StockTransactionType(id: 1, name: '입고'), + status: StockTransactionStatus(id: 1, name: '초안'), + warehouse: StockTransactionWarehouse(id: 1, code: 'WH-001', name: '1센터'), + createdBy: StockTransactionEmployee( + id: 7, + employeeNo: 'E2025001', + name: '김승인', + ), + note: '테스트 전표', + lines: [ + StockTransactionLine( + id: 12001, + lineNo: 1, + product: StockTransactionProduct( + id: 101, + code: 'P100', + name: '샘플', + vendor: StockTransactionVendorSummary(id: 10, name: '한빛상사'), + uom: StockTransactionUomSummary(id: 1, name: 'EA'), + ), + quantity: 50, + unitPrice: 1200, + note: '비고', + ), + ], + customers: [ + StockTransactionCustomer( + id: 301, + customer: StockTransactionCustomerSummary( + id: 301, + code: 'C001', + name: 'ABC물류', + ), + ), + ], + expectedReturnDate: DateTime(2025, 9, 30), + ); /// 테스트용 페이징 응답을 생성하는 헬퍼. PaginatedResult createResult(List items) { @@ -90,9 +137,11 @@ void main() { setUp(() { repository = _MockApprovalRepository(); templateRepository = _MockApprovalTemplateRepository(); + transactionRepository = _MockStockTransactionRepository(); controller = ApprovalController( approvalRepository: repository, templateRepository: templateRepository, + transactionRepository: transactionRepository, ); when(() => repository.canProceed(any())).thenAnswer( (_) async => ApprovalProceedStatus( @@ -100,6 +149,12 @@ void main() { canProceed: true, ), ); + when( + () => transactionRepository.fetchDetail( + any(), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => sampleTransaction); when( () => repository.list( page: any(named: 'page'), @@ -273,6 +328,7 @@ void main() { final ctrl = ApprovalController( approvalRepository: repository, templateRepository: templateRepository, + transactionRepository: transactionRepository, lookupRepository: lookupRepository, ); when( @@ -340,6 +396,45 @@ void main() { expect(controller.canProceedSelected, isTrue); }); + test('트랜잭션 상세를 로드해 상태에 반영한다', () async { + when( + () => repository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + includeHistories: any(named: 'includeHistories'), + ), + ).thenAnswer((_) async => sampleApproval); + + await controller.selectApproval(sampleApproval.id!); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.selectedTransaction, isNotNull); + expect(controller.selectedTransaction!.id, sampleTransaction.id); + expect(controller.transactionError, isNull); + }); + + test('트랜잭션 조회 실패 시 오류 상태를 기록한다', () async { + when( + () => repository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + includeHistories: any(named: 'includeHistories'), + ), + ).thenAnswer((_) async => sampleApproval); + when( + () => transactionRepository.fetchDetail( + any(), + include: any(named: 'include'), + ), + ).thenThrow(Exception('tx fail')); + + await controller.selectApproval(sampleApproval.id!); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.selectedTransaction, isNull); + expect(controller.transactionError, isNotNull); + }); + test('상세 조회에서 접근 거부 시 accessDenied를 기록한다', () async { when( () => repository.fetchDetail( @@ -543,6 +638,114 @@ void main() { verify(() => repository.performStepAction(any())).called(1); }); + test('최종 승인 시 연결 전표를 승인 처리한다', () async { + final approvedStatus = ApprovalStatus( + id: 7, + name: '승인', + isTerminal: true, + ); + final approvedStep = updatedStep.copyWith(status: approvedStatus); + final finalApproval = sampleApproval.copyWith( + status: approvedStatus, + currentStep: approvedStep, + steps: [approvedStep], + ); + when( + () => repository.performStepAction(any()), + ).thenAnswer((_) async => finalApproval); + when( + () => transactionRepository.approve(any(), note: any(named: 'note')), + ).thenAnswer((_) async => sampleTransaction); + + await controller.loadActionOptions(force: true); + await controller.fetch(); + await controller.selectApproval(sampleApproval.id!); + + final success = await controller.performStepAction( + step: sampleStep, + type: ApprovalStepActionType.approve, + ); + + expect(success, isTrue); + verify( + () => transactionRepository.approve( + sampleApproval.transactionId!, + note: any(named: 'note'), + ), + ).called(1); + }); + + test('완료 상태면 연결 전표를 완료 처리한다', () async { + final completedStatus = ApprovalStatus( + id: 8, + name: '처리완료', + isTerminal: true, + ); + final completedStep = updatedStep.copyWith(status: completedStatus); + final finalApproval = sampleApproval.copyWith( + status: completedStatus, + currentStep: completedStep, + steps: [completedStep], + ); + when( + () => repository.performStepAction(any()), + ).thenAnswer((_) async => finalApproval); + when( + () => transactionRepository.complete(any(), note: any(named: 'note')), + ).thenAnswer((_) async => sampleTransaction); + + await controller.loadActionOptions(force: true); + await controller.fetch(); + await controller.selectApproval(sampleApproval.id!); + + final success = await controller.performStepAction( + step: sampleStep, + type: ApprovalStepActionType.approve, + ); + + expect(success, isTrue); + verify( + () => transactionRepository.complete( + sampleApproval.transactionId!, + note: any(named: 'note'), + ), + ).called(1); + }); + + test('승인 대기 상태면 전표 전이를 실행하지 않는다', () async { + final pendingStatus = ApprovalStatus( + id: 9, + name: '승인대기', + isTerminal: false, + ); + final pendingStep = updatedStep.copyWith(status: pendingStatus); + final pendingApproval = sampleApproval.copyWith( + status: pendingStatus, + currentStep: pendingStep, + steps: [pendingStep], + ); + when( + () => repository.performStepAction(any()), + ).thenAnswer((_) async => pendingApproval); + + await controller.loadActionOptions(force: true); + await controller.fetch(); + await controller.selectApproval(sampleApproval.id!); + + final success = await controller.performStepAction( + step: sampleStep, + type: ApprovalStepActionType.approve, + ); + + expect(success, isTrue); + verifyNever( + () => transactionRepository.approve(any(), note: any(named: 'note')), + ); + verifyNever( + () => transactionRepository.complete(any(), note: any(named: 'note')), + ); + }); + test('예외 발생 시 errorMessage 설정', () async { when( () => repository.performStepAction(any()), @@ -795,6 +998,7 @@ void main() { final controllerWithSave = ApprovalController( approvalRepository: repository, templateRepository: templateRepository, + transactionRepository: transactionRepository, saveDraftUseCase: saveUseCase, ); @@ -855,6 +1059,7 @@ void main() { final controllerWithDrafts = ApprovalController( approvalRepository: repository, templateRepository: templateRepository, + transactionRepository: transactionRepository, listDraftsUseCase: listUseCase, getDraftUseCase: getUseCase, );