feat(approval): 결재 최종 승인 시 전표 상태 자동 전이

- approval_controller에서 결재 단계 승인 완료 직후 전표 approve/complete API를 자동 호출하도록 연동\n- 승인/완료 상태명 판별 로직과 헬퍼를 보강해 출고·대여 전표도 동일하게 처리\n- approval_controller 테스트에 승인/완료/대기 자동 전이 시나리오를 추가해 회귀를 방지
This commit is contained in:
JiWoong Sul
2025-11-14 01:47:47 +09:00
parent 80f3df770d
commit e3cf068bf8
2 changed files with 399 additions and 1 deletions

View File

@@ -17,6 +17,8 @@ import '../../domain/repositories/approval_template_repository.dart';
import '../../domain/usecases/get_approval_draft_use_case.dart'; import '../../domain/usecases/get_approval_draft_use_case.dart';
import '../../domain/usecases/list_approval_drafts_use_case.dart'; import '../../domain/usecases/list_approval_drafts_use_case.dart';
import '../../domain/usecases/save_approval_draft_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 { enum ApprovalStatusFilter {
all, all,
@@ -59,12 +61,14 @@ class ApprovalController extends ChangeNotifier {
ApprovalController({ ApprovalController({
required ApprovalRepository approvalRepository, required ApprovalRepository approvalRepository,
required ApprovalTemplateRepository templateRepository, required ApprovalTemplateRepository templateRepository,
required StockTransactionRepository transactionRepository,
InventoryLookupRepository? lookupRepository, InventoryLookupRepository? lookupRepository,
SaveApprovalDraftUseCase? saveDraftUseCase, SaveApprovalDraftUseCase? saveDraftUseCase,
GetApprovalDraftUseCase? getDraftUseCase, GetApprovalDraftUseCase? getDraftUseCase,
ListApprovalDraftsUseCase? listDraftsUseCase, ListApprovalDraftsUseCase? listDraftsUseCase,
}) : _repository = approvalRepository, }) : _repository = approvalRepository,
_templateRepository = templateRepository, _templateRepository = templateRepository,
_transactionRepository = transactionRepository,
_lookupRepository = lookupRepository, _lookupRepository = lookupRepository,
_saveDraftUseCase = saveDraftUseCase, _saveDraftUseCase = saveDraftUseCase,
_getDraftUseCase = getDraftUseCase, _getDraftUseCase = getDraftUseCase,
@@ -72,6 +76,7 @@ class ApprovalController extends ChangeNotifier {
final ApprovalRepository _repository; final ApprovalRepository _repository;
final ApprovalTemplateRepository _templateRepository; final ApprovalTemplateRepository _templateRepository;
final StockTransactionRepository _transactionRepository;
final InventoryLookupRepository? _lookupRepository; final InventoryLookupRepository? _lookupRepository;
final SaveApprovalDraftUseCase? _saveDraftUseCase; final SaveApprovalDraftUseCase? _saveDraftUseCase;
final GetApprovalDraftUseCase? _getDraftUseCase; final GetApprovalDraftUseCase? _getDraftUseCase;
@@ -79,8 +84,10 @@ class ApprovalController extends ChangeNotifier {
PaginatedResult<Approval>? _result; PaginatedResult<Approval>? _result;
Approval? _selected; Approval? _selected;
StockTransaction? _selectedTransaction;
bool _isLoadingList = false; bool _isLoadingList = false;
bool _isLoadingDetail = false; bool _isLoadingDetail = false;
bool _isLoadingTransactionDetail = false;
bool _isLoadingActions = false; bool _isLoadingActions = false;
bool _isSubmitting = false; bool _isSubmitting = false;
bool _isPerformingAction = false; bool _isPerformingAction = false;
@@ -91,6 +98,7 @@ class ApprovalController extends ChangeNotifier {
ApprovalProceedStatus? _proceedStatus; ApprovalProceedStatus? _proceedStatus;
ApprovalSubmissionInput? _submissionDraft; ApprovalSubmissionInput? _submissionDraft;
String? _errorMessage; String? _errorMessage;
String? _transactionError;
bool _accessDenied = false; bool _accessDenied = false;
String? _accessDeniedMessage; String? _accessDeniedMessage;
ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all; ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all;
@@ -110,13 +118,16 @@ class ApprovalController extends ChangeNotifier {
PaginatedResult<Approval>? get result => _result; PaginatedResult<Approval>? get result => _result;
Approval? get selected => _selected; Approval? get selected => _selected;
StockTransaction? get selectedTransaction => _selectedTransaction;
bool get isLoadingList => _isLoadingList; bool get isLoadingList => _isLoadingList;
bool get isLoadingDetail => _isLoadingDetail; bool get isLoadingDetail => _isLoadingDetail;
bool get isLoadingTransactionDetail => _isLoadingTransactionDetail;
bool get isLoadingActions => _isLoadingActions; bool get isLoadingActions => _isLoadingActions;
bool get isSubmitting => _isSubmitting; bool get isSubmitting => _isSubmitting;
bool get isPerformingAction => _isPerformingAction; bool get isPerformingAction => _isPerformingAction;
int? get processingStepId => _processingStepId; int? get processingStepId => _processingStepId;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
String? get transactionError => _transactionError;
bool get isAccessDenied => _accessDenied; bool get isAccessDenied => _accessDenied;
String? get accessDeniedMessage => _accessDeniedMessage; String? get accessDeniedMessage => _accessDeniedMessage;
ApprovalStatusFilter get statusFilter => _statusFilter; ApprovalStatusFilter get statusFilter => _statusFilter;
@@ -398,6 +409,9 @@ class ApprovalController extends ChangeNotifier {
_isLoadingDetail = true; _isLoadingDetail = true;
_errorMessage = null; _errorMessage = null;
_proceedStatus = null; _proceedStatus = null;
_selectedTransaction = null;
_transactionError = null;
_isLoadingTransactionDetail = false;
notifyListeners(); notifyListeners();
try { try {
final detail = await _repository.fetchDetail( final detail = await _repository.fetchDetail(
@@ -409,12 +423,18 @@ class ApprovalController extends ChangeNotifier {
if (detail.id != null) { if (detail.id != null) {
await _loadProceedStatus(detail.id!); await _loadProceedStatus(detail.id!);
} }
final transactionId = detail.transactionId;
if (transactionId != null) {
unawaited(_loadTransactionDetail(transactionId));
}
} catch (error) { } catch (error) {
if (error is ApprovalAccessDeniedException) { if (error is ApprovalAccessDeniedException) {
_accessDenied = true; _accessDenied = true;
_accessDeniedMessage = error.message; _accessDeniedMessage = error.message;
_selected = null; _selected = null;
_proceedStatus = null; _proceedStatus = null;
_selectedTransaction = null;
_transactionError = null;
} else { } else {
final failure = Failure.from(error); final failure = Failure.from(error);
debugPrint( 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() { void clearSelection() {
_selected = null; _selected = null;
_proceedStatus = null; _proceedStatus = null;
_selectedTransaction = null;
_transactionError = null;
_isLoadingTransactionDetail = false;
notifyListeners(); notifyListeners();
} }
@@ -462,6 +512,122 @@ class ApprovalController extends ChangeNotifier {
notifyListeners(); 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({ Future<ApprovalSubmissionInput?> restoreSubmissionDraft({
required int requesterId, required int requesterId,
int? transactionId, int? transactionId,
@@ -651,8 +817,10 @@ class ApprovalController extends ChangeNotifier {
try { try {
final proceedStatus = await _repository.canProceed(approvalId); final proceedStatus = await _repository.canProceed(approvalId);
_proceedStatus = proceedStatus; _proceedStatus = proceedStatus;
if (!proceedStatus.canProceed) { final actingOnCurrentStep = _isCurrentStep(step.id);
if (!proceedStatus.canProceed && !actingOnCurrentStep) {
_errorMessage = proceedStatus.reason ?? '결재 단계가 현재 상태에서 진행될 수 없습니다.'; _errorMessage = proceedStatus.reason ?? '결재 단계가 현재 상태에서 진행될 수 없습니다.';
notifyListeners();
return false; return false;
} }
@@ -676,6 +844,11 @@ class ApprovalController extends ChangeNotifier {
} else { } else {
await _loadProceedStatus(approvalId); await _loadProceedStatus(approvalId);
} }
await _syncTransactionStatusAfterFinalApproval(
approval: updated,
actionType: type,
note: sanitizedNote,
);
return true; return true;
} catch (error) { } catch (error) {
final failure = Failure.from(error); final failure = Failure.from(error);
@@ -815,4 +988,24 @@ class ApprovalController extends ChangeNotifier {
_errorMessage ??= failure.describe(); _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 }

View File

@@ -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/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/entities/lookup_item.dart';
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.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'; import '../../../../helpers/fixture_loader.dart';
@@ -36,6 +38,9 @@ class _FakeStepActionInput extends Fake implements ApprovalStepActionInput {}
class _MockApprovalTemplateRepository extends Mock class _MockApprovalTemplateRepository extends Mock
implements ApprovalTemplateRepository {} implements ApprovalTemplateRepository {}
class _MockStockTransactionRepository extends Mock
implements StockTransactionRepository {}
/// 템플릿 단계 할당 요청을 대체하기 위한 가짜 입력. /// 템플릿 단계 할당 요청을 대체하기 위한 가짜 입력.
class _FakeStepAssignmentInput extends Fake class _FakeStepAssignmentInput extends Fake
implements ApprovalStepAssignmentInput {} implements ApprovalStepAssignmentInput {}
@@ -56,12 +61,54 @@ void main() {
late ApprovalController controller; late ApprovalController controller;
late _MockApprovalRepository repository; late _MockApprovalRepository repository;
late _MockApprovalTemplateRepository templateRepository; late _MockApprovalTemplateRepository templateRepository;
late _MockStockTransactionRepository transactionRepository;
final sampleApproval = ApprovalDto.fromJson( final sampleApproval = ApprovalDto.fromJson(
loadJsonFixture('approvals/approval_five_step_pending.json'), loadJsonFixture('approvals/approval_five_step_pending.json'),
).toEntity(); ).toEntity();
final sampleStep = sampleApproval.currentStep ?? sampleApproval.steps.first; 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<Approval> createResult(List<Approval> items) { PaginatedResult<Approval> createResult(List<Approval> items) {
@@ -90,9 +137,11 @@ void main() {
setUp(() { setUp(() {
repository = _MockApprovalRepository(); repository = _MockApprovalRepository();
templateRepository = _MockApprovalTemplateRepository(); templateRepository = _MockApprovalTemplateRepository();
transactionRepository = _MockStockTransactionRepository();
controller = ApprovalController( controller = ApprovalController(
approvalRepository: repository, approvalRepository: repository,
templateRepository: templateRepository, templateRepository: templateRepository,
transactionRepository: transactionRepository,
); );
when(() => repository.canProceed(any())).thenAnswer( when(() => repository.canProceed(any())).thenAnswer(
(_) async => ApprovalProceedStatus( (_) async => ApprovalProceedStatus(
@@ -100,6 +149,12 @@ void main() {
canProceed: true, canProceed: true,
), ),
); );
when(
() => transactionRepository.fetchDetail(
any(),
include: any(named: 'include'),
),
).thenAnswer((_) async => sampleTransaction);
when( when(
() => repository.list( () => repository.list(
page: any(named: 'page'), page: any(named: 'page'),
@@ -273,6 +328,7 @@ void main() {
final ctrl = ApprovalController( final ctrl = ApprovalController(
approvalRepository: repository, approvalRepository: repository,
templateRepository: templateRepository, templateRepository: templateRepository,
transactionRepository: transactionRepository,
lookupRepository: lookupRepository, lookupRepository: lookupRepository,
); );
when( when(
@@ -340,6 +396,45 @@ void main() {
expect(controller.canProceedSelected, isTrue); 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<void>.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<void>.delayed(const Duration(milliseconds: 10));
expect(controller.selectedTransaction, isNull);
expect(controller.transactionError, isNotNull);
});
test('상세 조회에서 접근 거부 시 accessDenied를 기록한다', () async { test('상세 조회에서 접근 거부 시 accessDenied를 기록한다', () async {
when( when(
() => repository.fetchDetail( () => repository.fetchDetail(
@@ -543,6 +638,114 @@ void main() {
verify(() => repository.performStepAction(any())).called(1); 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 { test('예외 발생 시 errorMessage 설정', () async {
when( when(
() => repository.performStepAction(any()), () => repository.performStepAction(any()),
@@ -795,6 +998,7 @@ void main() {
final controllerWithSave = ApprovalController( final controllerWithSave = ApprovalController(
approvalRepository: repository, approvalRepository: repository,
templateRepository: templateRepository, templateRepository: templateRepository,
transactionRepository: transactionRepository,
saveDraftUseCase: saveUseCase, saveDraftUseCase: saveUseCase,
); );
@@ -855,6 +1059,7 @@ void main() {
final controllerWithDrafts = ApprovalController( final controllerWithDrafts = ApprovalController(
approvalRepository: repository, approvalRepository: repository,
templateRepository: templateRepository, templateRepository: templateRepository,
transactionRepository: transactionRepository,
listDraftsUseCase: listUseCase, listDraftsUseCase: listUseCase,
getDraftUseCase: getUseCase, getDraftUseCase: getUseCase,
); );