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,538 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../../approvals/domain/entities/approval.dart';
|
||||
import '../../../../approvals/domain/entities/approval_flow.dart';
|
||||
import '../../../../approvals/domain/entities/approval_template.dart';
|
||||
import '../../../../approvals/domain/repositories/approval_template_repository.dart';
|
||||
import '../../../../approvals/domain/usecases/apply_approval_template_use_case.dart';
|
||||
import '../../../../approvals/domain/usecases/save_approval_template_use_case.dart';
|
||||
import '../../../../inventory/transactions/domain/entities/stock_transaction_input.dart';
|
||||
|
||||
/// 결재 요청 화면에서 사용하는 참가자 요약 정보.
|
||||
///
|
||||
/// - 상신자(requester)와 승인자(approver)에 공통으로 적용한다.
|
||||
class ApprovalRequestParticipant {
|
||||
const ApprovalRequestParticipant({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.employeeNo,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String employeeNo;
|
||||
|
||||
/// [ApprovalRequester]로 변환한다.
|
||||
ApprovalRequester toRequester() {
|
||||
return ApprovalRequester(id: id, employeeNo: employeeNo, name: name);
|
||||
}
|
||||
|
||||
/// [ApprovalApprover]로 변환한다.
|
||||
ApprovalApprover toApprover() {
|
||||
return ApprovalApprover(id: id, employeeNo: employeeNo, name: name);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 요청 단계 상태를 표현한다.
|
||||
class ApprovalRequestStep {
|
||||
const ApprovalRequestStep({
|
||||
required this.stepOrder,
|
||||
required this.approver,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final int stepOrder;
|
||||
final ApprovalRequestParticipant approver;
|
||||
final String? note;
|
||||
|
||||
int get approverId => approver.id;
|
||||
|
||||
ApprovalRequestStep copyWith({
|
||||
int? stepOrder,
|
||||
ApprovalRequestParticipant? approver,
|
||||
String? note,
|
||||
}) {
|
||||
return ApprovalRequestStep(
|
||||
stepOrder: stepOrder ?? this.stepOrder,
|
||||
approver: approver ?? this.approver,
|
||||
note: note ?? this.note,
|
||||
);
|
||||
}
|
||||
|
||||
/// 도메인 계층에서 사용하는 [ApprovalStepAssignmentItem]으로 변환한다.
|
||||
ApprovalStepAssignmentItem toAssignmentItem() {
|
||||
return ApprovalStepAssignmentItem(
|
||||
stepOrder: stepOrder,
|
||||
approverId: approver.id,
|
||||
note: note,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 템플릿 버전 정보를 보관한다.
|
||||
class ApprovalTemplateSnapshot {
|
||||
const ApprovalTemplateSnapshot({
|
||||
required this.templateId,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final int templateId;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
ApprovalTemplateSnapshot copyWith({int? templateId, DateTime? updatedAt}) {
|
||||
return ApprovalTemplateSnapshot(
|
||||
templateId: templateId ?? this.templateId,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 요청 상태를 관리하고 검증/전송 모델로 변환하는 컨트롤러.
|
||||
///
|
||||
/// - 최대 98단계까지 결재 단계를 추가할 수 있으며, 승인자 중복을 방지한다.
|
||||
/// - 마지막 단계 승인자가 최종 승인자로 자동 바인딩된다.
|
||||
/// - 템플릿을 적용/변경할 때 버전 정보를 기록해 추후 비교에 활용한다.
|
||||
class ApprovalRequestController extends ChangeNotifier {
|
||||
ApprovalRequestController({
|
||||
int maxSteps = 98,
|
||||
ApprovalTemplateRepository? templateRepository,
|
||||
SaveApprovalTemplateUseCase? saveTemplateUseCase,
|
||||
ApplyApprovalTemplateUseCase? applyTemplateUseCase,
|
||||
}) : assert(maxSteps > 0, 'maxSteps는 1 이상이어야 합니다.'),
|
||||
_maxSteps = maxSteps,
|
||||
_templateRepository = templateRepository,
|
||||
_saveTemplateUseCase = saveTemplateUseCase,
|
||||
_applyTemplateUseCase = applyTemplateUseCase;
|
||||
|
||||
static const int defaultMaxSteps = 98;
|
||||
|
||||
final int _maxSteps;
|
||||
final ApprovalTemplateRepository? _templateRepository;
|
||||
final SaveApprovalTemplateUseCase? _saveTemplateUseCase;
|
||||
final ApplyApprovalTemplateUseCase? _applyTemplateUseCase;
|
||||
|
||||
ApprovalRequestParticipant? _requester;
|
||||
final List<ApprovalRequestStep> _steps = <ApprovalRequestStep>[];
|
||||
ApprovalTemplateSnapshot? _templateSnapshot;
|
||||
bool _isDirty = false;
|
||||
String? _errorMessage;
|
||||
bool _isApplyingTemplate = false;
|
||||
|
||||
ApprovalRequestParticipant? get requester => _requester;
|
||||
List<ApprovalRequestStep> get steps => List.unmodifiable(_steps);
|
||||
int get maxSteps => _maxSteps;
|
||||
bool get hasReachedStepLimit => _steps.length >= _maxSteps;
|
||||
bool get hasDuplicateApprover =>
|
||||
_steps.map((step) => step.approverId).toSet().length != _steps.length;
|
||||
bool get hasRequesterConflict {
|
||||
final requester = _requester;
|
||||
if (requester == null) {
|
||||
return false;
|
||||
}
|
||||
return _steps.any((step) => step.approverId == requester.id);
|
||||
}
|
||||
|
||||
int get totalSteps => _steps.length;
|
||||
String? get errorMessage => _errorMessage;
|
||||
bool get isDirty => _isDirty;
|
||||
bool get isApplyingTemplate => _isApplyingTemplate;
|
||||
ApprovalTemplateSnapshot? get templateSnapshot => _templateSnapshot;
|
||||
|
||||
ApprovalRequestParticipant? get finalApprover {
|
||||
if (_steps.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return _steps.last.approver;
|
||||
}
|
||||
|
||||
int? get finalApproverId => finalApprover?.id;
|
||||
|
||||
/// 상신자를 설정한다.
|
||||
void setRequester(ApprovalRequestParticipant? participant) {
|
||||
if (_requester == participant) {
|
||||
return;
|
||||
}
|
||||
_requester = participant;
|
||||
if (participant != null &&
|
||||
_steps.any((step) => step.approverId == participant.id)) {
|
||||
_markDirty();
|
||||
_setError('상신자는 승인자로 지정할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
_isDirty = true;
|
||||
_clearError();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 결재 단계를 추가한다.
|
||||
///
|
||||
/// - 최대 단계 수를 초과하거나 중복 승인자를 추가하면 false를 반환한다.
|
||||
bool addStep({required ApprovalRequestParticipant approver, String? note}) {
|
||||
if (hasReachedStepLimit) {
|
||||
_setError('결재 단계는 최대 $_maxSteps개까지 추가할 수 있습니다.');
|
||||
return false;
|
||||
}
|
||||
final duplicated = _steps.any((step) => step.approverId == approver.id);
|
||||
if (_conflictsWithRequester(approver)) {
|
||||
_setError('상신자는 승인자로 지정할 수 없습니다.');
|
||||
return false;
|
||||
}
|
||||
if (duplicated) {
|
||||
_setError('동일한 승인자는 한 번만 추가할 수 있습니다.');
|
||||
return false;
|
||||
}
|
||||
_clearError();
|
||||
final step = ApprovalRequestStep(
|
||||
stepOrder: _steps.length + 1,
|
||||
approver: approver,
|
||||
note: note,
|
||||
);
|
||||
_steps.add(step);
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 지정된 위치의 결재 단계를 제거한다.
|
||||
void removeStepAt(int index) {
|
||||
if (index < 0 || index >= _steps.length) {
|
||||
return;
|
||||
}
|
||||
_clearError();
|
||||
_steps.removeAt(index);
|
||||
_reassignStepOrders();
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 결재 단계의 순서를 이동한다.
|
||||
void moveStep(int oldIndex, int newIndex) {
|
||||
if (oldIndex < 0 ||
|
||||
oldIndex >= _steps.length ||
|
||||
newIndex < 0 ||
|
||||
newIndex >= _steps.length ||
|
||||
oldIndex == newIndex) {
|
||||
return;
|
||||
}
|
||||
_clearError();
|
||||
final step = _steps.removeAt(oldIndex);
|
||||
_steps.insert(newIndex, step);
|
||||
_reassignStepOrders();
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 결재 단계를 수정한다.
|
||||
///
|
||||
/// - 승인자를 변경할 경우 중복 여부를 검사한다.
|
||||
bool updateStep(
|
||||
int index, {
|
||||
ApprovalRequestParticipant? approver,
|
||||
String? note,
|
||||
}) {
|
||||
if (index < 0 || index >= _steps.length) {
|
||||
return false;
|
||||
}
|
||||
final current = _steps[index];
|
||||
final nextApprover = approver ?? current.approver;
|
||||
final duplicated =
|
||||
approver != null &&
|
||||
_steps.asMap().entries.any(
|
||||
(entry) =>
|
||||
entry.key != index && entry.value.approverId == nextApprover.id,
|
||||
);
|
||||
if (_conflictsWithRequester(nextApprover)) {
|
||||
_setError('상신자는 승인자로 지정할 수 없습니다.');
|
||||
return false;
|
||||
}
|
||||
if (duplicated) {
|
||||
_setError('동일한 승인자는 한 번만 추가할 수 있습니다.');
|
||||
return false;
|
||||
}
|
||||
_clearError();
|
||||
_steps[index] = current.copyWith(approver: approver, note: note);
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 최종 승인자를 지정한다.
|
||||
///
|
||||
/// - 단계가 없으면 새로운 마지막 단계를 추가한다.
|
||||
/// - 이미 존재하는 경우 마지막 단계만 해당 승인자로 교체한다.
|
||||
bool setFinalApprover(ApprovalRequestParticipant approver, {String? note}) {
|
||||
if (_conflictsWithRequester(approver)) {
|
||||
_setError('최종 승인자는 상신자와 다른 사람이어야 합니다.');
|
||||
return false;
|
||||
}
|
||||
if (_steps.isEmpty) {
|
||||
return addStep(approver: approver, note: note);
|
||||
}
|
||||
final duplicateOtherIndex = _steps
|
||||
.sublist(0, _steps.length - 1)
|
||||
.any((step) => step.approverId == approver.id);
|
||||
if (duplicateOtherIndex) {
|
||||
_setError('최종 승인자는 다른 단계와 중복될 수 없습니다.');
|
||||
return false;
|
||||
}
|
||||
final lastIndex = _steps.length - 1;
|
||||
final last = _steps[lastIndex];
|
||||
_steps[lastIndex] = last.copyWith(
|
||||
approver: approver,
|
||||
note: note ?? last.note,
|
||||
);
|
||||
_clearError();
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 템플릿 단계를 그대로 적용한다.
|
||||
void applyTemplateSteps(List<ApprovalRequestStep> steps) {
|
||||
if (_requester != null &&
|
||||
steps.any((step) => step.approverId == _requester!.id)) {
|
||||
_markDirty();
|
||||
_setError('상신자는 승인자로 지정할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
_steps
|
||||
..clear()
|
||||
..addAll(steps);
|
||||
_reassignStepOrders();
|
||||
_clearError();
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 템플릿 스냅샷을 기록한다.
|
||||
void setTemplateSnapshot(ApprovalTemplateSnapshot? snapshot) {
|
||||
_templateSnapshot = snapshot;
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 템플릿 버전이 최신인지 간단히 확인한다.
|
||||
bool isTemplateUpToDate(DateTime? serverUpdatedAt) {
|
||||
final snapshot = _templateSnapshot;
|
||||
if (snapshot == null) {
|
||||
return true;
|
||||
}
|
||||
if (snapshot.updatedAt == null || serverUpdatedAt == null) {
|
||||
return true;
|
||||
}
|
||||
return !snapshot.updatedAt!.isBefore(serverUpdatedAt);
|
||||
}
|
||||
|
||||
/// 현재 상태로부터 결재 상신 입력 모델을 생성한다.
|
||||
ApprovalSubmissionInput buildSubmissionInput({
|
||||
int? transactionId,
|
||||
int? templateId,
|
||||
required int statusId,
|
||||
DateTime? requestedAt,
|
||||
DateTime? decidedAt,
|
||||
DateTime? cancelledAt,
|
||||
DateTime? lastActionAt,
|
||||
String? title,
|
||||
String? summary,
|
||||
String? note,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) {
|
||||
final requester = _ensureRequester();
|
||||
final steps = _ensureSteps();
|
||||
return ApprovalSubmissionInput(
|
||||
transactionId: transactionId,
|
||||
templateId: templateId ?? _templateSnapshot?.templateId,
|
||||
statusId: statusId,
|
||||
requesterId: requester.id,
|
||||
finalApproverId: steps.isEmpty ? null : steps.last.approverId,
|
||||
requestedAt: requestedAt,
|
||||
decidedAt: decidedAt,
|
||||
cancelledAt: cancelledAt,
|
||||
lastActionAt: lastActionAt,
|
||||
title: title,
|
||||
summary: summary,
|
||||
note: note,
|
||||
metadata: metadata,
|
||||
steps: steps.map((step) => step.toAssignmentItem()).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 재고 전표 결재 입력 모델로 변환한다.
|
||||
StockTransactionApprovalInput buildTransactionApprovalInput({
|
||||
int? approvalStatusId,
|
||||
int? templateId,
|
||||
DateTime? requestedAt,
|
||||
DateTime? decidedAt,
|
||||
DateTime? cancelledAt,
|
||||
DateTime? lastActionAt,
|
||||
String? title,
|
||||
String? summary,
|
||||
String? note,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) {
|
||||
final requester = _ensureRequester();
|
||||
final steps = _ensureSteps();
|
||||
return StockTransactionApprovalInput(
|
||||
requestedById: requester.id,
|
||||
approvalStatusId: approvalStatusId,
|
||||
templateId: templateId ?? _templateSnapshot?.templateId,
|
||||
finalApproverId: steps.isEmpty ? null : steps.last.approverId,
|
||||
requestedAt: requestedAt,
|
||||
decidedAt: decidedAt,
|
||||
cancelledAt: cancelledAt,
|
||||
lastActionAt: lastActionAt,
|
||||
title: title,
|
||||
summary: summary,
|
||||
note: note,
|
||||
metadata: metadata,
|
||||
steps: steps.map((step) => step.toAssignmentItem()).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 현재 상태를 초기화한다.
|
||||
void clear() {
|
||||
_requester = null;
|
||||
_steps.clear();
|
||||
_templateSnapshot = null;
|
||||
_errorMessage = null;
|
||||
_isDirty = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 템플릿을 저장 후 상태를 갱신한다.
|
||||
///
|
||||
/// 외부에서 저장 유즈케이스를 주입한 경우에만 동작한다.
|
||||
Future<ApprovalTemplate?> saveTemplate({
|
||||
int? templateId,
|
||||
required ApprovalTemplateInput input,
|
||||
List<ApprovalTemplateStepInput>? steps,
|
||||
}) async {
|
||||
final useCase = _saveTemplateUseCase;
|
||||
if (useCase == null) {
|
||||
throw StateError('SaveApprovalTemplateUseCase가 주입되지 않았습니다.');
|
||||
}
|
||||
final template = await useCase.call(
|
||||
templateId: templateId,
|
||||
input: input,
|
||||
steps: steps,
|
||||
);
|
||||
_templateSnapshot = ApprovalTemplateSnapshot(
|
||||
templateId: template.id,
|
||||
updatedAt: template.updatedAt,
|
||||
);
|
||||
_clearError();
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
return template;
|
||||
}
|
||||
|
||||
/// 템플릿을 적용해 결재 단계를 갱신한다.
|
||||
///
|
||||
/// - 템플릿 저장소와 Apply 유즈케이스가 모두 주입된 경우에만 지원한다.
|
||||
Future<ApprovalFlow?> applyTemplate({
|
||||
required int approvalId,
|
||||
required int templateId,
|
||||
}) async {
|
||||
final repository = _templateRepository;
|
||||
final useCase = _applyTemplateUseCase;
|
||||
if (repository == null || useCase == null) {
|
||||
throw StateError('템플릿 적용을 위한 의존성이 주입되지 않았습니다.');
|
||||
}
|
||||
_isApplyingTemplate = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final template = await repository.fetchDetail(
|
||||
templateId,
|
||||
includeSteps: true,
|
||||
);
|
||||
if (template.steps.isEmpty) {
|
||||
throw StateError('단계가 없는 템플릿은 적용할 수 없습니다.');
|
||||
}
|
||||
final flow = await useCase.call(
|
||||
approvalId: approvalId,
|
||||
templateId: templateId,
|
||||
);
|
||||
_templateSnapshot = ApprovalTemplateSnapshot(
|
||||
templateId: template.id,
|
||||
updatedAt: template.updatedAt,
|
||||
);
|
||||
final steps = template.steps
|
||||
.map(
|
||||
(step) => ApprovalRequestStep(
|
||||
stepOrder: step.stepOrder,
|
||||
approver: ApprovalRequestParticipant(
|
||||
id: step.approver.id,
|
||||
name: step.approver.name,
|
||||
employeeNo: step.approver.employeeNo,
|
||||
),
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
applyTemplateSteps(steps);
|
||||
return flow;
|
||||
} finally {
|
||||
_isApplyingTemplate = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _setError(String message) {
|
||||
_errorMessage = message;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _clearError() {
|
||||
_errorMessage = null;
|
||||
}
|
||||
|
||||
void _reassignStepOrders() {
|
||||
for (var index = 0; index < _steps.length; index++) {
|
||||
final current = _steps[index];
|
||||
_steps[index] = current.copyWith(stepOrder: index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
List<ApprovalRequestStep> _ensureSteps() {
|
||||
if (_steps.isEmpty) {
|
||||
throw StateError('최소 한 개 이상의 결재 단계를 추가해야 합니다.');
|
||||
}
|
||||
if (hasDuplicateApprover) {
|
||||
throw StateError('동일한 승인자가 중복되어 있습니다.');
|
||||
}
|
||||
final requester = _requester;
|
||||
if (requester != null) {
|
||||
for (var index = 0; index < _steps.length; index++) {
|
||||
final step = _steps[index];
|
||||
if (step.approverId != requester.id) {
|
||||
continue;
|
||||
}
|
||||
if (index == _steps.length - 1) {
|
||||
throw StateError('최종 승인자는 상신자와 다른 사람이어야 합니다.');
|
||||
}
|
||||
throw StateError('상신자는 승인자로 지정할 수 없습니다.');
|
||||
}
|
||||
}
|
||||
return List<ApprovalRequestStep>.unmodifiable(_steps);
|
||||
}
|
||||
|
||||
ApprovalRequestParticipant _ensureRequester() {
|
||||
final requester = _requester;
|
||||
if (requester == null) {
|
||||
throw StateError('상신자를 선택해야 합니다.');
|
||||
}
|
||||
return requester;
|
||||
}
|
||||
|
||||
void _markDirty() {
|
||||
_isDirty = true;
|
||||
}
|
||||
|
||||
bool _conflictsWithRequester(ApprovalRequestParticipant participant) {
|
||||
final requester = _requester;
|
||||
if (requester == null) {
|
||||
return false;
|
||||
}
|
||||
return requester.id == participant.id;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user