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,65 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:superport_v2/features/approvals/data/dtos/approval_draft_dto.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_draft.dart';
void main() {
group('ApprovalDraftDto', () {
test('parsePaginated가 요약 리스트를 변환한다', () {
final result = ApprovalDraftDto.parsePaginated({
'items': [
{
'id': 10,
'requester_id': 77,
'status': 'active',
'saved_at': '2025-01-01T00:00:00Z',
'step_count': 2,
'session_key': 'session-1',
},
],
'page': 2,
'page_size': 50,
'total': 90,
});
expect(result.page, 2);
expect(result.pageSize, 50);
expect(result.total, 90);
expect(result.items, hasLength(1));
final summary = result.items.first;
expect(summary.id, 10);
expect(summary.requesterId, 77);
expect(summary.status, ApprovalDraftStatus.active);
expect(summary.stepCount, 2);
expect(summary.sessionKey, 'session-1');
});
test('parseDetail이 상세 정보를 반환한다', () {
final detail = ApprovalDraftDto.parseDetail({
'data': {
'id': 5,
'requester_id': 11,
'saved_at': '2025-01-02T12:00:00Z',
'payload': {
'title': '대여 결재',
'summary': '사전 확인',
'metadata': {
'_client_state': {'status_id': 4},
},
'steps': [
{'step_order': 1, 'approver_id': 20, 'note': '검토'},
],
},
},
});
expect(detail, isNotNull);
final value = detail!;
expect(value.id, 5);
expect(value.requesterId, 11);
expect(value.payload.title, '대여 결재');
expect(value.payload.steps, hasLength(1));
expect(value.payload.steps.first.approverId, 20);
});
});
}

View File

@@ -0,0 +1,138 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:superport_v2/features/approvals/data/dtos/approval_request_dto.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
void main() {
group('ApprovalSubmitRequestDto', () {
test('toJson은 상신 본문과 단계를 직렬화한다', () {
final submission = ApprovalSubmissionInput(
transactionId: 1001,
templateId: 55,
statusId: 7,
requesterId: 22,
finalApproverId: 99,
requestedAt: DateTime.utc(2025, 1, 1, 9, 30),
lastActionAt: DateTime.utc(2025, 1, 3, 8, 15),
title: ' 결재 제목 ',
summary: ' 결재 요약 ',
note: ' 비고 ',
metadata: const {'channel': 'web'},
steps: [
ApprovalStepAssignmentItem(
stepOrder: 1,
approverId: 300,
note: ' 1차 ',
),
],
);
final dto = ApprovalSubmitRequestDto(
approval: ApprovalCreatePayloadDto.fromSubmission(submission),
steps: submission.steps
.map(ApprovalStepInputDto.fromDomain)
.toList(growable: false),
);
final json = dto.toJson();
expect(json['approval'], isA<Map<String, dynamic>>());
expect(json['steps'], isA<List<dynamic>>());
final approvalJson = json['approval'] as Map<String, dynamic>;
expect(approvalJson['transaction_id'], 1001);
expect(approvalJson['template_id'], 55);
expect(approvalJson['approval_status_id'], 7);
expect(approvalJson['requested_by_id'], 22);
expect(approvalJson['final_approver_id'], 99);
expect(
approvalJson['requested_at'],
DateTime.utc(2025, 1, 1, 9, 30).toIso8601String(),
);
expect(approvalJson.containsKey('decided_at'), isFalse);
expect(
approvalJson['last_action_at'],
DateTime.utc(2025, 1, 3, 8, 15).toIso8601String(),
);
expect(approvalJson['title'], '결재 제목');
expect(approvalJson['summary'], '결재 요약');
expect(approvalJson['note'], '비고');
expect(approvalJson['metadata'], {'channel': 'web'});
final steps = json['steps'] as List<dynamic>;
expect(steps, hasLength(1));
final stepJson = steps.first as Map<String, dynamic>;
expect(stepJson['step_order'], 1);
expect(stepJson['approver_id'], 300);
expect(stepJson['note'], '1차');
});
});
group('ApprovalResubmitRequestDto', () {
test('toJson은 옵션 필드와 타임스탬프를 포함한다', () {
final dto = ApprovalResubmitRequestDto(
approvalId: 700,
actorId: 123,
steps: [
ApprovalStepInputDto(stepOrder: 1, approverId: 45, note: ' 의견 '),
],
note: ' 재상신 ',
expectedUpdatedAt: DateTime.utc(2025, 2, 1, 10, 0),
transactionExpectedUpdatedAt: DateTime.utc(2025, 2, 1, 11, 0),
);
final json = dto.toJson();
expect(json['approval_id'], 700);
expect(json['actor_id'], 123);
expect(json['note'], '재상신');
expect(
json['expected_updated_at'],
DateTime.utc(2025, 2, 1, 10, 0).toIso8601String(),
);
expect(
json['transaction_expected_updated_at'],
DateTime.utc(2025, 2, 1, 11, 0).toIso8601String(),
);
final steps = json['steps'] as List<dynamic>;
expect(steps, hasLength(1));
expect((steps.first as Map<String, dynamic>)['note'], '의견');
});
});
group('ApprovalRecallRequestDto', () {
test('회수 요청은 메모가 없으면 note를 누락한다', () {
final dto = ApprovalRecallRequestDto(
approvalId: 501,
actorId: 88,
expectedUpdatedAt: DateTime.utc(2025, 3, 1, 12, 0),
transactionExpectedUpdatedAt: DateTime.utc(2025, 3, 1, 13, 0),
);
final json = dto.toJson();
expect(json['approval_id'], 501);
expect(json['actor_id'], 88);
expect(json.containsKey('note'), isFalse);
expect(
json['expected_updated_at'],
DateTime.utc(2025, 3, 1, 12, 0).toIso8601String(),
);
expect(
json['transaction_expected_updated_at'],
DateTime.utc(2025, 3, 1, 13, 0).toIso8601String(),
);
});
});
group('ApprovalDecisionRequestDto', () {
test('toJson은 비어 있는 메모를 포함하지 않는다', () {
final dto = ApprovalDecisionRequestDto(
approvalId: 301,
actorId: 44,
note: ' ',
);
final json = dto.toJson();
expect(json['approval_id'], 301);
expect(json['actor_id'], 44);
expect(json.containsKey('note'), isFalse);
});
});
}

