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,371 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shadcn_ui/shadcn_ui.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_proceed_status.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/repositories/approval_history_repository.dart';
import 'package:superport_v2/features/approvals/history/presentation/controllers/approval_history_controller.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 {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late ApprovalHistoryRepository historyRepository;
late ApprovalRepository approvalRepository;
late RecallApprovalUseCase recallUseCase;
late ResubmitApprovalUseCase resubmitUseCase;
setUpAll(() {
registerFallbackValue(ApprovalRecallInput(approvalId: 0, actorId: 0));
registerFallbackValue(
ApprovalResubmissionInput(
approvalId: 0,
actorId: 0,
submission: ApprovalSubmissionInput(statusId: 0, requesterId: 0),
),
);
});
setUp(() {
historyRepository = _MockApprovalHistoryRepository();
approvalRepository = _MockApprovalRepository();
recallUseCase = _MockRecallApprovalUseCase();
resubmitUseCase = _MockResubmitApprovalUseCase();
});
ApprovalFlow buildRecallableFlow() {
final status = ApprovalStatus(id: 1, name: '진행중');
final steps = [
ApprovalStep(
id: 1,
stepOrder: 1,
approver: ApprovalApprover(id: 101, employeeNo: 'E101', name: '1차'),
status: status,
assignedAt: DateTime(2024, 4, 1, 9),
),
ApprovalStep(
id: 2,
stepOrder: 2,
approver: ApprovalApprover(id: 102, employeeNo: 'E102', name: '2차'),
status: status,
assignedAt: DateTime(2024, 4, 1, 10),
decidedAt: DateTime(2024, 4, 1, 10, 30),
),
];
final approval = Approval(
id: 500,
approvalNo: 'APP-500',
transactionId: 5000,
transactionNo: 'TRX-500',
status: status,
requester: ApprovalRequester(id: 90, employeeNo: 'E090', name: '상신자'),
requestedAt: DateTime(2024, 4, 1),
steps: steps,
histories: const [],
updatedAt: DateTime(2024, 4, 1, 12),
currentStep: steps.first,
transactionUpdatedAt: DateTime(2024, 4, 1, 11, 30),
);
return ApprovalFlow(approval: approval);
}
ApprovalFlow buildResubmittableFlow() {
final rejectedStatus = ApprovalStatus(id: 9, name: '반려', isTerminal: true);
final steps = [
ApprovalStep(
id: 1,
stepOrder: 1,
approver: ApprovalApprover(id: 101, employeeNo: 'E101', name: '1차'),
status: rejectedStatus,
assignedAt: DateTime(2024, 3, 30, 9),
decidedAt: DateTime(2024, 3, 30, 9, 30),
),
ApprovalStep(
id: 2,
stepOrder: 2,
approver: ApprovalApprover(id: 102, employeeNo: 'E102', name: '최종'),
status: rejectedStatus,
assignedAt: DateTime(2024, 3, 30, 10),
decidedAt: DateTime(2024, 3, 30, 10, 30),
note: '보완 필요',
),
];
final approval = Approval(
id: 600,
approvalNo: 'APP-600',
transactionId: 6000,
transactionNo: 'TRX-600',
status: rejectedStatus,
requester: ApprovalRequester(id: 90, employeeNo: 'E090', name: '상신자'),
requestedAt: DateTime(2024, 3, 30),
decidedAt: DateTime(2024, 3, 30, 12),
note: '반려 메모',
steps: steps,
histories: const [],
updatedAt: DateTime(2024, 3, 30, 12),
currentStep: null,
transactionUpdatedAt: DateTime(2024, 3, 30, 11, 45),
);
return ApprovalFlow(approval: approval);
}
Future<ApprovalHistoryController> buildController(ApprovalFlow flow) async {
final controller = ApprovalHistoryController(
repository: historyRepository,
approvalRepository: approvalRepository,
recallUseCase: recallUseCase,
resubmitUseCase: resubmitUseCase,
);
when(
() => approvalRepository.fetchDetail(
flow.id!,
includeSteps: any(named: 'includeSteps'),
includeHistories: any(named: 'includeHistories'),
),
).thenAnswer((_) async => flow.approval);
when(() => approvalRepository.canProceed(flow.id!)).thenAnswer(
(_) async => ApprovalProceedStatus(
approvalId: flow.id!,
canProceed: flow.currentStep != null,
),
);
await controller.loadApprovalFlow(flow.id!);
return controller;
}
testWidgets('회수 버튼이 노트를 전달해 유즈케이스를 호출한다', (tester) async {
final flow = buildRecallableFlow();
final controller = await buildController(flow);
ApprovalRecallInput? captured;
when(() => recallUseCase.call(any())).thenAnswer((invocation) async {
captured = invocation.positionalArguments.first as ApprovalRecallInput;
return flow;
});
await tester.pumpWidget(
_ApprovalActionHarness(controller: controller, actorId: 99),
);
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('recall-note-field')),
' 긴급 회수 ',
);
await tester.tap(find.text('회수'));
await tester.pumpAndSettle();
expect(captured, isNotNull);
expect(captured?.approvalId, flow.id);
expect(captured?.actorId, 99);
expect(captured?.note, '긴급 회수');
expect(captured?.transactionExpectedUpdatedAt, flow.transactionUpdatedAt);
});
testWidgets('재상신 버튼이 템플릿 단계를 포함해 유즈케이스를 호출한다', (tester) async {
final flow = buildResubmittableFlow();
final controller = await buildController(flow);
ApprovalResubmissionInput? captured;
when(() => resubmitUseCase.call(any())).thenAnswer((invocation) async {
captured =
invocation.positionalArguments.first as ApprovalResubmissionInput;
return flow;
});
await tester.pumpWidget(
_ApprovalActionHarness(controller: controller, actorId: 99),
);
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('resubmit-note-field')),
' 재상신 메모 ',
);
await tester.tap(find.text('재상신'));
await tester.pumpAndSettle();
expect(captured, isNotNull);
expect(captured?.actorId, 99);
expect(captured?.note, '재상신 메모');
expect(captured?.submission.steps.length, flow.steps.length);
expect(captured?.submission.requesterId, flow.requester.id);
expect(captured?.submission.transactionId, flow.transactionId);
expect(captured?.transactionExpectedUpdatedAt, flow.transactionUpdatedAt);
});
}
class _ApprovalActionHarness extends StatefulWidget {
const _ApprovalActionHarness({
required this.controller,
required this.actorId,
});
final ApprovalHistoryController controller;
final int actorId;
@override
State<_ApprovalActionHarness> createState() => _ApprovalActionHarnessState();
}
class _ApprovalActionHarnessState extends State<_ApprovalActionHarness> {
final TextEditingController _recallNoteController = TextEditingController();
final TextEditingController _resubmitNoteController = TextEditingController();
@override
Widget build(BuildContext context) {
return MaterialApp(
home: ShadTheme(
data: ShadThemeData(
colorScheme: const ShadSlateColorScheme.light(),
brightness: Brightness.light,
),
child: Scaffold(
body: AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final flow = widget.controller.selectedFlow;
if (flow == null) {
return const SizedBox.shrink();
}
final canRecall = _canRecall(flow);
final canResubmit = _canResubmit(flow);
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShadInput(
key: const ValueKey('recall-note-field'),
controller: _recallNoteController,
placeholder: const Text('회수 사유'),
),
const SizedBox(height: 12),
ShadButton(
onPressed: canRecall ? () => _handleRecall(flow) : null,
child: const Text('회수'),
),
const SizedBox(height: 24),
ShadInput(
key: const ValueKey('resubmit-note-field'),
controller: _resubmitNoteController,
placeholder: const Text('재상신 메모'),
),
const SizedBox(height: 12),
ShadButton.outline(
onPressed: canResubmit
? () => _handleResubmit(flow)
: null,
child: const Text('재상신'),
),
],
),
);
},
),
),
),
);
}
bool _canRecall(ApprovalFlow flow) {
if (flow.status.isTerminal) {
return false;
}
if (flow.steps.isEmpty) {
return false;
}
return flow.steps.first.decidedAt == null;
}
bool _canResubmit(ApprovalFlow flow) {
if (!flow.status.isTerminal) {
return false;
}
final statusName = flow.status.name.toLowerCase();
return statusName.contains('반려') || statusName.contains('reject');
}
Future<void> _handleRecall(ApprovalFlow flow) async {
final latestFlow = await widget.controller.refreshFlow(flow.id!) ?? flow;
final transactionUpdatedAt = latestFlow.transactionUpdatedAt;
if (transactionUpdatedAt == null) {
return;
}
final input = ApprovalRecallInput(
approvalId: latestFlow.id!,
actorId: widget.actorId,
note: _trimmedOrNull(_recallNoteController.text),
expectedUpdatedAt: latestFlow.approval.updatedAt,
transactionExpectedUpdatedAt: transactionUpdatedAt,
);
await widget.controller.recallApproval(input);
}
Future<void> _handleResubmit(ApprovalFlow flow) async {
final latestFlow = await widget.controller.refreshFlow(flow.id!) ?? flow;
final transactionUpdatedAt = latestFlow.transactionUpdatedAt;
if (transactionUpdatedAt == null) {
return;
}
final steps = latestFlow.steps
.map(
(step) => ApprovalStepAssignmentItem(
stepOrder: step.stepOrder,
approverId: step.approver.id,
note: step.note,
),
)
.toList(growable: false);
final submission = ApprovalSubmissionInput(
transactionId: latestFlow.transactionId,
statusId: latestFlow.status.id,
requesterId: latestFlow.requester.id,
finalApproverId: latestFlow.finalApprover?.id,
note: latestFlow.note,
steps: steps,
);
final input = ApprovalResubmissionInput(
approvalId: latestFlow.id!,
actorId: widget.actorId,
submission: submission,
note: _trimmedOrNull(_resubmitNoteController.text),
expectedUpdatedAt: latestFlow.approval.updatedAt,
transactionExpectedUpdatedAt: transactionUpdatedAt,
);
await widget.controller.resubmitApproval(input);
}
String? _trimmedOrNull(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return null;
}
return trimmed;
}
@override
void dispose() {
_recallNoteController.dispose();
_resubmitNoteController.dispose();
super.dispose();
}
}