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

@@ -2,13 +2,26 @@ 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_template_dto.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_flow.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart';
import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart';
import 'package:superport_v2/features/approvals/domain/usecases/apply_approval_template_use_case.dart';
import 'package:superport_v2/features/approvals/domain/usecases/save_approval_template_use_case.dart';
import 'package:superport_v2/features/approvals/template/presentation/controllers/approval_template_controller.dart';
import '../../../../../helpers/fixture_loader.dart';
class _MockApprovalTemplateRepository extends Mock
implements ApprovalTemplateRepository {}
class _MockSaveApprovalTemplateUseCase extends Mock
implements SaveApprovalTemplateUseCase {}
class _MockApplyApprovalTemplateUseCase extends Mock
implements ApplyApprovalTemplateUseCase {}
class _FakeTemplateInput extends Fake implements ApprovalTemplateInput {}
class _FakeStepInput extends Fake implements ApprovalTemplateStepInput {}
@@ -16,19 +29,12 @@ class _FakeStepInput extends Fake implements ApprovalTemplateStepInput {}
void main() {
late ApprovalTemplateController controller;
late _MockApprovalTemplateRepository repository;
late _MockSaveApprovalTemplateUseCase saveUseCase;
late _MockApplyApprovalTemplateUseCase applyUseCase;
final sampleTemplate = ApprovalTemplate(
id: 1,
code: 'AP_INBOUND',
name: '입고 결재 기본',
description: '입고 2단계',
note: '기본 템플릿',
isActive: true,
createdBy: null,
createdAt: DateTime(2024, 4, 1, 9),
updatedAt: DateTime(2024, 4, 2, 9),
steps: const [],
);
final sampleTemplate = ApprovalTemplateDto.fromJson(
loadJsonFixture('approvals/approval_template_sample.json'),
).toEntity();
PaginatedResult<ApprovalTemplate> createResult(List<ApprovalTemplate> items) {
return PaginatedResult<ApprovalTemplate>(
@@ -39,6 +45,36 @@ void main() {
);
}
ApprovalFlow createFlow() {
final status = ApprovalStatus(id: 10, name: '대기');
final requester = ApprovalRequester(
id: 99,
employeeNo: 'EMP099',
name: '상신자',
);
final approver = ApprovalApprover(
id: 100,
employeeNo: 'EMP100',
name: '승인자',
);
final step = ApprovalStep(
stepOrder: 1,
approver: approver,
status: status,
assignedAt: DateTime(2024, 4, 1),
);
final approval = Approval(
id: 7,
approvalNo: 'APP-20240401-0001',
transactionNo: 'TRX-001',
status: status,
requester: requester,
requestedAt: DateTime(2024, 4, 1),
steps: [step],
);
return ApprovalFlow(approval: approval);
}
setUpAll(() {
registerFallbackValue(_FakeTemplateInput());
registerFallbackValue(_FakeStepInput());
@@ -47,7 +83,13 @@ void main() {
setUp(() {
repository = _MockApprovalTemplateRepository();
controller = ApprovalTemplateController(repository: repository);
saveUseCase = _MockSaveApprovalTemplateUseCase();
applyUseCase = _MockApplyApprovalTemplateUseCase();
controller = ApprovalTemplateController(
repository: repository,
saveTemplateUseCase: saveUseCase,
applyTemplateUseCase: applyUseCase,
);
});
group('fetch', () {
@@ -62,11 +104,12 @@ void main() {
).thenAnswer((_) async => createResult([sampleTemplate]));
});
test('목록을 조회한다', () async {
test('목록을 조회하고 버전을 캐시한다', () async {
await controller.fetch();
expect(controller.result?.items, isNotEmpty);
expect(controller.errorMessage, isNull);
expect(controller.versionOf(sampleTemplate.id), sampleTemplate.updatedAt);
});
test('필터를 전달한다', () async {
@@ -101,11 +144,14 @@ void main() {
});
});
test('create 성공목록 갱신', () async {
test('create 호출SaveUseCase를 사용하고 버전을 기록한다', () async {
when(
() => repository.create(any(), steps: any(named: 'steps')),
() => saveUseCase.call(
templateId: any(named: 'templateId'),
input: any(named: 'input'),
steps: any(named: 'steps'),
),
).thenAnswer((_) async => sampleTemplate);
when(
() => repository.list(
page: any(named: 'page'),
@@ -121,16 +167,24 @@ void main() {
);
expect(created, isNotNull);
expect(controller.versionOf(sampleTemplate.id), sampleTemplate.updatedAt);
verify(
() => repository.create(any(), steps: any(named: 'steps')),
() => saveUseCase.call(
templateId: any(named: 'templateId'),
input: any(named: 'input'),
steps: any(named: 'steps'),
),
).called(1);
});
test('update 성공 시 현재 페이지 갱신', () async {
test('save는 update 경로에서도 유즈케이스를 사용한다', () async {
when(
() => repository.update(any(), any(), steps: any(named: 'steps')),
() => saveUseCase.call(
templateId: any(named: 'templateId'),
input: any(named: 'input'),
steps: any(named: 'steps'),
),
).thenAnswer((_) async => sampleTemplate);
when(
() => repository.list(
page: any(named: 'page'),
@@ -140,13 +194,44 @@ void main() {
),
).thenAnswer((_) async => createResult([sampleTemplate]));
controller.updateQuery('AP');
await controller.update(1, ApprovalTemplateInput(name: '입고 결재 수정'), [
ApprovalTemplateStepInput(stepOrder: 1, approverId: 33),
]);
await controller.save(
templateId: 1,
input: ApprovalTemplateInput(name: '수정 템플릿'),
steps: const [],
);
verify(
() => repository.update(any(), any(), steps: any(named: 'steps')),
() => saveUseCase.call(
templateId: 1,
input: any(named: 'input'),
steps: any(named: 'steps'),
),
).called(1);
});
test('유즈케이스 미주입 시 저장소를 직접 호출한다', () async {
final fallbackController = ApprovalTemplateController(
repository: repository,
);
when(
() => repository.create(any(), steps: any(named: 'steps')),
).thenAnswer((_) async => sampleTemplate);
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
),
).thenAnswer((_) async => createResult([sampleTemplate]));
await fallbackController.create(
ApprovalTemplateInput(code: 'AP', name: '템플릿'),
const [],
);
verify(
() => repository.create(any(), steps: any(named: 'steps')),
).called(1);
});
@@ -167,7 +252,7 @@ void main() {
verify(() => repository.delete(1)).called(1);
});
test('restore 성공 시 템플릿을 반환한다', () async {
test('restore 성공 시 템플릿을 반환하고 버전을 갱신한다', () async {
when(
() => repository.restore(any()),
).thenAnswer((_) async => sampleTemplate);
@@ -183,6 +268,58 @@ void main() {
final restored = await controller.restore(1);
expect(restored, isNotNull);
verify(() => repository.restore(1)).called(1);
expect(controller.versionOf(sampleTemplate.id), sampleTemplate.updatedAt);
});
test('applyToApproval은 유즈케이스를 호출하고 버전을 갱신한다', () async {
when(
() => applyUseCase.call(
approvalId: any(named: 'approvalId'),
templateId: any(named: 'templateId'),
),
).thenAnswer((_) async => createFlow());
when(
() => repository.fetchDetail(
any(),
includeSteps: any(named: 'includeSteps'),
),
).thenAnswer((_) async => sampleTemplate);
final flow = await controller.applyToApproval(
approvalId: 10,
templateId: sampleTemplate.id,
);
expect(flow, isNotNull);
expect(controller.isApplyingTemplate, isFalse);
expect(controller.versionOf(sampleTemplate.id), sampleTemplate.updatedAt);
verify(
() => applyUseCase.call(approvalId: 10, templateId: sampleTemplate.id),
).called(1);
});
test('isTemplateStale은 최신 버전을 판단한다', () async {
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
),
).thenAnswer((_) async => createResult([sampleTemplate]));
await controller.fetch();
expect(
controller.isTemplateStale(sampleTemplate.id, sampleTemplate.updatedAt),
isFalse,
);
expect(
controller.isTemplateStale(
sampleTemplate.id,
sampleTemplate.updatedAt!.add(const Duration(minutes: 10)),
),
isTrue,
);
});
}

View File

@@ -7,12 +7,17 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_template.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/apply_approval_template_use_case.dart';
import 'package:superport_v2/features/approvals/domain/usecases/save_approval_template_use_case.dart';
import 'package:superport_v2/features/approvals/template/presentation/pages/approval_template_page.dart';
class _MockApprovalTemplateRepository extends Mock
implements ApprovalTemplateRepository {}
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
class _FakeTemplateInput extends Fake implements ApprovalTemplateInput {}
class _FakeTemplateStepInput extends Fake
@@ -56,13 +61,27 @@ void main() {
group('플래그 On', () {
late _MockApprovalTemplateRepository repository;
late _MockApprovalRepository approvalRepository;
setUp(() {
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
repository = _MockApprovalTemplateRepository();
approvalRepository = _MockApprovalRepository();
GetIt.I.registerLazySingleton<ApprovalTemplateRepository>(
() => repository,
);
GetIt.I.registerLazySingleton<ApprovalRepository>(
() => approvalRepository,
);
GetIt.I.registerLazySingleton<SaveApprovalTemplateUseCase>(
() => SaveApprovalTemplateUseCase(repository: repository),
);
GetIt.I.registerLazySingleton<ApplyApprovalTemplateUseCase>(
() => ApplyApprovalTemplateUseCase(
templateRepository: repository,
approvalRepository: approvalRepository,
),
);
});
ApprovalTemplate buildTemplate({bool isActive = true}) {
@@ -114,6 +133,7 @@ void main() {
expect(find.text('AP_INBOUND'), findsOneWidget);
expect(find.text('입고 템플릿'), findsOneWidget);
expect(find.textContaining('1. 최승인'), findsOneWidget);
verify(
() =>
@@ -188,6 +208,54 @@ void main() {
expect(find.text('템플릿 "신규 템플릿"을 생성했습니다.'), findsOneWidget);
});
testWidgets('보기 버튼을 눌러 템플릿 단계를 미리본다', (tester) async {
final template = buildTemplate();
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<ApprovalTemplate>(
items: [template],
page: 1,
pageSize: 20,
total: 1,
),
);
when(
() => repository.fetchDetail(template.id, includeSteps: true),
).thenAnswer((_) async => template);
await tester.pumpWidget(_buildApp(const ApprovalTemplatePage()));
await tester.pump();
await tester.pumpAndSettle();
final previewFinder = find.text('보기', skipOffstage: false);
await tester.dragUntilVisible(
previewFinder,
find.text(template.name),
const Offset(-200, 0),
);
await tester.pumpAndSettle();
await tester.tap(previewFinder);
await tester.pump();
await tester.pumpAndSettle();
expect(find.text(template.name), findsWidgets);
expect(find.textContaining('사번 E001'), findsOneWidget);
verify(
() => repository.fetchDetail(template.id, includeSteps: true),
).called(1);
});
testWidgets('수정 플로우에서 fetchDetail 후 update를 호출한다', (tester) async {
final activeTemplate = buildTemplate();