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,7 +2,12 @@ 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/core/network/failure.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/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/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/controllers/approval_history_controller.dart';
@@ -10,16 +15,27 @@ import 'package:superport_v2/features/approvals/history/presentation/controllers
class _MockApprovalHistoryRepository extends Mock
implements ApprovalHistoryRepository {}
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
class _MockRecallApprovalUseCase extends Mock
implements RecallApprovalUseCase {}
class _MockResubmitApprovalUseCase extends Mock
implements ResubmitApprovalUseCase {}
void main() {
late ApprovalHistoryController controller;
late _MockApprovalHistoryRepository repository;
late _MockApprovalRepository approvalRepository;
late _MockRecallApprovalUseCase recallUseCase;
late _MockResubmitApprovalUseCase resubmitUseCase;
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: '최승인'),
@@ -27,6 +43,16 @@ void main() {
note: '승인 완료',
);
final auditEntry = ApprovalHistory(
id: 2,
action: ApprovalAction(id: 12, name: 'recall', code: 'recall'),
fromStatus: ApprovalStatus(id: 2, name: '승인'),
toStatus: ApprovalStatus(id: 3, name: '회수'),
approver: ApprovalApprover(id: 22, employeeNo: 'E002', name: '박회수'),
actionAt: DateTime(2024, 4, 2, 10),
note: '요청자 회수',
);
PaginatedResult<ApprovalHistoryRecord> createResult(
List<ApprovalHistoryRecord> items,
) {
@@ -38,9 +64,49 @@ void main() {
);
}
PaginatedResult<ApprovalHistory> createAuditResult(
List<ApprovalHistory> items,
) {
return PaginatedResult<ApprovalHistory>(
items: items,
page: 1,
pageSize: 10,
total: items.length,
);
}
ApprovalFlow createFlow(int approvalId) {
final status = ApprovalStatus(id: 5, name: '대기');
final requester = ApprovalRequester(
id: 88,
employeeNo: 'EMP088',
name: '상신자',
);
final approval = Approval(
id: approvalId,
approvalNo: 'APP-$approvalId',
transactionId: approvalId * 1000,
transactionNo: 'TRX-$approvalId',
status: status,
requester: requester,
requestedAt: DateTime(2024, 4, 1),
transactionUpdatedAt: DateTime(2024, 4, 1, 12),
steps: const [],
);
return ApprovalFlow(approval: approval);
}
setUp(() {
repository = _MockApprovalHistoryRepository();
controller = ApprovalHistoryController(repository: repository);
approvalRepository = _MockApprovalRepository();
recallUseCase = _MockRecallApprovalUseCase();
resubmitUseCase = _MockResubmitApprovalUseCase();
controller = ApprovalHistoryController(
repository: repository,
approvalRepository: approvalRepository,
recallUseCase: recallUseCase,
resubmitUseCase: resubmitUseCase,
);
});
test('fetch 성공 시 결과를 갱신한다', () async {
@@ -131,4 +197,186 @@ void main() {
expect(controller.from, isNull);
expect(controller.to, isNull);
});
test('fetchAuditLogs는 ApprovalRepository를 사용한다', () async {
when(
() => approvalRepository.listHistory(
approvalId: any(named: 'approvalId'),
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
from: any(named: 'from'),
to: any(named: 'to'),
actorId: any(named: 'actorId'),
approvalActionId: any(named: 'approvalActionId'),
),
).thenAnswer((_) async => createAuditResult([auditEntry]));
await controller.fetchAuditLogs(approvalId: 10);
expect(controller.auditResult?.items, isNotEmpty);
expect(controller.selectedApprovalId, 10);
expect(controller.auditPageSize, 10);
});
test('updateActiveTab이 탭 상태를 변경한다', () {
expect(controller.activeTab, ApprovalHistoryTab.flow);
controller.updateActiveTab(ApprovalHistoryTab.audit);
expect(controller.activeTab, ApprovalHistoryTab.audit);
});
test('clearAuditSelection은 감사 로그 상태를 초기화한다', () async {
when(
() => approvalRepository.listHistory(
approvalId: any(named: 'approvalId'),
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
from: any(named: 'from'),
to: any(named: 'to'),
actorId: any(named: 'actorId'),
approvalActionId: any(named: 'approvalActionId'),
),
).thenAnswer((_) async => createAuditResult([auditEntry]));
await controller.fetchAuditLogs(approvalId: 10);
controller.clearAuditSelection();
expect(controller.selectedApprovalId, isNull);
expect(controller.auditResult, isNull);
});
test('recallApproval은 유즈케이스를 호출하고 목록을 새로고침한다', () async {
final recallInput = ApprovalRecallInput(approvalId: 10, actorId: 77);
when(
() => recallUseCase.call(recallInput),
).thenAnswer((_) async => createFlow(10));
when(
() => repository.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 => createResult([record]));
when(
() => approvalRepository.listHistory(
approvalId: any(named: 'approvalId'),
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
from: any(named: 'from'),
to: any(named: 'to'),
actorId: any(named: 'actorId'),
approvalActionId: any(named: 'approvalActionId'),
),
).thenAnswer((_) async => createAuditResult([auditEntry]));
await controller.fetchAuditLogs(approvalId: 10);
final flow = await controller.recallApproval(recallInput);
expect(flow, isNotNull);
expect(controller.isPerformingAction, isFalse);
verify(() => recallUseCase.call(recallInput)).called(1);
verify(
() => repository.list(
page: 1,
pageSize: 20,
query: null,
action: null,
from: null,
to: null,
),
).called(greaterThanOrEqualTo(1));
verify(
() => approvalRepository.listHistory(
approvalId: 10,
page: 1,
pageSize: any(named: 'pageSize'),
from: any(named: 'from'),
to: any(named: 'to'),
actorId: null,
approvalActionId: null,
),
).called(greaterThanOrEqualTo(1));
});
test('resubmitApproval은 유즈케이스를 호출한다', () async {
final submission = ApprovalSubmissionInput(
statusId: 3,
requesterId: 77,
steps: const [],
);
final resubmitInput = ApprovalResubmissionInput(
approvalId: 11,
actorId: 77,
submission: submission,
);
when(
() => resubmitUseCase.call(resubmitInput),
).thenAnswer((_) async => createFlow(11));
when(
() => repository.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 => createResult([record]));
when(
() => approvalRepository.listHistory(
approvalId: any(named: 'approvalId'),
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
from: any(named: 'from'),
to: any(named: 'to'),
actorId: any(named: 'actorId'),
approvalActionId: any(named: 'approvalActionId'),
),
).thenAnswer((_) async => createAuditResult([auditEntry]));
await controller.fetchAuditLogs(approvalId: 11);
final flow = await controller.resubmitApproval(resubmitInput);
expect(flow, isNotNull);
expect(controller.isPerformingAction, isFalse);
verify(() => resubmitUseCase.call(resubmitInput)).called(1);
});
test('loadApprovalFlow는 403 응답 시 선택을 금지한다', () async {
when(
() => approvalRepository.fetchDetail(
any(),
includeSteps: any(named: 'includeSteps'),
includeHistories: any(named: 'includeHistories'),
),
).thenThrow(const Failure(message: '접근이 거부되었습니다.', statusCode: 403));
await controller.loadApprovalFlow(77);
expect(controller.isSelectionForbidden, isTrue);
expect(controller.selectedFlow, isNull);
expect(controller.errorMessage, contains('접근이 거부'));
});
test('fetchAuditLogs는 403 응답 시 선택을 차단한다', () async {
when(
() => approvalRepository.listHistory(
approvalId: any(named: 'approvalId'),
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
from: any(named: 'from'),
to: any(named: 'to'),
actorId: any(named: 'actorId'),
approvalActionId: any(named: 'approvalActionId'),
),
).thenThrow(const Failure(message: '접근 불가', statusCode: 403));
await controller.fetchAuditLogs(approvalId: 12);
expect(controller.isSelectionForbidden, isTrue);
expect(controller.auditResult, isNull);
expect(controller.errorMessage, contains('접근 불가'));
});
}