feat(inventory): 재고 현황 요약/상세 플로우를 릴리스

- lib/features/inventory/summary 계층과 warehouse select 위젯을 추가해 목록/상세, 자동 새로고침, 필터, 상세 시트를 구현

- PermissionBootstrapper, scope 파서, 라우트 가드로 inventory.view 기반 권한 부여와 메뉴 노출을 통합(lib/core, lib/main.dart 등)

- Inventory Summary API/QA/Audit 문서와 PR 템플릿, CHANGELOG를 신규 스펙과 검증 커맨드로 업데이트

- DTO 직렬화 의존성을 추가하고 Golden·Widget·단위 테스트를 작성했으며 flutter analyze / flutter test --coverage를 통과
This commit is contained in:
JiWoong Sul
2025-11-09 01:13:02 +09:00
parent 486ab8706f
commit 47cc62a33d
72 changed files with 5453 additions and 1021 deletions

View File

@@ -170,11 +170,7 @@ void main() {
record = historyRepository.listResult!.items.first;
user = const AuthenticatedUser(
id: 42,
name: '결재자',
employeeNo: 'E042',
);
user = const AuthenticatedUser(id: 42, name: '결재자', employeeNo: 'E042');
});
tearDown(() {
@@ -199,148 +195,143 @@ void main() {
await tester.pumpAndSettle();
}
testWidgets(
'showApprovalHistoryDetailDialog 결재 요약과 타임라인을 표시한다',
(tester) async {
await openDialog(tester);
testWidgets('showApprovalHistoryDetailDialog 결재 요약과 타임라인을 표시한다', (
tester,
) async {
await openDialog(tester);
expect(find.text('결재 이력 상세'), findsOneWidget);
expect(find.textContaining('결재번호 ${record.approvalNo}'), findsWidgets);
expect(find.text('상태 타임라인'), findsOneWidget);
expect(find.text('감사 로그'), findsOneWidget);
expect(find.text('결재 이력 상세'), findsOneWidget);
expect(find.textContaining('결재번호 ${record.approvalNo}'), findsWidgets);
expect(find.text('상태 타임라인'), findsOneWidget);
expect(find.text('감사 로그'), findsOneWidget);
expect(
find.textContaining(
'상신자 ${sampleApproval.requester.name} (${sampleApproval.requester.employeeNo})',
),
findsOneWidget,
);
expect(
find.textContaining('${sampleApproval.steps.length}단계'),
findsOneWidget,
);
expect(find.text('승인'), findsWidgets);
expect(
find.textContaining(
'상신자 ${sampleApproval.requester.name} (${sampleApproval.requester.employeeNo})',
),
findsOneWidget,
);
expect(
find.textContaining('${sampleApproval.steps.length}단계'),
findsOneWidget,
);
expect(find.text('승인'), findsWidgets);
expect(approvalRepository.listHistoryCalls, isNotEmpty);
},
);
expect(approvalRepository.listHistoryCalls, isNotEmpty);
});
testWidgets(
'회수 버튼을 누르면 recallApproval이 호출되어 감사 로그가 새로고침된다',
(tester) async {
await openDialog(tester);
testWidgets('회수 버튼을 누르면 recallApproval이 호출되어 감사 로그가 새로고침된다', (tester) async {
await openDialog(tester);
final recallButton = find.widgetWithText(ShadButton, '회수');
expect(recallButton, findsOneWidget);
await tester.ensureVisible(recallButton);
await tester.tap(recallButton, warnIfMissed: false);
await tester.pumpAndSettle();
final recallButton = find.widgetWithText(ShadButton, '회수');
expect(recallButton, findsOneWidget);
await tester.ensureVisible(recallButton);
await tester.tap(recallButton, warnIfMissed: false);
await tester.pumpAndSettle();
final dialogFinder = find.ancestor(
of: find.text('결재 회수'),
matching: find.byType(SuperportDialog),
);
expect(dialogFinder, findsOneWidget);
final dialogFinder = find.ancestor(
of: find.text('결재 회수'),
matching: find.byType(SuperportDialog),
);
expect(dialogFinder, findsOneWidget);
final memoField = find.descendant(
of: dialogFinder,
matching: find.byType(ShadTextarea),
);
expect(memoField, findsOneWidget);
await tester.enterText(memoField, '긴급 회수');
final memoField = find.descendant(
of: dialogFinder,
matching: find.byType(ShadTextarea),
);
expect(memoField, findsOneWidget);
await tester.enterText(memoField, '긴급 회수');
final confirmButton = find.descendant(
of: dialogFinder,
matching: find.widgetWithText(ShadButton, '회수'),
);
await tester.tap(confirmButton, warnIfMissed: false);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
final confirmButton = find.descendant(
of: dialogFinder,
matching: find.widgetWithText(ShadButton, '회수'),
);
await tester.tap(confirmButton, warnIfMissed: false);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(approvalRepository.recallInputs, hasLength(1));
expect(approvalRepository.recallInputs.first.note, '긴급 회수');
expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2));
},
);
expect(approvalRepository.recallInputs, hasLength(1));
expect(approvalRepository.recallInputs.first.note, '긴급 회수');
expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2));
});
testWidgets(
'재상신 버튼을 누르면 resubmitApproval이 호출되어 최신 단계 정보가 전달된다',
(tester) async {
final rejectedApproval = sampleApproval.copyWith(
status: statusRejected,
steps: sampleApproval.steps
.map(
(step) => step.stepOrder == 1
? step.copyWith(
status: statusRejected,
decidedAt: DateTime(2024, 1, 10, 11),
)
: step,
)
.toList(growable: false),
);
final resubmittedStatus = ApprovalStatus(
id: 4,
name: '재상신',
color: '#6366F1',
isTerminal: false,
);
final resubmittedApproval = rejectedApproval.copyWith(
status: resubmittedStatus,
updatedAt: DateTime(2024, 1, 10, 13, 10),
);
testWidgets('재상신 버튼을 누르면 resubmitApproval이 호출되어 최신 단계 정보가 전달된다', (
tester,
) async {
final rejectedApproval = sampleApproval.copyWith(
status: statusRejected,
steps: sampleApproval.steps
.map(
(step) => step.stepOrder == 1
? step.copyWith(
status: statusRejected,
decidedAt: DateTime(2024, 1, 10, 11),
)
: step,
)
.toList(growable: false),
);
final resubmittedStatus = ApprovalStatus(
id: 4,
name: '재상신',
color: '#6366F1',
isTerminal: false,
);
final resubmittedApproval = rejectedApproval.copyWith(
status: resubmittedStatus,
updatedAt: DateTime(2024, 1, 10, 13, 10),
);
approvalRepository
..detail = rejectedApproval
..resubmitResult = resubmittedApproval;
record = record.copyWith(
action: ApprovalAction(id: 33, name: '반려', code: 'reject'),
toStatus: statusRejected,
stepOrder: 2,
);
approvalRepository
..detail = rejectedApproval
..resubmitResult = resubmittedApproval;
record = record.copyWith(
action: ApprovalAction(id: 33, name: '반려', code: 'reject'),
toStatus: statusRejected,
stepOrder: 2,
);
await openDialog(tester);
await openDialog(tester);
final resubmitButton = find.widgetWithText(ShadButton, '재상신');
expect(resubmitButton, findsOneWidget);
await tester.ensureVisible(resubmitButton);
await tester.tap(resubmitButton, warnIfMissed: false);
await tester.pumpAndSettle();
final resubmitButton = find.widgetWithText(ShadButton, '재상신');
expect(resubmitButton, findsOneWidget);
await tester.ensureVisible(resubmitButton);
await tester.tap(resubmitButton, warnIfMissed: false);
await tester.pumpAndSettle();
final dialogFinder = find.ancestor(
of: find.text('결재 재상신'),
matching: find.byType(SuperportDialog),
);
expect(dialogFinder, findsOneWidget);
final dialogFinder = find.ancestor(
of: find.text('결재 재상신'),
matching: find.byType(SuperportDialog),
);
expect(dialogFinder, findsOneWidget);
final memoField = find.descendant(
of: dialogFinder,
matching: find.byType(ShadTextarea),
);
expect(memoField, findsOneWidget);
await tester.enterText(memoField, '재상신 메모');
final memoField = find.descendant(
of: dialogFinder,
matching: find.byType(ShadTextarea),
);
expect(memoField, findsOneWidget);
await tester.enterText(memoField, '재상신 메모');
final confirmButton = find.descendant(
of: dialogFinder,
matching: find.widgetWithText(ShadButton, '재상신'),
);
await tester.tap(confirmButton, warnIfMissed: false);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
final confirmButton = find.descendant(
of: dialogFinder,
matching: find.widgetWithText(ShadButton, '재상신'),
);
await tester.tap(confirmButton, warnIfMissed: false);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(approvalRepository.resubmitInputs, hasLength(1));
final input = approvalRepository.resubmitInputs.first;
expect(input.note, '재상신 메모');
expect(input.submission.steps.length, rejectedApproval.steps.length);
expect(
input.submission.steps.first.stepOrder,
rejectedApproval.steps.first.stepOrder,
);
expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2));
},
);
expect(approvalRepository.resubmitInputs, hasLength(1));
final input = approvalRepository.resubmitInputs.first;
expect(input.note, '재상신 메모');
expect(input.submission.steps.length, rejectedApproval.steps.length);
expect(
input.submission.steps.first.stepOrder,
rejectedApproval.steps.first.stepOrder,
);
expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2));
});
}
class _FakeApprovalRepository implements ApprovalRepository {