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:
JiWoong Sul
2025-10-31 01:05:39 +09:00
parent 259b056072
commit d76f765814
133 changed files with 13878 additions and 947 deletions

View File

@@ -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);