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:
@@ -6,7 +6,9 @@ class Approval {
|
||||
Approval({
|
||||
this.id,
|
||||
required this.approvalNo,
|
||||
this.transactionId,
|
||||
required this.transactionNo,
|
||||
this.transactionUpdatedAt,
|
||||
required this.status,
|
||||
this.currentStep,
|
||||
required this.requester,
|
||||
@@ -23,7 +25,9 @@ class Approval {
|
||||
|
||||
final int? id;
|
||||
final String approvalNo;
|
||||
final int? transactionId;
|
||||
final String transactionNo;
|
||||
final DateTime? transactionUpdatedAt;
|
||||
final ApprovalStatus status;
|
||||
final ApprovalStep? currentStep;
|
||||
final ApprovalRequester requester;
|
||||
@@ -40,7 +44,9 @@ class Approval {
|
||||
Approval copyWith({
|
||||
int? id,
|
||||
String? approvalNo,
|
||||
int? transactionId,
|
||||
String? transactionNo,
|
||||
DateTime? transactionUpdatedAt,
|
||||
ApprovalStatus? status,
|
||||
ApprovalStep? currentStep,
|
||||
ApprovalRequester? requester,
|
||||
@@ -57,7 +63,9 @@ class Approval {
|
||||
return Approval(
|
||||
id: id ?? this.id,
|
||||
approvalNo: approvalNo ?? this.approvalNo,
|
||||
transactionId: transactionId ?? this.transactionId,
|
||||
transactionNo: transactionNo ?? this.transactionNo,
|
||||
transactionUpdatedAt: transactionUpdatedAt ?? this.transactionUpdatedAt,
|
||||
status: status ?? this.status,
|
||||
currentStep: currentStep ?? this.currentStep,
|
||||
requester: requester ?? this.requester,
|
||||
@@ -75,11 +83,35 @@ class Approval {
|
||||
}
|
||||
|
||||
class ApprovalStatus {
|
||||
ApprovalStatus({required this.id, required this.name, this.color});
|
||||
ApprovalStatus({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.color,
|
||||
this.isBlockingNext = true,
|
||||
this.isTerminal = false,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String? color;
|
||||
final bool isBlockingNext;
|
||||
final bool isTerminal;
|
||||
|
||||
ApprovalStatus copyWith({
|
||||
int? id,
|
||||
String? name,
|
||||
String? color,
|
||||
bool? isBlockingNext,
|
||||
bool? isTerminal,
|
||||
}) {
|
||||
return ApprovalStatus(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
color: color ?? this.color,
|
||||
isBlockingNext: isBlockingNext ?? this.isBlockingNext,
|
||||
isTerminal: isTerminal ?? this.isTerminal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ApprovalRequester {
|
||||
@@ -97,43 +129,71 @@ class ApprovalRequester {
|
||||
class ApprovalStep {
|
||||
ApprovalStep({
|
||||
this.id,
|
||||
this.requestId,
|
||||
required this.stepOrder,
|
||||
this.templateStepId,
|
||||
this.approverRole,
|
||||
required this.approver,
|
||||
required this.status,
|
||||
required this.assignedAt,
|
||||
this.decidedAt,
|
||||
this.note,
|
||||
this.isDeleted = false,
|
||||
this.actionAt,
|
||||
this.isOptional = false,
|
||||
this.escalationMinutes,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final int? requestId;
|
||||
final int stepOrder;
|
||||
final int? templateStepId;
|
||||
final String? approverRole;
|
||||
final ApprovalApprover approver;
|
||||
final ApprovalStatus status;
|
||||
final DateTime assignedAt;
|
||||
final DateTime? decidedAt;
|
||||
final String? note;
|
||||
final bool isDeleted;
|
||||
final DateTime? actionAt;
|
||||
final bool isOptional;
|
||||
final int? escalationMinutes;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
ApprovalStep copyWith({
|
||||
int? id,
|
||||
int? requestId,
|
||||
int? stepOrder,
|
||||
int? templateStepId,
|
||||
String? approverRole,
|
||||
ApprovalApprover? approver,
|
||||
ApprovalStatus? status,
|
||||
DateTime? assignedAt,
|
||||
DateTime? decidedAt,
|
||||
String? note,
|
||||
bool? isDeleted,
|
||||
DateTime? actionAt,
|
||||
bool? isOptional,
|
||||
int? escalationMinutes,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) {
|
||||
return ApprovalStep(
|
||||
id: id ?? this.id,
|
||||
requestId: requestId ?? this.requestId,
|
||||
stepOrder: stepOrder ?? this.stepOrder,
|
||||
templateStepId: templateStepId ?? this.templateStepId,
|
||||
approverRole: approverRole ?? this.approverRole,
|
||||
approver: approver ?? this.approver,
|
||||
status: status ?? this.status,
|
||||
assignedAt: assignedAt ?? this.assignedAt,
|
||||
decidedAt: decidedAt ?? this.decidedAt,
|
||||
note: note ?? this.note,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
actionAt: actionAt ?? this.actionAt,
|
||||
isOptional: isOptional ?? this.isOptional,
|
||||
escalationMinutes: escalationMinutes ?? this.escalationMinutes,
|
||||
metadata: metadata ?? this.metadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -159,6 +219,8 @@ class ApprovalHistory {
|
||||
required this.approver,
|
||||
required this.actionAt,
|
||||
this.note,
|
||||
this.actionCode,
|
||||
this.payload,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
@@ -168,13 +230,16 @@ class ApprovalHistory {
|
||||
final ApprovalApprover approver;
|
||||
final DateTime actionAt;
|
||||
final String? note;
|
||||
final String? actionCode;
|
||||
final Map<String, dynamic>? payload;
|
||||
}
|
||||
|
||||
class ApprovalAction {
|
||||
ApprovalAction({required this.id, required this.name});
|
||||
ApprovalAction({required this.id, required this.name, this.code});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String? code;
|
||||
}
|
||||
|
||||
/// 결재 단계에서 수행 가능한 행위 타입
|
||||
@@ -300,3 +365,85 @@ class ApprovalStepAssignmentItem {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 상신 입력 모델.
|
||||
class ApprovalSubmissionInput {
|
||||
ApprovalSubmissionInput({
|
||||
this.transactionId,
|
||||
this.templateId,
|
||||
required this.statusId,
|
||||
required this.requesterId,
|
||||
this.finalApproverId,
|
||||
this.requestedAt,
|
||||
this.decidedAt,
|
||||
this.cancelledAt,
|
||||
this.lastActionAt,
|
||||
this.title,
|
||||
this.summary,
|
||||
this.note,
|
||||
this.metadata,
|
||||
this.steps = const [],
|
||||
});
|
||||
|
||||
final int? transactionId;
|
||||
final int? templateId;
|
||||
final int statusId;
|
||||
final int requesterId;
|
||||
final int? finalApproverId;
|
||||
final DateTime? requestedAt;
|
||||
final DateTime? decidedAt;
|
||||
final DateTime? cancelledAt;
|
||||
final DateTime? lastActionAt;
|
||||
final String? title;
|
||||
final String? summary;
|
||||
final String? note;
|
||||
final Map<String, dynamic>? metadata;
|
||||
final List<ApprovalStepAssignmentItem> steps;
|
||||
}
|
||||
|
||||
/// 결재 승인/반려 입력 모델.
|
||||
class ApprovalDecisionInput {
|
||||
ApprovalDecisionInput({
|
||||
required this.approvalId,
|
||||
required this.actorId,
|
||||
this.note,
|
||||
this.expectedUpdatedAt,
|
||||
});
|
||||
|
||||
final int approvalId;
|
||||
final int actorId;
|
||||
final String? note;
|
||||
final DateTime? expectedUpdatedAt;
|
||||
}
|
||||
|
||||
/// 결재 회수 입력 모델.
|
||||
class ApprovalRecallInput extends ApprovalDecisionInput {
|
||||
ApprovalRecallInput({
|
||||
required super.approvalId,
|
||||
required super.actorId,
|
||||
super.note,
|
||||
super.expectedUpdatedAt,
|
||||
this.transactionExpectedUpdatedAt,
|
||||
});
|
||||
|
||||
final DateTime? transactionExpectedUpdatedAt;
|
||||
}
|
||||
|
||||
/// 결재 재상신 입력 모델.
|
||||
class ApprovalResubmissionInput {
|
||||
ApprovalResubmissionInput({
|
||||
required this.approvalId,
|
||||
required this.actorId,
|
||||
required this.submission,
|
||||
this.note,
|
||||
this.expectedUpdatedAt,
|
||||
this.transactionExpectedUpdatedAt,
|
||||
});
|
||||
|
||||
final int approvalId;
|
||||
final int actorId;
|
||||
final ApprovalSubmissionInput submission;
|
||||
final String? note;
|
||||
final DateTime? expectedUpdatedAt;
|
||||
final DateTime? transactionExpectedUpdatedAt;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
167
lib/features/approvals/domain/entities/approval_flow.dart
Normal file
167
lib/features/approvals/domain/entities/approval_flow.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import '../entities/approval.dart';
|
||||
|
||||
/// 결재 흐름(Approval Flow)을 표현하는 도메인 엔티티.
|
||||
///
|
||||
/// - 상신자, 최종 승인자, 단계 목록, 이력, 상태 요약을 한 번에 제공한다.
|
||||
/// - presentation 레이어에서는 이 엔티티만 의존해 UI를 구성한다.
|
||||
class ApprovalFlow {
|
||||
ApprovalFlow({
|
||||
required Approval approval,
|
||||
ApprovalApprover? finalApprover,
|
||||
ApprovalFlowStatusSummary? statusSummary,
|
||||
}) : _approval = approval,
|
||||
finalApprover = finalApprover ?? _inferFinalApprover(approval.steps),
|
||||
statusSummary =
|
||||
statusSummary ??
|
||||
ApprovalFlowStatusSummary.from(
|
||||
status: approval.status,
|
||||
steps: approval.steps,
|
||||
currentStep: approval.currentStep,
|
||||
),
|
||||
_steps = List<ApprovalStep>.unmodifiable(approval.steps),
|
||||
_histories = List<ApprovalHistory>.unmodifiable(approval.histories);
|
||||
|
||||
/// 결재 원본 데이터
|
||||
final Approval _approval;
|
||||
|
||||
/// 결재 단계 목록
|
||||
final List<ApprovalStep> _steps;
|
||||
|
||||
/// 결재 이력 목록
|
||||
final List<ApprovalHistory> _histories;
|
||||
|
||||
/// 최종 승인자 정보 (단계 목록 기반 추론 결과)
|
||||
final ApprovalApprover? finalApprover;
|
||||
|
||||
/// 결재 상태 요약 정보
|
||||
final ApprovalFlowStatusSummary statusSummary;
|
||||
|
||||
/// 원본 결재 엔티티에 접근한다.
|
||||
Approval get approval => _approval;
|
||||
|
||||
/// 결재 식별자(ID)
|
||||
int? get id => _approval.id;
|
||||
|
||||
/// 결재 번호(APP-YYYYMMDDNNNN 형식)
|
||||
String get approvalNo => _approval.approvalNo;
|
||||
|
||||
/// 연동된 전표 번호
|
||||
String get transactionNo => _approval.transactionNo;
|
||||
|
||||
/// 연동된 전표 ID
|
||||
int? get transactionId => _approval.transactionId;
|
||||
|
||||
/// 연동된 전표 최신 수정 시각
|
||||
DateTime? get transactionUpdatedAt => _approval.transactionUpdatedAt;
|
||||
|
||||
/// 현재 결재 상태
|
||||
ApprovalStatus get status => _approval.status;
|
||||
|
||||
/// 현재 진행 중인 단계 정보
|
||||
ApprovalStep? get currentStep => _approval.currentStep;
|
||||
|
||||
/// 상신자 정보
|
||||
ApprovalRequester get requester => _approval.requester;
|
||||
|
||||
/// 상신 일시
|
||||
DateTime get requestedAt => _approval.requestedAt;
|
||||
|
||||
/// 최종 결정 일시
|
||||
DateTime? get decidedAt => _approval.decidedAt;
|
||||
|
||||
/// 결재 메모
|
||||
String? get note => _approval.note;
|
||||
|
||||
/// 생성 일시
|
||||
DateTime? get createdAt => _approval.createdAt;
|
||||
|
||||
/// 변경 일시
|
||||
DateTime? get updatedAt => _approval.updatedAt;
|
||||
|
||||
/// 단계 목록을 반환한다.
|
||||
List<ApprovalStep> get steps => _steps;
|
||||
|
||||
/// 이력 목록을 반환한다.
|
||||
List<ApprovalHistory> get histories => _histories;
|
||||
|
||||
/// [Approval] 엔티티에서 [ApprovalFlow]를 생성하는 팩토리.
|
||||
factory ApprovalFlow.fromApproval(Approval approval) =>
|
||||
ApprovalFlow(approval: approval);
|
||||
|
||||
static ApprovalApprover? _inferFinalApprover(List<ApprovalStep> steps) {
|
||||
if (steps.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final sorted = List<ApprovalStep>.from(steps)
|
||||
..sort((a, b) => a.stepOrder.compareTo(b.stepOrder));
|
||||
return sorted.last.approver;
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 상태 요약 정보.
|
||||
///
|
||||
/// - 전체 단계 수, 완료 단계 수, 대기 단계 수, 현재 단계 순번을 제공한다.
|
||||
class ApprovalFlowStatusSummary {
|
||||
ApprovalFlowStatusSummary({
|
||||
required this.status,
|
||||
required this.totalSteps,
|
||||
required this.completedSteps,
|
||||
required this.pendingSteps,
|
||||
this.currentStepOrder,
|
||||
});
|
||||
|
||||
/// 전체 결재 상태
|
||||
final ApprovalStatus status;
|
||||
|
||||
/// 총 단계 수
|
||||
final int totalSteps;
|
||||
|
||||
/// 완료된 단계 수
|
||||
final int completedSteps;
|
||||
|
||||
/// 대기 중인 단계 수
|
||||
final int pendingSteps;
|
||||
|
||||
/// 현재 진행 중인 단계 순번 (없으면 null)
|
||||
final int? currentStepOrder;
|
||||
|
||||
/// 완료율(%)을 정수로 반환한다.
|
||||
int get completionRate {
|
||||
if (totalSteps <= 0) {
|
||||
return 0;
|
||||
}
|
||||
final ratio = (completedSteps / totalSteps) * 100;
|
||||
return ratio.isFinite ? ratio.round() : 0;
|
||||
}
|
||||
|
||||
/// 결재 상태와 단계 목록을 기반으로 요약 정보를 생성한다.
|
||||
factory ApprovalFlowStatusSummary.from({
|
||||
required ApprovalStatus status,
|
||||
required List<ApprovalStep> steps,
|
||||
ApprovalStep? currentStep,
|
||||
}) {
|
||||
final total = steps.length;
|
||||
final completed = steps.where((step) => step.decidedAt != null).length;
|
||||
final pending = total - completed;
|
||||
final currentOrder = currentStep?.stepOrder ?? _findCurrentStepOrder(steps);
|
||||
return ApprovalFlowStatusSummary(
|
||||
status: status,
|
||||
totalSteps: total,
|
||||
completedSteps: completed,
|
||||
pendingSteps: pending < 0 ? 0 : pending,
|
||||
currentStepOrder: currentOrder,
|
||||
);
|
||||
}
|
||||
|
||||
static int? _findCurrentStepOrder(List<ApprovalStep> steps) {
|
||||
for (final step in steps) {
|
||||
if (step.decidedAt == null) {
|
||||
return step.stepOrder;
|
||||
}
|
||||
}
|
||||
if (steps.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return steps.last.stepOrder;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user