Files
superport_v2/lib/features/approvals/domain/entities/approval_draft.dart
JiWoong Sul d76f765814 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
2025-10-31 01:05:39 +09:00

287 lines
7.6 KiB
Dart

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;
}