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:
@@ -0,0 +1,78 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/network/api_client.dart';
|
||||
import 'package:superport_v2/core/network/api_routes.dart';
|
||||
|
||||
import '../../domain/entities/approval_draft.dart';
|
||||
import '../../domain/repositories/approval_draft_repository.dart';
|
||||
import '../dtos/approval_draft_dto.dart';
|
||||
|
||||
/// 결재 초안을 원격 저장소로 관리한다.
|
||||
class ApprovalDraftRepositoryRemote implements ApprovalDraftRepository {
|
||||
ApprovalDraftRepositoryRemote({required ApiClient apiClient})
|
||||
: _api = apiClient;
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
@override
|
||||
Future<PaginatedResult<ApprovalDraftSummary>> list(
|
||||
ApprovalDraftListFilter filter,
|
||||
) async {
|
||||
final query = ApiClient.buildQuery(
|
||||
page: filter.page,
|
||||
pageSize: filter.pageSize,
|
||||
filters: {
|
||||
'requester_id': filter.requesterId,
|
||||
if (filter.transactionId != null)
|
||||
'transaction_id': filter.transactionId,
|
||||
if (filter.includeExpired) 'include_expired': filter.includeExpired,
|
||||
},
|
||||
);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalDrafts,
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDraftDto.parsePaginated(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ApprovalDraftDetail?> fetch({
|
||||
required int id,
|
||||
required int requesterId,
|
||||
}) async {
|
||||
final query = ApiClient.buildQuery(filters: {'requester_id': requesterId});
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
ApiClient.buildPath(ApiRoutes.approvalDrafts, [id]),
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDraftDto.parseDetail(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ApprovalDraftDetail> save(ApprovalDraftSaveInput input) async {
|
||||
final payload = input.toJson();
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalDrafts,
|
||||
data: payload,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
final detail = ApprovalDraftDto.parseDetail(response.data);
|
||||
if (detail == null) {
|
||||
throw const FormatException('초안 저장 응답이 비어 있습니다.');
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete({required int id, required int requesterId}) async {
|
||||
final query = ApiClient.buildQuery(filters: {'requester_id': requesterId});
|
||||
await _api.delete<void>(
|
||||
ApiClient.buildPath(ApiRoutes.approvalDrafts, [id]),
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import 'package:superport_v2/core/network/api_routes.dart';
|
||||
import '../../domain/entities/approval.dart';
|
||||
import '../../domain/entities/approval_proceed_status.dart';
|
||||
import '../../domain/repositories/approval_repository.dart';
|
||||
import '../dtos/approval_audit_dto.dart';
|
||||
import '../dtos/approval_dto.dart';
|
||||
import '../dtos/approval_proceed_status_dto.dart';
|
||||
import '../dtos/approval_request_dto.dart';
|
||||
|
||||
/// 결재 API 엔드포인트를 호출하는 원격 저장소 구현체.
|
||||
///
|
||||
@@ -18,7 +20,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
static const _basePath = '${ApiRoutes.apiV1}/approvals';
|
||||
static const _basePath = ApiRoutes.approvals;
|
||||
|
||||
/// 결재 목록을 조회한다. 필터 조건이 없으면 최신순 페이지를 반환한다.
|
||||
@override
|
||||
@@ -28,26 +30,34 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
int? transactionId,
|
||||
int? approvalStatusId,
|
||||
int? requestedById,
|
||||
List<String>? statusCodes,
|
||||
bool includePending = false,
|
||||
bool includeHistories = false,
|
||||
bool includeSteps = false,
|
||||
}) async {
|
||||
final includeParts = <String>[];
|
||||
final includeParts = <String>['requested_by', 'transaction'];
|
||||
if (includeSteps) {
|
||||
includeParts.add('steps');
|
||||
}
|
||||
if (includeHistories) {
|
||||
includeParts.add('histories');
|
||||
}
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_basePath,
|
||||
query: {
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
final query = ApiClient.buildQuery(
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
include: includeParts,
|
||||
filters: {
|
||||
if (transactionId != null) 'transaction_id': transactionId,
|
||||
if (approvalStatusId != null) 'approval_status_id': approvalStatusId,
|
||||
if (requestedById != null) 'requested_by_id': requestedById,
|
||||
if (includeParts.isNotEmpty) 'include': includeParts.join(','),
|
||||
if (statusCodes != null && statusCodes.isNotEmpty)
|
||||
'status': statusCodes,
|
||||
if (includePending) 'include_pending': includePending,
|
||||
},
|
||||
);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_basePath,
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDto.parsePaginated(response.data ?? const {});
|
||||
@@ -60,27 +70,114 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
bool includeSteps = true,
|
||||
bool includeHistories = true,
|
||||
}) async {
|
||||
final includeParts = <String>[];
|
||||
final includeParts = <String>['transaction', 'requested_by'];
|
||||
if (includeSteps) {
|
||||
includeParts.add('steps');
|
||||
}
|
||||
if (includeHistories) {
|
||||
includeParts.add('histories');
|
||||
}
|
||||
final query = ApiClient.buildQuery(include: includeParts);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
'$_basePath/$id',
|
||||
query: {if (includeParts.isNotEmpty) 'include': includeParts.join(',')},
|
||||
ApiClient.buildPath(_basePath, [id]),
|
||||
query: query.isEmpty ? null : query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity();
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> submit(ApprovalSubmissionInput input) async {
|
||||
final payload = ApprovalSubmitRequestDto(
|
||||
approval: ApprovalCreatePayloadDto.fromSubmission(input),
|
||||
steps: _mapSteps(input.steps),
|
||||
);
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalAction('submit'),
|
||||
data: payload.toJson(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> resubmit(ApprovalResubmissionInput input) async {
|
||||
final payload = ApprovalResubmitRequestDto(
|
||||
approvalId: input.approvalId,
|
||||
actorId: input.actorId,
|
||||
steps: _mapSteps(input.submission.steps),
|
||||
note: input.note,
|
||||
expectedUpdatedAt: input.expectedUpdatedAt,
|
||||
transactionExpectedUpdatedAt: input.transactionExpectedUpdatedAt,
|
||||
);
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalAction('resubmit'),
|
||||
data: payload.toJson(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> approve(ApprovalDecisionInput input) async {
|
||||
final payload = ApprovalDecisionRequestDto(
|
||||
approvalId: input.approvalId,
|
||||
actorId: input.actorId,
|
||||
note: input.note,
|
||||
expectedUpdatedAt: input.expectedUpdatedAt,
|
||||
);
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalAction('approve'),
|
||||
data: payload.toJson(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> reject(ApprovalDecisionInput input) async {
|
||||
final payload = ApprovalDecisionRequestDto(
|
||||
approvalId: input.approvalId,
|
||||
actorId: input.actorId,
|
||||
note: input.note,
|
||||
expectedUpdatedAt: input.expectedUpdatedAt,
|
||||
);
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalAction('reject'),
|
||||
data: payload.toJson(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> recall(ApprovalRecallInput input) async {
|
||||
final payload = ApprovalRecallRequestDto(
|
||||
approvalId: input.approvalId,
|
||||
actorId: input.actorId,
|
||||
note: input.note,
|
||||
expectedUpdatedAt: input.expectedUpdatedAt,
|
||||
transactionExpectedUpdatedAt: input.transactionExpectedUpdatedAt,
|
||||
);
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalAction('recall'),
|
||||
data: payload.toJson(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
/// 활성화된 결재 행위 목록을 조회한다.
|
||||
@override
|
||||
Future<List<ApprovalAction>> listActions({bool activeOnly = true}) async {
|
||||
final query = ApiClient.buildQuery(
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
filters: {if (activeOnly) 'active': true},
|
||||
);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
'${ApiRoutes.apiV1}/approval-actions',
|
||||
query: {'page': 1, 'page_size': 100, if (activeOnly) 'active': true},
|
||||
ApiRoutes.approvalActions,
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
final items = (response.data?['items'] as List<dynamic>? ?? [])
|
||||
@@ -91,11 +188,50 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
return items;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PaginatedResult<ApprovalHistory>> listHistory({
|
||||
required int approvalId,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
int? actorId,
|
||||
int? approvalActionId,
|
||||
}) async {
|
||||
final query = ApiClient.buildQuery(
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
filters: {
|
||||
'approval_id': approvalId,
|
||||
if (from != null) 'action_from': from,
|
||||
if (to != null) 'action_to': to,
|
||||
if (actorId != null) 'approver_id': actorId,
|
||||
if (approvalActionId != null) 'approval_action_id': approvalActionId,
|
||||
},
|
||||
);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalHistory,
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
final dto = ApprovalAuditListDto.fromJson(response.data ?? const {});
|
||||
return PaginatedResult<ApprovalHistory>(
|
||||
items: dto.items.map((e) => e.toEntity()).toList(growable: false),
|
||||
page: dto.page,
|
||||
pageSize: dto.pageSize,
|
||||
total: dto.total,
|
||||
);
|
||||
}
|
||||
|
||||
/// 결재 단계 행위를 수행하고 업데이트된 결재 정보를 반환한다.
|
||||
@override
|
||||
Future<Approval> performStepAction(ApprovalStepActionInput input) async {
|
||||
final path = ApiClient.buildPath(ApiRoutes.approvalSteps, [
|
||||
input.stepId,
|
||||
'actions',
|
||||
]);
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'${ApiRoutes.apiV1}/approval-steps/${input.stepId}/actions',
|
||||
path,
|
||||
data: input.toPayload(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
@@ -111,8 +247,9 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
/// 결재 단계들을 일괄로 생성하거나 재배치한다.
|
||||
@override
|
||||
Future<Approval> assignSteps(ApprovalStepAssignmentInput input) async {
|
||||
final path = ApiClient.buildPath(_basePath, [input.approvalId, 'steps']);
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'${ApiRoutes.apiV1}/approvals/${input.approvalId}/steps',
|
||||
path,
|
||||
data: input.toPayload(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
@@ -129,7 +266,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
@override
|
||||
Future<ApprovalProceedStatus> canProceed(int id) async {
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
'$_basePath/$id/can-proceed',
|
||||
ApiClient.buildPath(_basePath, [id, 'can-proceed']),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalProceedStatusDto.fromJson(
|
||||
@@ -145,34 +282,66 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
data: input.toPayload(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity();
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
/// 결재 기본 정보를 수정한다.
|
||||
@override
|
||||
Future<Approval> update(ApprovalUpdateInput input) async {
|
||||
final response = await _api.patch<Map<String, dynamic>>(
|
||||
'$_basePath/${input.id}',
|
||||
ApiClient.buildPath(_basePath, [input.id]),
|
||||
data: input.toPayload(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity();
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
/// 결재를 삭제(비활성화)한다.
|
||||
@override
|
||||
Future<void> delete(int id) async {
|
||||
await _api.delete<void>('$_basePath/$id');
|
||||
await _api.delete<void>(ApiClient.buildPath(_basePath, [id]));
|
||||
}
|
||||
|
||||
/// 삭제된 결재를 복구한다.
|
||||
@override
|
||||
Future<Approval> restore(int id) async {
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'$_basePath/$id/restore',
|
||||
ApiClient.buildPath(_basePath, [id, 'restore']),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity();
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
/// 결재 단계/행위 응답에서 결재 객체 JSON을 추출한다.
|
||||
Approval _mapApprovalFromResponse(Map<String, dynamic>? body) {
|
||||
final payload = _extractApprovalPayload(body);
|
||||
if (payload.isEmpty) {
|
||||
throw StateError('결재 응답에 결재 데이터가 없습니다.');
|
||||
}
|
||||
return ApprovalDto.fromJson(payload).toEntity();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _extractApprovalPayload(Map<String, dynamic>? body) {
|
||||
if (body == null || body.isEmpty) {
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
final data = body['data'];
|
||||
if (data is Map<String, dynamic>) {
|
||||
final approval = _selectApprovalPayload(data);
|
||||
if (approval != null) {
|
||||
return approval;
|
||||
}
|
||||
return Map<String, dynamic>.from(data);
|
||||
}
|
||||
final approval = _selectApprovalPayload(body);
|
||||
if (approval != null) {
|
||||
return approval;
|
||||
}
|
||||
return Map<String, dynamic>.from(body);
|
||||
}
|
||||
|
||||
List<ApprovalStepInputDto> _mapSteps(List<ApprovalStepAssignmentItem> items) {
|
||||
return items.map(ApprovalStepInputDto.fromDomain).toList(growable: false);
|
||||
}
|
||||
|
||||
/// 결재 단계/행위 응답에서 결재 객체 JSON을 추출한다.
|
||||
|
||||
Reference in New Issue
Block a user