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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
|
||||
import '../entities/approval_draft.dart';
|
||||
|
||||
/// 결재 초안 저장소 인터페이스.
|
||||
abstract class ApprovalDraftRepository {
|
||||
Future<PaginatedResult<ApprovalDraftSummary>> list(
|
||||
ApprovalDraftListFilter filter,
|
||||
);
|
||||
|
||||
Future<ApprovalDraftDetail?> fetch({
|
||||
required int id,
|
||||
required int requesterId,
|
||||
});
|
||||
|
||||
Future<ApprovalDraftDetail> save(ApprovalDraftSaveInput input);
|
||||
|
||||
Future<void> delete({required int id, required int requesterId});
|
||||
}
|
||||
@@ -14,6 +14,8 @@ abstract class ApprovalRepository {
|
||||
int? transactionId,
|
||||
int? approvalStatusId,
|
||||
int? requestedById,
|
||||
List<String>? statusCodes,
|
||||
bool includePending = false,
|
||||
bool includeHistories = false,
|
||||
bool includeSteps = false,
|
||||
});
|
||||
@@ -25,6 +27,32 @@ abstract class ApprovalRepository {
|
||||
bool includeHistories = true,
|
||||
});
|
||||
|
||||
/// 결재를 상신한다.
|
||||
Future<Approval> submit(ApprovalSubmissionInput input);
|
||||
|
||||
/// 결재를 재상신한다.
|
||||
Future<Approval> resubmit(ApprovalResubmissionInput input);
|
||||
|
||||
/// 결재를 승인한다.
|
||||
Future<Approval> approve(ApprovalDecisionInput input);
|
||||
|
||||
/// 결재를 반려한다.
|
||||
Future<Approval> reject(ApprovalDecisionInput input);
|
||||
|
||||
/// 결재를 회수한다.
|
||||
Future<Approval> recall(ApprovalRecallInput input);
|
||||
|
||||
/// 결재 감사 로그를 조회한다.
|
||||
Future<PaginatedResult<ApprovalHistory>> listHistory({
|
||||
required int approvalId,
|
||||
int page,
|
||||
int pageSize,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
int? actorId,
|
||||
int? approvalActionId,
|
||||
});
|
||||
|
||||
/// 활성화된 결재 행위(approve/reject/comment 등) 목록 조회
|
||||
Future<List<ApprovalAction>> listActions({bool activeOnly = true});
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import '../entities/approval.dart';
|
||||
import '../entities/approval_flow.dart';
|
||||
import '../entities/approval_template.dart';
|
||||
import '../repositories/approval_repository.dart';
|
||||
import '../repositories/approval_template_repository.dart';
|
||||
|
||||
/// 결재 템플릿을 결재 요청에 적용하는 유즈케이스.
|
||||
///
|
||||
/// - 템플릿 단계를 정렬해 [ApprovalStepAssignmentInput]으로 변환한 뒤 저장소에 위임한다.
|
||||
class ApplyApprovalTemplateUseCase {
|
||||
ApplyApprovalTemplateUseCase({
|
||||
required ApprovalTemplateRepository templateRepository,
|
||||
required ApprovalRepository approvalRepository,
|
||||
}) : _templateRepository = templateRepository,
|
||||
_approvalRepository = approvalRepository;
|
||||
|
||||
final ApprovalTemplateRepository _templateRepository;
|
||||
final ApprovalRepository _approvalRepository;
|
||||
|
||||
/// [templateId]에 해당하는 템플릿을 [approvalId] 결재에 적용한다.
|
||||
///
|
||||
/// 템플릿에 단계가 없으면 [StateError]를 던진다.
|
||||
Future<ApprovalFlow> call({
|
||||
required int approvalId,
|
||||
required int templateId,
|
||||
}) async {
|
||||
final template = await _templateRepository.fetchDetail(
|
||||
templateId,
|
||||
includeSteps: true,
|
||||
);
|
||||
if (template.steps.isEmpty) {
|
||||
throw StateError('단계가 없는 결재 템플릿은 적용할 수 없습니다.');
|
||||
}
|
||||
final steps = _mapTemplateSteps(template);
|
||||
final assignment = ApprovalStepAssignmentInput(
|
||||
approvalId: approvalId,
|
||||
steps: steps,
|
||||
);
|
||||
final approval = await _approvalRepository.assignSteps(assignment);
|
||||
return ApprovalFlow.fromApproval(approval);
|
||||
}
|
||||
|
||||
List<ApprovalStepAssignmentItem> _mapTemplateSteps(
|
||||
ApprovalTemplate template,
|
||||
) {
|
||||
final sorted = List<ApprovalTemplateStep>.of(template.steps)
|
||||
..sort((a, b) => a.stepOrder.compareTo(b.stepOrder));
|
||||
return sorted
|
||||
.map(
|
||||
(step) => ApprovalStepAssignmentItem(
|
||||
stepOrder: step.stepOrder,
|
||||
approverId: step.approver.id,
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import '../entities/approval.dart';
|
||||
import '../entities/approval_flow.dart';
|
||||
import '../repositories/approval_repository.dart';
|
||||
|
||||
/// 결재를 승인하는 유즈케이스.
|
||||
///
|
||||
/// - 승인자는 [ApprovalDecisionInput]을 통해 필요한 정보를 전달한다.
|
||||
class ApproveApprovalUseCase {
|
||||
ApproveApprovalUseCase({required ApprovalRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalRepository _repository;
|
||||
|
||||
/// 결재를 승인하고 최신 [ApprovalFlow]를 반환한다.
|
||||
Future<ApprovalFlow> call(ApprovalDecisionInput input) async {
|
||||
await _ensureCanProceed(input.approvalId);
|
||||
final approval = await _repository.approve(input);
|
||||
return ApprovalFlow.fromApproval(approval);
|
||||
}
|
||||
|
||||
/// 결재 단계 진행 권한을 사전 확인한다.
|
||||
Future<void> _ensureCanProceed(int approvalId) async {
|
||||
final status = await _repository.canProceed(approvalId);
|
||||
if (status.canProceed) {
|
||||
return;
|
||||
}
|
||||
final reason = status.reason?.trim();
|
||||
if (reason != null && reason.isNotEmpty) {
|
||||
throw StateError(reason);
|
||||
}
|
||||
throw StateError('결재를 진행할 권한이 없습니다.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import '../repositories/approval_draft_repository.dart';
|
||||
|
||||
/// 결재 초안을 삭제하는 유즈케이스.
|
||||
class DeleteApprovalDraftUseCase {
|
||||
DeleteApprovalDraftUseCase({required ApprovalDraftRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalDraftRepository _repository;
|
||||
|
||||
Future<void> call({required int id, required int requesterId}) {
|
||||
return _repository.delete(id: id, requesterId: requesterId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import '../entities/approval_draft.dart';
|
||||
import '../repositories/approval_draft_repository.dart';
|
||||
|
||||
/// 결재 초안 상세를 조회하는 유즈케이스.
|
||||
class GetApprovalDraftUseCase {
|
||||
GetApprovalDraftUseCase({required ApprovalDraftRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalDraftRepository _repository;
|
||||
|
||||
Future<ApprovalDraftDetail?> call({
|
||||
required int id,
|
||||
required int requesterId,
|
||||
}) {
|
||||
return _repository.fetch(id: id, requesterId: requesterId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
|
||||
import '../entities/approval_draft.dart';
|
||||
import '../repositories/approval_draft_repository.dart';
|
||||
|
||||
/// 결재 초안 목록을 조회하는 유즈케이스.
|
||||
class ListApprovalDraftsUseCase {
|
||||
ListApprovalDraftsUseCase({required ApprovalDraftRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalDraftRepository _repository;
|
||||
|
||||
Future<PaginatedResult<ApprovalDraftSummary>> call(
|
||||
ApprovalDraftListFilter filter,
|
||||
) {
|
||||
return _repository.list(filter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import '../entities/approval.dart';
|
||||
import '../entities/approval_flow.dart';
|
||||
import '../repositories/approval_repository.dart';
|
||||
|
||||
/// 결재를 회수(recall)하는 유즈케이스.
|
||||
///
|
||||
/// - 회수 가능 여부는 별도의 선행 검증으로 확인해야 한다.
|
||||
class RecallApprovalUseCase {
|
||||
RecallApprovalUseCase({required ApprovalRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalRepository _repository;
|
||||
|
||||
/// 결재를 회수하고 최신 [ApprovalFlow]를 반환한다.
|
||||
Future<ApprovalFlow> call(ApprovalRecallInput input) async {
|
||||
final approval = await _repository.recall(input);
|
||||
return ApprovalFlow.fromApproval(approval);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import '../entities/approval.dart';
|
||||
import '../entities/approval_flow.dart';
|
||||
import '../repositories/approval_repository.dart';
|
||||
|
||||
/// 결재를 반려하는 유즈케이스.
|
||||
///
|
||||
/// - 반려 사유 및 코멘트는 [ApprovalDecisionInput.note]로 전달한다.
|
||||
class RejectApprovalUseCase {
|
||||
RejectApprovalUseCase({required ApprovalRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalRepository _repository;
|
||||
|
||||
/// 결재를 반려하고 최신 [ApprovalFlow]를 반환한다.
|
||||
Future<ApprovalFlow> call(ApprovalDecisionInput input) async {
|
||||
await _ensureCanProceed(input.approvalId);
|
||||
final approval = await _repository.reject(input);
|
||||
return ApprovalFlow.fromApproval(approval);
|
||||
}
|
||||
|
||||
/// 결재 단계 진행 권한을 사전 확인한다.
|
||||
Future<void> _ensureCanProceed(int approvalId) async {
|
||||
final status = await _repository.canProceed(approvalId);
|
||||
if (status.canProceed) {
|
||||
return;
|
||||
}
|
||||
final reason = status.reason?.trim();
|
||||
if (reason != null && reason.isNotEmpty) {
|
||||
throw StateError(reason);
|
||||
}
|
||||
throw StateError('결재를 진행할 권한이 없습니다.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import '../entities/approval.dart';
|
||||
import '../entities/approval_flow.dart';
|
||||
import '../repositories/approval_repository.dart';
|
||||
|
||||
/// 결재를 재상신(resubmit)하는 유즈케이스.
|
||||
///
|
||||
/// - 재상신 시 수정된 단계 정보와 메모를 함께 전달한다.
|
||||
class ResubmitApprovalUseCase {
|
||||
ResubmitApprovalUseCase({required ApprovalRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalRepository _repository;
|
||||
|
||||
/// 결재를 재상신하고 최신 [ApprovalFlow]를 반환한다.
|
||||
Future<ApprovalFlow> call(ApprovalResubmissionInput input) async {
|
||||
final approval = await _repository.resubmit(input);
|
||||
return ApprovalFlow.fromApproval(approval);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import '../entities/approval_draft.dart';
|
||||
import '../repositories/approval_draft_repository.dart';
|
||||
|
||||
/// 결재 초안을 서버에 저장하는 유즈케이스.
|
||||
class SaveApprovalDraftUseCase {
|
||||
SaveApprovalDraftUseCase({required ApprovalDraftRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalDraftRepository _repository;
|
||||
|
||||
Future<ApprovalDraftDetail> call(ApprovalDraftSaveInput input) {
|
||||
return _repository.save(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import '../entities/approval_template.dart';
|
||||
import '../repositories/approval_template_repository.dart';
|
||||
|
||||
/// 결재 템플릿을 생성/수정하는 유즈케이스.
|
||||
///
|
||||
/// - [templateId]가 null이면 신규 생성, 값이 있으면 수정으로 처리한다.
|
||||
class SaveApprovalTemplateUseCase {
|
||||
SaveApprovalTemplateUseCase({required ApprovalTemplateRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalTemplateRepository _repository;
|
||||
|
||||
/// 템플릿을 저장하고 최신 [ApprovalTemplate]을 반환한다.
|
||||
Future<ApprovalTemplate> call({
|
||||
int? templateId,
|
||||
required ApprovalTemplateInput input,
|
||||
List<ApprovalTemplateStepInput>? steps,
|
||||
}) {
|
||||
if (templateId == null) {
|
||||
return _repository.create(input, steps: steps ?? const []);
|
||||
}
|
||||
return _repository.update(templateId, input, steps: steps);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import '../entities/approval.dart';
|
||||
import '../entities/approval_flow.dart';
|
||||
import '../repositories/approval_repository.dart';
|
||||
|
||||
/// 결재를 상신(submit)하는 유즈케이스.
|
||||
///
|
||||
/// - 입력 파라미터는 [ApprovalSubmissionInput]을 사용한다.
|
||||
class SubmitApprovalUseCase {
|
||||
SubmitApprovalUseCase({required ApprovalRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalRepository _repository;
|
||||
|
||||
/// 결재를 상신하고 갱신된 [ApprovalFlow]를 반환한다.
|
||||
Future<ApprovalFlow> call(ApprovalSubmissionInput input) async {
|
||||
final approval = await _repository.submit(input);
|
||||
return ApprovalFlow.fromApproval(approval);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user