feat(approvals): Approval Flow v2 프런트엔드 전면 개편
- 환경/라우터 모듈에 approval_flow_v2 토글을 추가하고 FeatureFlags 초기화를 연결 (.env*, lib/core/**) - ApiClient 빌더·ApiRoutes 확장과 ApprovalRepositoryRemote 리팩터링으로 include·액션 시그니처를 정합화 - ApprovalFlow·ApprovalDraft 엔티티/레포/유즈케이스를 도입해 서버 초안과 단계 액션(승인·회수·재상신)을 지원 - Approval 컨트롤러·히스토리·템플릿 페이지와 공유 위젯을 재작성해 감사 로그·회수 UX·템플릿 CRUD를 반영 - Inbound/Outbound/Rental 컨트롤러·페이지에 결재 섹션을 삽입하고 대시보드 pending 카드 요약을 갱신 - SuperportDialog·FormField 등 공통 위젯을 보강하고 승인 위젯 가이드를 추가해 UI 가이드를 정리 - 결재/재고 테스트 픽스처와 단위·위젯·통합 테스트를 확장하고 flutter_test_config로 스테이징 호스트를 허용 - Approval Flow 레포트/플랜 문서를 업데이트하고 ApprovalFlow_System_Integration_and_ChangePlan.md를 추가 - 실행: flutter analyze, flutter test
This commit is contained in:
@@ -1,16 +1,24 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_draft.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/get_approval_draft_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/list_approval_drafts_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/save_approval_draft_use_case.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/repositories/inventory_lookup_repository.dart';
|
||||
|
||||
import '../../../../helpers/fixture_loader.dart';
|
||||
|
||||
/// ApprovalRepository 모킹 클래스.
|
||||
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
|
||||
|
||||
@@ -34,31 +42,25 @@ class _FakeStepAssignmentInput extends Fake
|
||||
class _MockInventoryLookupRepository extends Mock
|
||||
implements InventoryLookupRepository {}
|
||||
|
||||
class _MockSaveApprovalDraftUseCase extends Mock
|
||||
implements SaveApprovalDraftUseCase {}
|
||||
|
||||
class _MockGetApprovalDraftUseCase extends Mock
|
||||
implements GetApprovalDraftUseCase {}
|
||||
|
||||
class _MockListApprovalDraftsUseCase extends Mock
|
||||
implements ListApprovalDraftsUseCase {}
|
||||
|
||||
void main() {
|
||||
late ApprovalController controller;
|
||||
late _MockApprovalRepository repository;
|
||||
late _MockApprovalTemplateRepository templateRepository;
|
||||
|
||||
final sampleStep = ApprovalStep(
|
||||
id: 11,
|
||||
stepOrder: 1,
|
||||
approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'),
|
||||
status: ApprovalStatus(id: 1, name: '대기'),
|
||||
assignedAt: DateTime(2024, 4, 1, 9),
|
||||
);
|
||||
final sampleApproval = ApprovalDto.fromJson(
|
||||
loadJsonFixture('approvals/approval_five_step_pending.json'),
|
||||
).toEntity();
|
||||
|
||||
final sampleApproval = Approval(
|
||||
id: 1,
|
||||
approvalNo: 'AP-24001',
|
||||
transactionNo: 'TRX-001',
|
||||
status: ApprovalStatus(id: 1, name: '대기'),
|
||||
currentStep: sampleStep,
|
||||
requester: ApprovalRequester(id: 31, employeeNo: 'EMP001', name: '김상신'),
|
||||
requestedAt: DateTime(2024, 4, 1, 9),
|
||||
note: '긴급 결재',
|
||||
steps: [sampleStep],
|
||||
histories: const [],
|
||||
);
|
||||
final sampleStep = sampleApproval.currentStep ?? sampleApproval.steps.first;
|
||||
|
||||
/// 테스트용 페이징 응답을 생성하는 헬퍼.
|
||||
PaginatedResult<Approval> createResult(List<Approval> items) {
|
||||
@@ -75,6 +77,13 @@ void main() {
|
||||
registerFallbackValue(_FakeApprovalUpdateInput());
|
||||
registerFallbackValue(_FakeStepActionInput());
|
||||
registerFallbackValue(_FakeStepAssignmentInput());
|
||||
registerFallbackValue(const ApprovalDraftListFilter(requesterId: 0));
|
||||
registerFallbackValue(
|
||||
ApprovalDraftSaveInput(
|
||||
requesterId: 0,
|
||||
steps: [ApprovalDraftStep(stepOrder: 0, approverId: 0)],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
@@ -90,6 +99,19 @@ void main() {
|
||||
canProceed: true,
|
||||
),
|
||||
);
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
transactionId: any(named: 'transactionId'),
|
||||
approvalStatusId: any(named: 'approvalStatusId'),
|
||||
requestedById: any(named: 'requestedById'),
|
||||
statusCodes: any(named: 'statusCodes'),
|
||||
includePending: any(named: 'includePending'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
),
|
||||
).thenAnswer((_) async => createResult([sampleApproval]));
|
||||
});
|
||||
|
||||
// fetch 메서드 관련 시나리오
|
||||
@@ -102,6 +124,8 @@ void main() {
|
||||
transactionId: any(named: 'transactionId'),
|
||||
approvalStatusId: any(named: 'approvalStatusId'),
|
||||
requestedById: any(named: 'requestedById'),
|
||||
statusCodes: any(named: 'statusCodes'),
|
||||
includePending: any(named: 'includePending'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
),
|
||||
@@ -116,6 +140,29 @@ void main() {
|
||||
expect(controller.errorMessage, isNull);
|
||||
});
|
||||
|
||||
test('전체 상태 필터는 include_pending을 true로 전달한다', () async {
|
||||
await controller.fetch();
|
||||
|
||||
final captured = verify(
|
||||
() => repository.list(
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
transactionId: null,
|
||||
approvalStatusId: null,
|
||||
requestedById: null,
|
||||
statusCodes: captureAny(named: 'statusCodes'),
|
||||
includePending: captureAny(named: 'includePending'),
|
||||
includeHistories: false,
|
||||
includeSteps: false,
|
||||
),
|
||||
).captured;
|
||||
|
||||
final includePending = captured[0] as bool;
|
||||
final statusCodes = captured[1] as List<String>?;
|
||||
expect(includePending, isTrue);
|
||||
expect(statusCodes, isNull);
|
||||
});
|
||||
|
||||
// 검색어/상태/기간 필터가 Repository 호출에 반영되는지 확인한다.
|
||||
test('필터 전달을 검증한다', () async {
|
||||
controller.updateTransactionFilter(55);
|
||||
@@ -128,17 +175,52 @@ void main() {
|
||||
|
||||
await controller.fetch(page: 3);
|
||||
|
||||
verify(
|
||||
final captured = verify(
|
||||
() => repository.list(
|
||||
page: 3,
|
||||
pageSize: 20,
|
||||
transactionId: 55,
|
||||
approvalStatusId: null,
|
||||
requestedById: 77,
|
||||
statusCodes: captureAny(named: 'statusCodes'),
|
||||
includePending: captureAny(named: 'includePending'),
|
||||
includeHistories: false,
|
||||
includeSteps: false,
|
||||
),
|
||||
).called(1);
|
||||
).captured;
|
||||
|
||||
final includePending = captured[0] as bool;
|
||||
final statusCodes = captured[1] as List<String>?;
|
||||
expect(includePending, isFalse);
|
||||
expect(listEquals(statusCodes, const ['approved']), isTrue);
|
||||
});
|
||||
|
||||
test('대기 상태 필터는 초안·상신 상태 코드를 전달한다', () async {
|
||||
controller.updateStatusFilter(ApprovalStatusFilter.pending);
|
||||
|
||||
await controller.fetch();
|
||||
|
||||
final captured = verify(
|
||||
() => repository.list(
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
transactionId: null,
|
||||
approvalStatusId: null,
|
||||
requestedById: null,
|
||||
statusCodes: captureAny(named: 'statusCodes'),
|
||||
includePending: captureAny(named: 'includePending'),
|
||||
includeHistories: false,
|
||||
includeSteps: false,
|
||||
),
|
||||
).captured;
|
||||
|
||||
final includePending = captured[0] as bool;
|
||||
final statusCodes = captured[1] as List<String>?;
|
||||
expect(includePending, isFalse);
|
||||
expect(
|
||||
listEquals(statusCodes, const ['draft', 'submitted', 'in_progress']),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
// Repository 오류 발생 시 errorMessage가 설정된다.
|
||||
@@ -150,6 +232,8 @@ void main() {
|
||||
transactionId: any(named: 'transactionId'),
|
||||
approvalStatusId: any(named: 'approvalStatusId'),
|
||||
requestedById: any(named: 'requestedById'),
|
||||
statusCodes: any(named: 'statusCodes'),
|
||||
includePending: any(named: 'includePending'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
),
|
||||
@@ -187,17 +271,24 @@ void main() {
|
||||
ctrl.updateStatusFilter(ApprovalStatusFilter.approved);
|
||||
await ctrl.fetch();
|
||||
|
||||
verify(
|
||||
final captured = verify(
|
||||
() => repository.list(
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
transactionId: null,
|
||||
approvalStatusId: 5,
|
||||
requestedById: null,
|
||||
statusCodes: captureAny(named: 'statusCodes'),
|
||||
includePending: captureAny(named: 'includePending'),
|
||||
includeHistories: false,
|
||||
includeSteps: false,
|
||||
),
|
||||
).called(1);
|
||||
).captured;
|
||||
|
||||
final includePending = captured[0] as bool;
|
||||
final statusCodes = captured[1] as List<String>?;
|
||||
expect(includePending, isFalse);
|
||||
expect(listEquals(statusCodes, const ['approved']), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,17 +302,17 @@ void main() {
|
||||
),
|
||||
).thenAnswer((_) async => sampleApproval);
|
||||
|
||||
await controller.selectApproval(1);
|
||||
await controller.selectApproval(sampleApproval.id!);
|
||||
|
||||
expect(controller.selected, isNotNull);
|
||||
verify(
|
||||
() => repository.fetchDetail(
|
||||
1,
|
||||
sampleApproval.id!,
|
||||
includeSteps: true,
|
||||
includeHistories: true,
|
||||
),
|
||||
).called(1);
|
||||
verify(() => repository.canProceed(1)).called(1);
|
||||
verify(() => repository.canProceed(sampleApproval.id!)).called(1);
|
||||
expect(controller.canProceedSelected, isTrue);
|
||||
});
|
||||
|
||||
@@ -234,7 +325,7 @@ void main() {
|
||||
),
|
||||
).thenThrow(Exception('detail fail'));
|
||||
|
||||
await controller.selectApproval(1);
|
||||
await controller.selectApproval(sampleApproval.id!);
|
||||
|
||||
expect(controller.errorMessage, isNotNull);
|
||||
});
|
||||
@@ -392,6 +483,7 @@ void main() {
|
||||
|
||||
await controller.loadActionOptions(force: true);
|
||||
await controller.fetch();
|
||||
expect(controller.result, isNotNull);
|
||||
await controller.selectApproval(sampleApproval.id!);
|
||||
|
||||
final success = await controller.performStepAction(
|
||||
@@ -400,8 +492,12 @@ void main() {
|
||||
);
|
||||
|
||||
expect(success, isTrue);
|
||||
expect(controller.selected?.status.name, '승인');
|
||||
expect(controller.result?.items.first.status.name, '승인');
|
||||
expect(controller.selected?.status.id, updatedApproval.status.id);
|
||||
expect(controller.result, isNotNull);
|
||||
expect(
|
||||
controller.result!.items.first.status.id,
|
||||
updatedApproval.status.id,
|
||||
);
|
||||
expect(controller.isPerformingAction, isFalse);
|
||||
verify(() => repository.performStepAction(any())).called(1);
|
||||
});
|
||||
@@ -616,6 +712,165 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('cacheSubmissionDraft와 consumeSubmissionDraft가 초안을 관리한다', () {
|
||||
final draft = ApprovalSubmissionInput(
|
||||
statusId: 1,
|
||||
requesterId: 5,
|
||||
steps: const [],
|
||||
);
|
||||
|
||||
controller.cacheSubmissionDraft(draft);
|
||||
|
||||
expect(controller.hasSubmissionDraft, isTrue);
|
||||
expect(controller.submissionDraft, same(draft));
|
||||
|
||||
final restored = controller.consumeSubmissionDraft();
|
||||
|
||||
expect(restored, same(draft));
|
||||
expect(controller.hasSubmissionDraft, isFalse);
|
||||
});
|
||||
|
||||
test('clearSubmissionDraft는 저장된 초안을 제거한다', () {
|
||||
controller.cacheSubmissionDraft(
|
||||
ApprovalSubmissionInput(statusId: 1, requesterId: 2, steps: const []),
|
||||
);
|
||||
|
||||
controller.clearSubmissionDraft();
|
||||
|
||||
expect(controller.submissionDraft, isNull);
|
||||
expect(controller.hasSubmissionDraft, isFalse);
|
||||
});
|
||||
|
||||
test('cacheSubmissionDraft는 서버 초안을 저장한다', () async {
|
||||
final saveUseCase = _MockSaveApprovalDraftUseCase();
|
||||
final detail = ApprovalDraftDetail(
|
||||
id: 99,
|
||||
requesterId: 5,
|
||||
savedAt: DateTime.utc(2025, 1, 1),
|
||||
payload: ApprovalDraftPayload(steps: const []),
|
||||
);
|
||||
when(() => saveUseCase.call(any())).thenAnswer((_) async => detail);
|
||||
|
||||
final controllerWithSave = ApprovalController(
|
||||
approvalRepository: repository,
|
||||
templateRepository: templateRepository,
|
||||
saveDraftUseCase: saveUseCase,
|
||||
);
|
||||
|
||||
final draft = ApprovalSubmissionInput(
|
||||
statusId: 3,
|
||||
requesterId: 5,
|
||||
transactionId: 10,
|
||||
steps: [ApprovalStepAssignmentItem(stepOrder: 1, approverId: 7)],
|
||||
);
|
||||
|
||||
controllerWithSave.cacheSubmissionDraft(draft);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final captured = verify(() => saveUseCase.call(captureAny())).captured;
|
||||
expect(captured, isNotEmpty);
|
||||
final input = captured.first as ApprovalDraftSaveInput;
|
||||
expect(input.requesterId, 5);
|
||||
expect(input.transactionId, 10);
|
||||
expect(input.statusId, 3);
|
||||
expect(input.sessionKey, 'approval_submission_5');
|
||||
});
|
||||
|
||||
test('restoreSubmissionDraft는 서버 초안을 불러온다', () async {
|
||||
final listUseCase = _MockListApprovalDraftsUseCase();
|
||||
final getUseCase = _MockGetApprovalDraftUseCase();
|
||||
final summary = ApprovalDraftSummary(
|
||||
id: 77,
|
||||
requesterId: 5,
|
||||
status: ApprovalDraftStatus.active,
|
||||
savedAt: DateTime.utc(2025, 1, 3),
|
||||
sessionKey: 'approval_submission_5',
|
||||
stepCount: 1,
|
||||
);
|
||||
when(() => listUseCase.call(any())).thenAnswer(
|
||||
(_) async => PaginatedResult<ApprovalDraftSummary>(
|
||||
items: [summary],
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 1,
|
||||
),
|
||||
);
|
||||
final detail = ApprovalDraftDetail(
|
||||
id: summary.id,
|
||||
requesterId: 5,
|
||||
savedAt: DateTime.utc(2025, 1, 3),
|
||||
payload: ApprovalDraftPayload(
|
||||
templateId: 8,
|
||||
metadata: const {
|
||||
'_client_state': {'status_id': 6},
|
||||
},
|
||||
steps: [ApprovalDraftStep(stepOrder: 1, approverId: 9)],
|
||||
),
|
||||
);
|
||||
when(
|
||||
() => getUseCase.call(id: summary.id, requesterId: 5),
|
||||
).thenAnswer((_) async => detail);
|
||||
|
||||
final controllerWithDrafts = ApprovalController(
|
||||
approvalRepository: repository,
|
||||
templateRepository: templateRepository,
|
||||
listDraftsUseCase: listUseCase,
|
||||
getDraftUseCase: getUseCase,
|
||||
);
|
||||
|
||||
final restored = await controllerWithDrafts.restoreSubmissionDraft(
|
||||
requesterId: 5,
|
||||
);
|
||||
|
||||
expect(restored, isNotNull);
|
||||
expect(restored!.statusId, 6);
|
||||
expect(restored.steps, hasLength(1));
|
||||
expect(controllerWithDrafts.submissionDraft, isNotNull);
|
||||
|
||||
verify(() => listUseCase.call(any())).called(1);
|
||||
verify(() => getUseCase.call(id: summary.id, requesterId: 5)).called(1);
|
||||
});
|
||||
|
||||
test('statusLabel은 draft 라벨을 반환한다', () {
|
||||
final label = controller.statusLabel(ApprovalStatusFilter.draft);
|
||||
|
||||
expect(label, '임시저장');
|
||||
});
|
||||
|
||||
test('목록에서 사라진 결재는 선택 상태를 해제한다', () async {
|
||||
when(
|
||||
() => repository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenAnswer((_) async => sampleApproval);
|
||||
|
||||
await controller.fetch();
|
||||
await controller.selectApproval(sampleApproval.id!);
|
||||
expect(controller.selected, isNotNull);
|
||||
expect(controller.proceedStatus, isNotNull);
|
||||
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
transactionId: any(named: 'transactionId'),
|
||||
approvalStatusId: any(named: 'approvalStatusId'),
|
||||
requestedById: any(named: 'requestedById'),
|
||||
statusCodes: any(named: 'statusCodes'),
|
||||
includePending: any(named: 'includePending'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
),
|
||||
).thenAnswer((_) async => createResult(const []));
|
||||
|
||||
await controller.fetch();
|
||||
|
||||
expect(controller.selected, isNull);
|
||||
expect(controller.proceedStatus, isNull);
|
||||
});
|
||||
|
||||
test('필터 초기화', () {
|
||||
controller.updateTransactionFilter(42);
|
||||
controller.updateStatusFilter(ApprovalStatusFilter.rejected);
|
||||
|
||||
Reference in New Issue
Block a user