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:
JiWoong Sul
2025-10-31 01:05:39 +09:00
parent 259b056072
commit d76f765814
133 changed files with 13878 additions and 947 deletions

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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('결재를 진행할 권한이 없습니다.');
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('결재를 진행할 권한이 없습니다.');
}
}

View File

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

View File

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

View File

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

View File

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