View File

@@ -0,0 +1,148 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:superport_v2/features/approvals/data/dtos/approval_audit_dto.dart';
import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart';
import 'package:superport_v2/features/approvals/data/dtos/approval_request_dto.dart';
void main() {
group('ApprovalAuditListDto', () {
test('fromJson은 감사 로그 목록과 페이지 정보를 생성한다', () {
final dto = ApprovalAuditListDto.fromJson({
'items': [
{
'id': 1,
'action': {'id': 10, 'name': '상신'},
'to_status': {'id': 3, 'name': '진행중'},
'actor': {'id': 20, 'employee_no': 'EMP20', 'name': '홍길동'},
'action_at': '2025-01-01T09:00:00Z',
'note': '테스트',
},
],
'page': 2,
'page_size': 50,
'total': 80,
});
expect(dto.page, 2);
expect(dto.pageSize, 50);
expect(dto.total, 80);
expect(dto.items, hasLength(1));
final item = dto.items.first;
expect(item.id, 1);
expect(item.action.id, 10);
expect(item.toStatus.name, '진행중');
expect(item.actor.employeeNo, 'EMP20');
expect(item.actionAt.toUtc(), DateTime.utc(2025, 1, 1, 9));
expect(item.note, '테스트');
});
test('ApprovalAuditDto.fromJson은 액션 이름 누락 시 예외를 발생시킨다', () {
expect(
() => ApprovalAuditDto.fromJson({
'id': 9,
'action': {'id': 33},
'to_status': {'id': 2, 'name': '진행'},
'actor': {'id': 5, 'employee_no': 'EMP5', 'name': '최사용'},
'action_at': '2025-01-01T09:00:00Z',
}),
throwsFormatException,
);
});
});
group('ApprovalDto', () {
test('fromJson은 중첩 구조에서도 필드를 추출한다', () {
final dto = ApprovalDto.fromJson(_approvalJson());
final entity = dto.toEntity();
expect(entity.id, 5001);
expect(entity.approvalNo, 'APP-2025-0001');
expect(entity.transactionId, 77);
expect(entity.transactionNo, 'TRX-77');
expect(entity.transactionUpdatedAt, DateTime.utc(2025, 1, 1, 9, 30));
expect(entity.status.name, '진행중');
expect(entity.requester.employeeNo, 'EMP-700');
expect(entity.currentStep?.approver.name, '김승인');
expect(entity.steps, hasLength(1));
expect(entity.histories, hasLength(1));
expect(entity.histories.first.action.name, '상신');
expect(entity.createdAt, DateTime.utc(2025, 1, 1, 8));
expect(entity.updatedAt, DateTime.utc(2025, 1, 1, 9));
});
test('parsePaginated는 페이징 결과를 반환한다', () {
final result = ApprovalDto.parsePaginated({
'items': [_approvalJson()],
'page': 3,
'page_size': 25,
'total': 40,
});
expect(result.page, 3);
expect(result.pageSize, 25);
expect(result.total, 40);
expect(result.items, hasLength(1));
expect(result.items.first.approvalNo, 'APP-2025-0001');
});
test('fromJson은 요청자 요약 누락 시 기본 ID를 보존한다', () {
final dto = ApprovalDto.fromJson({
'id': 6001,
'approval_no': 'APP-2025-06001',
'status': {'id': 1, 'name': '대기'},
'requested_at': '2025-01-01T00:00:00Z',
'requester_id': 77,
'requested_by_id': 77,
'requester': const <String, dynamic>{},
});
expect(dto.requester.id, 77);
expect(dto.requester.employeeNo, '-');
expect(dto.requester.name, '-');
});
});
}
Map<String, dynamic> _approvalJson() {
return {
'id': 5001,
'approval': {
'approval_no': 'APP-2025-0001',
'requested_at': '2025-01-01T08:00:00Z',
'status': {'id': 30, 'name': '진행중'},
'requester': {'id': 700, 'employee_no': 'EMP-700', 'name': '상신자'},
'transaction': {
'id': 77,
'transaction_no': 'TRX-77',
'updated_at': '2025-01-01T09:30:00Z',
},
'steps': [
{
'id': 800,
'step_order': 1,
'approver': {'id': 910, 'employee_no': 'EMP-910', 'name': '김승인'},
'status': {'id': 30, 'name': '진행중'},
'assigned_at': '2025-01-01T08:00:00Z',
},
],
'histories': [
{
'id': 1,
'action': {'id': 10, 'name': '상신'},
'to_status': {'id': 30, 'name': '진행중'},
'actor': {'id': 700, 'employee_no': 'EMP-700', 'name': '상신자'},
'action_at': '2025-01-01T08:05:00Z',
},
],
'created_at': '2025-01-01T08:00:00Z',
'updated_at': '2025-01-01T09:00:00Z',
},
'current_step': {
'id': 800,
'step_order': 1,
'approver': {'id': 910, 'employee_no': 'EMP-910', 'name': '김승인'},
'status': {'id': 30, 'name': '진행중'},
'assigned_at': '2025-01-01T08:00:00Z',
},
};
}