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

@@ -0,0 +1,219 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart';
void main() {
ApprovalRequestParticipant buildParticipant(int id) {
return ApprovalRequestParticipant(
id: id,
name: '사용자$id',
employeeNo: 'EMP$id',
);
}
group('ApprovalRequestController', () {
late ApprovalRequestController controller;
setUp(() {
controller = ApprovalRequestController();
});
test('addStep 성공 시 최종 승인자로 바인딩된다', () {
final approver = buildParticipant(10);
final added = controller.addStep(approver: approver, note: '검토 필요');
expect(added, isTrue);
expect(controller.steps, hasLength(1));
expect(controller.steps.first.stepOrder, 1);
expect(controller.finalApprover?.id, approver.id);
expect(controller.errorMessage, isNull);
});
test('최대 단계 수를 초과하면 추가를 거부한다', () {
for (var i = 0; i < controller.maxSteps; i++) {
final success = controller.addStep(approver: buildParticipant(i + 1));
expect(success, isTrue);
}
final result = controller.addStep(approver: buildParticipant(999));
expect(result, isFalse);
expect(controller.errorMessage, isNotNull);
expect(controller.steps, hasLength(controller.maxSteps));
});
test('동일한 승인자를 중복 추가할 수 없다', () {
final approver = buildParticipant(3);
controller.addStep(approver: approver);
final duplicated = controller.addStep(approver: approver);
expect(duplicated, isFalse);
expect(controller.errorMessage, isNotNull);
expect(controller.steps, hasLength(1));
});
test('상신자는 승인자로 추가할 수 없다', () {
final requester = buildParticipant(7);
controller.setRequester(requester);
final added = controller.addStep(approver: requester);
expect(added, isFalse);
expect(controller.steps, isEmpty);
expect(controller.errorMessage, isNotNull);
});
test('updateStep에서 승인자 중복을 감지한다', () {
controller
..addStep(approver: buildParticipant(1))
..addStep(approver: buildParticipant(2));
final updated = controller.updateStep(1, approver: buildParticipant(1));
expect(updated, isFalse);
expect(controller.steps[1].approver.id, 2);
expect(controller.errorMessage, isNotNull);
});
test('removeStepAt 호출 시 순번을 재정렬한다', () {
controller
..addStep(approver: buildParticipant(1))
..addStep(approver: buildParticipant(2))
..addStep(approver: buildParticipant(3));
controller.removeStepAt(1);
expect(controller.steps, hasLength(2));
expect(controller.steps.first.stepOrder, 1);
expect(controller.steps.last.stepOrder, 2);
});
test('moveStep 호출 시 순서를 변경한다', () {
controller
..addStep(approver: buildParticipant(1))
..addStep(approver: buildParticipant(2))
..addStep(approver: buildParticipant(3));
controller.moveStep(2, 0);
expect(controller.steps.first.approver.id, 3);
expect(controller.steps.first.stepOrder, 1);
expect(controller.steps.last.approver.id, 2);
expect(controller.steps.last.stepOrder, 3);
});
test('setFinalApprover는 단계가 없을 때 새 단계를 추가한다', () {
final approver = buildParticipant(55);
final result = controller.setFinalApprover(approver);
expect(result, isTrue);
expect(controller.steps, hasLength(1));
expect(controller.finalApprover?.id, approver.id);
});
test('buildTransactionApprovalInput은 필수 정보를 누락하면 예외를 던진다', () {
controller.addStep(approver: buildParticipant(1));
expect(
() => controller.buildTransactionApprovalInput(),
throwsStateError,
);
});
test('buildTransactionApprovalInput은 요청 데이터를 변환한다', () {
final requester = buildParticipant(77);
controller.setRequester(requester);
controller
..addStep(approver: buildParticipant(101), note: '검토')
..addStep(approver: buildParticipant(102));
final approvalInput = controller.buildTransactionApprovalInput(
approvalStatusId: 9,
title: '입고 결재',
summary: '재고 입고 승인 요청',
note: '긴급 승인 필요',
metadata: const {'priority': 'high'},
);
final payload = approvalInput.toJson();
expect(payload['requested_by_id'], requester.id);
expect(payload['approval_status_id'], 9);
expect(payload['final_approver_id'], 102);
expect(payload['title'], '입고 결재');
expect(payload['summary'], '재고 입고 승인 요청');
expect(payload['note'], '긴급 승인 필요');
final steps = payload['steps'] as List<dynamic>;
expect(steps, hasLength(2));
expect(steps.first['step_order'], 1);
expect(steps.first['approver_id'], 101);
expect(steps.first['note'], '검토');
expect(steps.last['step_order'], 2);
expect(steps.last['approver_id'], 102);
});
test('buildSubmissionInput 변환 시 finalApproverId를 채운다', () {
controller
..setRequester(buildParticipant(2))
..addStep(approver: buildParticipant(10))
..addStep(approver: buildParticipant(11), note: '최종 검토');
final submission = controller.buildSubmissionInput(statusId: 4);
expect(submission.requesterId, 2);
expect(submission.finalApproverId, 11);
expect(submission.steps, hasLength(2));
});
test('applyTemplateSteps는 전달된 순서를 유지한다', () {
final steps = [
ApprovalRequestStep(stepOrder: 3, approver: buildParticipant(90)),
ApprovalRequestStep(stepOrder: 9, approver: buildParticipant(91)),
];
controller.applyTemplateSteps(steps);
expect(controller.steps.first.stepOrder, 1);
expect(controller.steps.last.stepOrder, 2);
expect(controller.finalApproverId, 91);
});
test('중복 승인자가 있는 상태에서 빌드 시 예외가 발생한다', () {
controller.setRequester(buildParticipant(5));
controller.applyTemplateSteps([
ApprovalRequestStep(stepOrder: 1, approver: buildParticipant(8)),
ApprovalRequestStep(stepOrder: 2, approver: buildParticipant(8)),
]);
expect(
() => controller.buildSubmissionInput(statusId: 1),
throwsStateError,
);
});
test('상신자와 동일한 승인자가 포함된 템플릿은 적용되지 않는다', () {
final requester = buildParticipant(20);
controller.setRequester(requester);
controller.applyTemplateSteps([
ApprovalRequestStep(stepOrder: 1, approver: requester),
ApprovalRequestStep(stepOrder: 2, approver: buildParticipant(21)),
]);
expect(controller.steps, isEmpty);
expect(controller.errorMessage, isNotNull);
});
test('상신자 변경 시 승인자와 겹치면 오류가 발생한다', () {
controller
..addStep(approver: buildParticipant(40))
..addStep(approver: buildParticipant(41));
controller.setRequester(buildParticipant(41));
expect(controller.requester?.id, 41);
expect(controller.errorMessage, isNotNull);
});
});
}