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

@@ -7,13 +7,32 @@ 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.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_flow.dart';
import 'package:superport_v2/features/approvals/history/domain/entities/approval_history_record.dart';
import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart';
import 'package:superport_v2/features/approvals/history/presentation/pages/approval_history_page.dart';
import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart';
import 'package:superport_v2/features/approvals/domain/usecases/recall_approval_use_case.dart';
import 'package:superport_v2/features/approvals/domain/usecases/resubmit_approval_use_case.dart';
import 'package:superport_v2/features/auth/application/auth_service.dart';
import 'package:superport_v2/features/auth/domain/entities/auth_permission.dart';
import 'package:superport_v2/features/auth/domain/entities/auth_session.dart';
import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart';
import 'package:superport_v2/widgets/components/superport_table.dart';
class _MockApprovalHistoryRepository extends Mock
implements ApprovalHistoryRepository {}
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
class _MockRecallApprovalUseCase extends Mock
implements RecallApprovalUseCase {}
class _MockResubmitApprovalUseCase extends Mock
implements ResubmitApprovalUseCase {}
class _MockAuthService extends Mock implements AuthService {}
Widget _buildApp(Widget child) {
return MaterialApp(
home: ShadTheme(
@@ -29,14 +48,33 @@ Widget _buildApp(Widget child) {
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late _MockApprovalHistoryRepository repository;
late _MockApprovalHistoryRepository historyRepository;
late _MockApprovalRepository approvalRepository;
late _MockRecallApprovalUseCase recallUseCase;
late _MockResubmitApprovalUseCase resubmitUseCase;
late _MockAuthService authService;
setUpAll(() {
registerFallbackValue(ApprovalRecallInput(approvalId: 0, actorId: 0));
registerFallbackValue(
ApprovalResubmissionInput(
approvalId: 0,
actorId: 0,
submission: ApprovalSubmissionInput(
statusId: 0,
requesterId: 0,
steps: const [],
),
),
);
});
final record = ApprovalHistoryRecord(
id: 1,
approvalId: 10,
approvalNo: 'APP-2024-0001',
stepOrder: 1,
action: ApprovalAction(id: 11, name: 'approve'),
action: ApprovalAction(id: 11, name: 'approve', code: 'approve'),
fromStatus: ApprovalStatus(id: 1, name: '대기', color: null),
toStatus: ApprovalStatus(id: 2, name: '승인', color: null),
approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'),
@@ -44,6 +82,160 @@ void main() {
note: '승인 완료',
);
final secondRecord = ApprovalHistoryRecord(
id: 2,
approvalId: 11,
approvalNo: 'APP-2024-0002',
stepOrder: 2,
action: ApprovalAction(id: 12, name: 'submit', code: 'submit'),
fromStatus: ApprovalStatus(id: 1, name: '대기', color: null),
toStatus: ApprovalStatus(id: 3, name: '진행중', color: null),
approver: ApprovalApprover(id: 31, employeeNo: 'E031', name: '초기승인'),
actionAt: DateTime(2024, 4, 2, 9, 30),
note: '상신 완료',
);
ApprovalFlow stubFlow() {
final approval = Approval(
transactionId: 1,
approvalNo: 'APP-2024-0001',
transactionNo: 'TRX-001',
status: ApprovalStatus(id: 2, name: '승인'),
requester: ApprovalRequester(id: 99, employeeNo: 'E099', name: '테스터'),
requestedAt: DateTime(2024, 4, 1),
steps: const [],
histories: const [],
transactionUpdatedAt: DateTime(2024, 4, 1, 12),
);
return ApprovalFlow(approval: approval);
}
ApprovalFlow recallableFlow() {
final status = ApprovalStatus(id: 2, name: '진행중', isTerminal: false);
final approver = ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인');
final steps = [
ApprovalStep(
id: 100,
stepOrder: 1,
approver: approver,
status: status,
assignedAt: DateTime(2024, 4, 1, 9),
decidedAt: null,
),
ApprovalStep(
id: 101,
stepOrder: 2,
approver: approver,
status: status,
assignedAt: DateTime(2024, 4, 1, 10),
decidedAt: DateTime(2024, 4, 1, 10, 30),
),
];
final approval = Approval(
id: 10,
approvalNo: 'APP-2024-0001',
transactionId: 10,
transactionNo: 'TRX-001',
status: status,
requester: ApprovalRequester(id: 99, employeeNo: 'E099', name: '테스터'),
requestedAt: DateTime(2024, 4, 1),
steps: steps,
histories: const [],
updatedAt: DateTime(2024, 4, 1, 12),
transactionUpdatedAt: DateTime(2024, 4, 1, 11, 50),
);
return ApprovalFlow(approval: approval);
}
// ignore: unused_element
ApprovalFlow resubmittableFlow() {
final pendingStatus = ApprovalStatus(id: 2, name: '대기', isTerminal: false);
final rejectedStatus = ApprovalStatus(id: 5, name: '반려', isTerminal: true);
final firstApprover = ApprovalApprover(
id: 31,
employeeNo: 'E002',
name: '1차 승인자',
);
final finalApprover = ApprovalApprover(
id: 32,
employeeNo: 'E003',
name: '최종 승인자',
);
final steps = [
ApprovalStep(
id: 120,
stepOrder: 1,
approver: firstApprover,
status: pendingStatus,
assignedAt: DateTime(2024, 3, 30, 9),
decidedAt: DateTime(2024, 3, 30, 10),
),
ApprovalStep(
id: 121,
stepOrder: 2,
approver: finalApprover,
status: rejectedStatus,
assignedAt: DateTime(2024, 3, 30, 11),
decidedAt: DateTime(2024, 3, 30, 12),
note: '보완 필요',
),
];
final approval = Approval(
id: 10,
approvalNo: 'APP-2024-0001',
transactionId: 11,
transactionNo: 'TRX-001',
status: rejectedStatus,
requester: ApprovalRequester(id: 99, employeeNo: 'E099', name: '테스터'),
requestedAt: DateTime(2024, 3, 30),
decidedAt: DateTime(2024, 3, 30, 12, 30),
note: '반려 사유 공유',
steps: steps,
histories: const [],
updatedAt: DateTime(2024, 3, 30, 12, 30),
transactionUpdatedAt: DateTime(2024, 3, 30, 12),
);
return ApprovalFlow(approval: approval);
}
setUp(() {
historyRepository = _MockApprovalHistoryRepository();
approvalRepository = _MockApprovalRepository();
recallUseCase = _MockRecallApprovalUseCase();
resubmitUseCase = _MockResubmitApprovalUseCase();
authService = _MockAuthService();
final sl = GetIt.I;
sl.registerLazySingleton<ApprovalHistoryRepository>(
() => historyRepository,
);
sl.registerLazySingleton<ApprovalRepository>(() => approvalRepository);
sl.registerLazySingleton<RecallApprovalUseCase>(() => recallUseCase);
sl.registerLazySingleton<ResubmitApprovalUseCase>(() => resubmitUseCase);
sl.registerLazySingleton<AuthService>(() => authService);
when(
() => approvalRepository.fetchDetail(
any(),
includeSteps: any(named: 'includeSteps'),
includeHistories: any(named: 'includeHistories'),
),
).thenAnswer((_) async => stubFlow().approval);
when(() => authService.session).thenReturn(
AuthSession(
accessToken: 'token',
refreshToken: 'refresh',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
user: const AuthenticatedUser(id: 99, name: '테스터', employeeNo: 'E099'),
permissions: const <AuthPermission>[],
),
);
when(() => recallUseCase.call(any())).thenAnswer((_) async => stubFlow());
when(() => resubmitUseCase.call(any())).thenAnswer((_) async => stubFlow());
});
tearDown(() async {
await GetIt.I.reset();
dotenv.clean();
@@ -61,11 +253,53 @@ void main() {
testWidgets('이력 목록을 렌더링하고 검색 필터를 적용한다', (tester) async {
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
repository = _MockApprovalHistoryRepository();
GetIt.I.registerLazySingleton<ApprovalHistoryRepository>(() => repository);
when(
() => repository.list(
() => historyRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
action: any(named: 'action'),
from: any(named: 'from'),
to: any(named: 'to'),
),
).thenAnswer(
(_) async => PaginatedResult<ApprovalHistoryRecord>(
items: [record, secondRecord],
page: 1,
pageSize: 20,
total: 2,
),
);
await tester.pumpWidget(_buildApp(const ApprovalHistoryPage()));
await tester.pump();
await tester.pumpAndSettle();
expect(find.textContaining('APP-2024-0001'), findsOneWidget);
expect(find.text('승인 완료'), findsOneWidget);
await tester.enterText(find.byType(ShadInput).first, 'APP-2024');
await tester.tap(find.text('검색 적용'));
await tester.pump();
verify(
() => historyRepository.list(
page: any(named: 'page'),
pageSize: 20,
query: 'APP-2024',
action: null,
from: null,
to: null,
),
).called(greaterThanOrEqualTo(1));
});
testWidgets('회수 시 상세 재조회 실패 안내를 노출한다', (tester) async {
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
when(
() => historyRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
@@ -82,26 +316,45 @@ void main() {
),
);
final recallable = recallableFlow();
var fetchCount = 0;
when(
() => approvalRepository.fetchDetail(
any(),
includeSteps: any(named: 'includeSteps'),
includeHistories: any(named: 'includeHistories'),
),
).thenAnswer((_) async {
if (fetchCount == 0) {
fetchCount++;
return recallable.approval;
}
fetchCount++;
throw Exception('refresh failed');
});
await tester.pumpWidget(_buildApp(const ApprovalHistoryPage()));
await tester.pump();
await tester.pumpAndSettle();
expect(find.textContaining('APP-2024-0001'), findsOneWidget);
expect(find.text('승인 완료'), findsOneWidget);
await tester.enterText(find.byType(ShadInput).first, 'APP-2024');
await tester.tap(find.text('검색 적용'));
final table = tester.widget<SuperportTable>(find.byType(SuperportTable));
table.onRowTap?.call(0);
await tester.pump();
await tester.pumpAndSettle();
verify(
() => repository.list(
page: any(named: 'page'),
pageSize: 20,
query: 'APP-2024',
action: null,
from: null,
to: null,
),
).called(greaterThanOrEqualTo(1));
final recallButton = find.widgetWithText(ShadButton, '회수').first;
await tester.ensureVisible(recallButton);
await tester.tap(recallButton);
await tester.pumpAndSettle();
expect(find.text('결재 회수'), findsOneWidget);
final confirmButton = find.widgetWithText(ShadButton, '회수').last;
await tester.ensureVisible(confirmButton);
await tester.tap(confirmButton);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(fetchCount, equals(2));
expect(find.text('결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.'), findsOneWidget);
});
}