- 환경/라우터 모듈에 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
287 lines
7.6 KiB
Dart
287 lines
7.6 KiB
Dart
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;
|
|
}
|