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:
286
lib/features/approvals/domain/entities/approval_draft.dart
Normal file
286
lib/features/approvals/domain/entities/approval_draft.dart
Normal file
@@ -0,0 +1,286 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'approval.dart';
|
||||
|
||||
/// 결재 초안 상태를 표현하는 열거형.
|
||||
enum ApprovalDraftStatus { active, expired, archived }
|
||||
|
||||
/// 결재 초안 단계 정보를 나타낸다.
|
||||
class ApprovalDraftStep {
|
||||
ApprovalDraftStep({
|
||||
required this.stepOrder,
|
||||
required this.approverId,
|
||||
this.approverRole,
|
||||
this.note,
|
||||
this.isOptional = false,
|
||||
});
|
||||
|
||||
final int stepOrder;
|
||||
final int approverId;
|
||||
final String? approverRole;
|
||||
final String? note;
|
||||
final bool isOptional;
|
||||
|
||||
ApprovalStepAssignmentItem toAssignment() {
|
||||
return ApprovalStepAssignmentItem(
|
||||
stepOrder: stepOrder,
|
||||
approverId: approverId,
|
||||
note: note,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final trimmedNote = note?.trim();
|
||||
return {
|
||||
'step_order': stepOrder,
|
||||
'approver_id': approverId,
|
||||
if (approverRole != null && approverRole!.trim().isNotEmpty)
|
||||
'approver_role': approverRole,
|
||||
if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote,
|
||||
'is_optional': isOptional,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 초안 본문을 나타낸다.
|
||||
class ApprovalDraftPayload {
|
||||
ApprovalDraftPayload({
|
||||
this.title,
|
||||
this.summary,
|
||||
this.note,
|
||||
this.templateId,
|
||||
Map<String, dynamic>? metadata,
|
||||
List<ApprovalDraftStep>? steps,
|
||||
}) : metadata = metadata == null
|
||||
? null
|
||||
: Map.unmodifiable(Map<String, dynamic>.from(metadata)),
|
||||
_steps = steps == null
|
||||
? const []
|
||||
: List<ApprovalDraftStep>.unmodifiable(steps);
|
||||
|
||||
final String? title;
|
||||
final String? summary;
|
||||
final String? note;
|
||||
final int? templateId;
|
||||
final Map<String, dynamic>? metadata;
|
||||
final List<ApprovalDraftStep> _steps;
|
||||
|
||||
UnmodifiableListView<ApprovalDraftStep> get steps =>
|
||||
UnmodifiableListView(_steps);
|
||||
|
||||
List<ApprovalStepAssignmentItem> toAssignments() {
|
||||
return _steps.map((step) => step.toAssignment()).toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 초안 요약 정보를 담는다.
|
||||
class ApprovalDraftSummary {
|
||||
ApprovalDraftSummary({
|
||||
required this.id,
|
||||
required this.requesterId,
|
||||
required this.status,
|
||||
required this.savedAt,
|
||||
this.requestId,
|
||||
this.transactionId,
|
||||
this.templateId,
|
||||
this.title,
|
||||
this.summary,
|
||||
this.expiresAt,
|
||||
this.sessionKey,
|
||||
this.stepCount = 0,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int requesterId;
|
||||
final ApprovalDraftStatus status;
|
||||
final DateTime savedAt;
|
||||
final int? requestId;
|
||||
final int? transactionId;
|
||||
final int? templateId;
|
||||
final String? title;
|
||||
final String? summary;
|
||||
final DateTime? expiresAt;
|
||||
final String? sessionKey;
|
||||
final int stepCount;
|
||||
}
|
||||
|
||||
/// 결재 초안 상세 정보를 나타낸다.
|
||||
class ApprovalDraftDetail {
|
||||
ApprovalDraftDetail({
|
||||
required this.id,
|
||||
required this.requesterId,
|
||||
required this.payload,
|
||||
required this.savedAt,
|
||||
this.transactionId,
|
||||
this.templateId,
|
||||
this.expiresAt,
|
||||
this.sessionKey,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int requesterId;
|
||||
final ApprovalDraftPayload payload;
|
||||
final DateTime savedAt;
|
||||
final int? transactionId;
|
||||
final int? templateId;
|
||||
final DateTime? expiresAt;
|
||||
final String? sessionKey;
|
||||
|
||||
Map<String, dynamic>? get sanitizedMetadata =>
|
||||
_stripClientState(payload.metadata);
|
||||
|
||||
ApprovalSubmissionInput toSubmissionInput({
|
||||
int? defaultStatusId,
|
||||
int? transactionIdOverride,
|
||||
}) {
|
||||
final statusId = _extractStatusId(payload.metadata) ?? defaultStatusId ?? 0;
|
||||
final assignments = payload.toAssignments();
|
||||
final cleanedMetadata = _stripClientState(payload.metadata);
|
||||
return ApprovalSubmissionInput(
|
||||
transactionId: transactionIdOverride ?? transactionId,
|
||||
templateId: payload.templateId ?? templateId,
|
||||
statusId: statusId,
|
||||
requesterId: requesterId,
|
||||
finalApproverId: assignments.isEmpty ? null : assignments.last.approverId,
|
||||
title: payload.title,
|
||||
summary: payload.summary,
|
||||
note: payload.note,
|
||||
metadata: cleanedMetadata,
|
||||
steps: assignments,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 초안 목록 필터.
|
||||
class ApprovalDraftListFilter {
|
||||
const ApprovalDraftListFilter({
|
||||
required this.requesterId,
|
||||
this.page = 1,
|
||||
this.pageSize = 20,
|
||||
this.transactionId,
|
||||
this.includeExpired = false,
|
||||
}) : assert(page > 0, 'page는 1 이상이어야 합니다.');
|
||||
|
||||
final int requesterId;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final int? transactionId;
|
||||
final bool includeExpired;
|
||||
|
||||
Map<String, dynamic> toQuery() {
|
||||
return {
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
'requester_id': requesterId,
|
||||
if (transactionId != null) 'transaction_id': transactionId,
|
||||
if (includeExpired) 'include_expired': includeExpired,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 초안 저장 입력 모델.
|
||||
class ApprovalDraftSaveInput {
|
||||
ApprovalDraftSaveInput({
|
||||
required this.requesterId,
|
||||
required List<ApprovalDraftStep> steps,
|
||||
this.requestId,
|
||||
this.transactionId,
|
||||
this.templateId,
|
||||
this.title,
|
||||
this.summary,
|
||||
this.note,
|
||||
Map<String, dynamic>? metadata,
|
||||
this.sessionKey,
|
||||
this.statusId,
|
||||
}) : metadata = metadata == null
|
||||
? null
|
||||
: Map.unmodifiable(Map<String, dynamic>.from(metadata)),
|
||||
steps = List<ApprovalDraftStep>.unmodifiable(steps);
|
||||
|
||||
final int requesterId;
|
||||
final List<ApprovalDraftStep> steps;
|
||||
final int? requestId;
|
||||
final int? transactionId;
|
||||
final int? templateId;
|
||||
final String? title;
|
||||
final String? summary;
|
||||
final String? note;
|
||||
final Map<String, dynamic>? metadata;
|
||||
final String? sessionKey;
|
||||
final int? statusId;
|
||||
|
||||
bool get hasSteps => steps.isNotEmpty;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final payload = <String, dynamic>{
|
||||
'requester_id': requesterId,
|
||||
if (requestId != null) 'request_id': requestId,
|
||||
if (transactionId != null) 'transaction_id': transactionId,
|
||||
if (templateId != null) 'template_id': templateId,
|
||||
if (title != null && title!.trim().isNotEmpty) 'title': title,
|
||||
if (summary != null && summary!.trim().isNotEmpty) 'summary': summary,
|
||||
if (note != null && note!.trim().isNotEmpty) 'note': note,
|
||||
if (sessionKey != null && sessionKey!.trim().isNotEmpty)
|
||||
'session_key': sessionKey,
|
||||
};
|
||||
final mergedMetadata = _mergeStatus(source: metadata, statusId: statusId);
|
||||
if (mergedMetadata != null && mergedMetadata.isNotEmpty) {
|
||||
payload['metadata'] = mergedMetadata;
|
||||
}
|
||||
payload['steps'] = steps
|
||||
.map((step) => step.toJson())
|
||||
.toList(growable: false);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
const _clientStateKey = '_client_state';
|
||||
const _statusKey = 'status_id';
|
||||
|
||||
Map<String, dynamic>? _mergeStatus({
|
||||
Map<String, dynamic>? source,
|
||||
int? statusId,
|
||||
}) {
|
||||
if (statusId == null) {
|
||||
return source;
|
||||
}
|
||||
final merged = source == null
|
||||
? <String, dynamic>{}
|
||||
: Map<String, dynamic>.from(source);
|
||||
final client = merged[_clientStateKey];
|
||||
final state = client is Map<String, dynamic>
|
||||
? Map<String, dynamic>.from(client)
|
||||
: <String, dynamic>{};
|
||||
state[_statusKey] = statusId;
|
||||
merged[_clientStateKey] = state;
|
||||
return merged;
|
||||
}
|
||||
|
||||
int? _extractStatusId(Map<String, dynamic>? metadata) {
|
||||
if (metadata == null || metadata.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final client = metadata[_clientStateKey];
|
||||
if (client is Map<String, dynamic>) {
|
||||
final value = client[_statusKey];
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is String) {
|
||||
return int.tryParse(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _stripClientState(Map<String, dynamic>? metadata) {
|
||||
if (metadata == null || metadata.isEmpty) {
|
||||
return metadata;
|
||||
}
|
||||
if (!metadata.containsKey(_clientStateKey)) {
|
||||
return metadata;
|
||||
}
|
||||
final cloned = Map<String, dynamic>.from(metadata);
|
||||
cloned.remove(_clientStateKey);
|
||||
return cloned.isEmpty ? null : cloned;
|
||||
}
|
||||
Reference in New Issue
Block a user