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:
@@ -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