feat(approval): 결재 최종 승인 시 전표 상태 자동 전이
- approval_controller에서 결재 단계 승인 완료 직후 전표 approve/complete API를 자동 호출하도록 연동\n- 승인/완료 상태명 판별 로직과 헬퍼를 보강해 출고·대여 전표도 동일하게 처리\n- approval_controller 테스트에 승인/완료/대기 자동 전이 시나리오를 추가해 회귀를 방지
This commit is contained in:
@@ -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<Approval> createResult(List<Approval> 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<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 {
|
||||
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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user