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

@@ -0,0 +1,156 @@
import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/approval.dart';
import 'approval_step_dto.dart';
/// 결재 감사 로그(Audit) DTO.
class ApprovalAuditDto {
ApprovalAuditDto({
this.id,
required this.action,
this.fromStatus,
required this.toStatus,
required this.actor,
required this.actionAt,
this.note,
this.actionCode,
this.payload,
});
final int? id;
final ApprovalActionDto action;
final ApprovalStatusDto? fromStatus;
final ApprovalStatusDto toStatus;
final ApprovalApproverDto actor;
final DateTime actionAt;
final String? note;
final String? actionCode;
final Map<String, dynamic>? payload;
factory ApprovalAuditDto.fromJson(Map<String, dynamic> json) {
final actionMap = {
...?_asMap(json['action']),
...?_asMap(json['approval_action']),
};
final fallbackActionId = json['approval_action_id'] ?? json['action_id'];
if (fallbackActionId != null) {
actionMap.putIfAbsent('id', () => fallbackActionId);
}
final fallbackActionName = _firstNonEmpty(<String?>[
_readString(json, 'action_name'),
_readString(json, 'approval_action_name'),
]);
if (fallbackActionName != null) {
actionMap.putIfAbsent('name', () => fallbackActionName);
}
final rootActionCode = _firstNonEmpty(<String?>[
_readString(json, 'action_code'),
_readString(json, 'approval_action_code'),
]);
if (rootActionCode != null) {
actionMap.putIfAbsent('code', () => rootActionCode);
actionMap.putIfAbsent('action_code', () => rootActionCode);
}
final actionDto = ApprovalActionDto.fromJson(actionMap);
final fromStatusMap = _asMap(json['from_status']);
final toStatusMap = _asMap(json['to_status']) ?? const <String, dynamic>{};
final actorMap =
_asMap(json['actor']) ??
_asMap(json['approver']) ??
const <String, dynamic>{};
final resolvedActionCode = rootActionCode ?? actionDto.code;
return ApprovalAuditDto(
id: JsonUtils.readInt(json, 'id'),
action: actionDto,
fromStatus: fromStatusMap == null
? null
: ApprovalStatusDto.fromJson(fromStatusMap),
toStatus: ApprovalStatusDto.fromJson(toStatusMap),
actor: ApprovalApproverDto.fromJson(actorMap),
actionAt: _parseDate(json['action_at']) ?? DateTime.now(),
note: _readString(json, 'note'),
actionCode: resolvedActionCode,
payload: _asMap(
json['payload'],
)?.map((key, value) => MapEntry(key, value)),
);
}
ApprovalHistory toEntity() => ApprovalHistory(
id: id,
action: action.toEntity(),
fromStatus: fromStatus?.toEntity(),
toStatus: toStatus.toEntity(),
approver: actor.toEntity(),
actionAt: actionAt,
note: note,
actionCode: actionCode,
payload: payload,
);
}
/// 결재 감사 로그 액션 DTO.
class ApprovalActionDto {
ApprovalActionDto({required this.id, required this.name, this.code});
final int id;
final String name;
final String? code;
factory ApprovalActionDto.fromJson(Map<String, dynamic> json) {
if (json['action'] is Map<String, dynamic>) {
return ApprovalActionDto.fromJson(json['action'] as Map<String, dynamic>);
}
final id = JsonUtils.readInt(json, 'id', fallback: 0);
final name = _firstNonEmpty(<String?>[
_readString(json, 'name'),
_readString(json, 'action_name'),
]);
if (name == null) {
throw const FormatException('결재 감사 로그 액션 이름이 누락되었습니다.');
}
final code = _firstNonEmpty(<String?>[
_readString(json, 'code'),
_readString(json, 'action_code'),
]);
return ApprovalActionDto(id: id, name: name, code: code);
}
ApprovalAction toEntity() => ApprovalAction(id: id, name: name, code: code);
}
Map<String, dynamic>? _asMap(dynamic value) =>
value is Map<String, dynamic> ? value : null;
String? _readString(
Map<String, dynamic>? source,
String key, {
String? fallback,
}) {
if (source == null) return fallback;
final value = source[key];
if (value is String) return value;
if (value == null) return fallback;
return value.toString();
}
DateTime? _parseDate(Object? value) {
if (value == null) return null;
if (value is DateTime) return value;
if (value is String) return DateTime.tryParse(value);
return null;
}
String? _firstNonEmpty(Iterable<String?> values) {
for (final value in values) {
if (value == null) {
continue;
}
final trimmed = value.trim();
if (trimmed.isNotEmpty) {
return trimmed;
}
}
return null;
}

View File

@@ -0,0 +1,268 @@
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/approval_draft.dart';
/// 결재 초안 단계 DTO.
class ApprovalDraftStepDto {
ApprovalDraftStepDto({
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;
factory ApprovalDraftStepDto.fromJson(Map<String, dynamic> json) {
return ApprovalDraftStepDto(
stepOrder: JsonUtils.readInt(json, 'step_order', fallback: 0),
approverId: JsonUtils.readInt(json, 'approver_id', fallback: 0),
approverRole: _readString(json['approver_role']),
note: _readString(json['note']),
isOptional: json['is_optional'] is bool
? json['is_optional'] as bool
: false,
);
}
ApprovalDraftStep toEntity() => ApprovalDraftStep(
stepOrder: stepOrder,
approverId: approverId,
approverRole: approverRole,
note: note,
isOptional: isOptional,
);
}
/// 결재 초안 페이로드 DTO.
class ApprovalDraftPayloadDto {
ApprovalDraftPayloadDto({
this.title,
this.summary,
this.note,
this.templateId,
this.metadata,
this.steps = const [],
});
final String? title;
final String? summary;
final String? note;
final int? templateId;
final Map<String, dynamic>? metadata;
final List<ApprovalDraftStepDto> steps;
factory ApprovalDraftPayloadDto.fromJson(Map<String, dynamic> json) {
final steps = (json['steps'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(ApprovalDraftStepDto.fromJson)
.toList(growable: false);
final metadata = json['metadata'] is Map<String, dynamic>
? Map<String, dynamic>.from(json['metadata'] as Map)
: null;
return ApprovalDraftPayloadDto(
title: _readString(json['title']),
summary: _readString(json['summary']),
note: _readString(json['note']),
templateId: json['template_id'] as int?,
metadata: metadata,
steps: steps,
);
}
ApprovalDraftPayload toEntity() => ApprovalDraftPayload(
title: title,
summary: summary,
note: note,
templateId: templateId,
metadata: metadata,
steps: steps.map((step) => step.toEntity()).toList(growable: false),
);
}
/// 결재 초안 요약 DTO.
class ApprovalDraftSummaryDto {
ApprovalDraftSummaryDto({
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;
factory ApprovalDraftSummaryDto.fromJson(Map<String, dynamic> json) {
final savedAtRaw = json['saved_at'];
final expiresAtRaw = json['expires_at'];
return ApprovalDraftSummaryDto(
id: JsonUtils.readInt(json, 'id', fallback: 0),
requesterId: JsonUtils.readInt(json, 'requester_id', fallback: 0),
status: _parseStatus(_readString(json['status'])),
savedAt: _parseDate(savedAtRaw) ?? DateTime.now().toUtc(),
requestId: json['request_id'] as int?,
transactionId: json['transaction_id'] as int?,
templateId: json['template_id'] as int?,
title: _readString(json['title']),
summary: _readString(json['summary']),
expiresAt: _parseDate(expiresAtRaw),
sessionKey: _readString(json['session_key']),
stepCount: JsonUtils.readInt(json, 'step_count', fallback: 0),
);
}
ApprovalDraftSummary toEntity() => ApprovalDraftSummary(
id: id,
requesterId: requesterId,
status: status,
savedAt: savedAt,
requestId: requestId,
transactionId: transactionId,
templateId: templateId,
title: title,
summary: summary,
expiresAt: expiresAt,
sessionKey: sessionKey,
stepCount: stepCount,
);
}
/// 결재 초안 상세 DTO.
class ApprovalDraftDetailDto {
ApprovalDraftDetailDto({
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 ApprovalDraftPayloadDto payload;
final DateTime savedAt;
final int? transactionId;
final int? templateId;
final DateTime? expiresAt;
final String? sessionKey;
factory ApprovalDraftDetailDto.fromJson(Map<String, dynamic> json) {
final payloadMap = json['payload'] is Map<String, dynamic>
? json['payload'] as Map<String, dynamic>
: const <String, dynamic>{};
return ApprovalDraftDetailDto(
id: JsonUtils.readInt(json, 'id', fallback: 0),
requesterId: JsonUtils.readInt(json, 'requester_id', fallback: 0),
payload: ApprovalDraftPayloadDto.fromJson(payloadMap),
savedAt: _parseDate(json['saved_at']) ?? DateTime.now().toUtc(),
transactionId: json['transaction_id'] as int?,
templateId: json['template_id'] as int?,
expiresAt: _parseDate(json['expires_at']),
sessionKey: _readString(json['session_key']),
);
}
ApprovalDraftDetail toEntity() => ApprovalDraftDetail(
id: id,
requesterId: requesterId,
payload: payload.toEntity(),
savedAt: savedAt,
transactionId: transactionId,
templateId: templateId,
expiresAt: expiresAt,
sessionKey: sessionKey,
);
}
class ApprovalDraftDto {
ApprovalDraftDto._();
static PaginatedResult<ApprovalDraftSummary> parsePaginated(
Map<String, dynamic>? json,
) {
final items = JsonUtils.extractList(json)
.map(ApprovalDraftSummaryDto.fromJson)
.map((dto) => dto.toEntity())
.toList(growable: false);
return PaginatedResult<ApprovalDraftSummary>(
items: items,
page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: JsonUtils.readInt(json, 'total', fallback: items.length),
);
}
static ApprovalDraftDetail? parseDetail(Map<String, dynamic>? json) {
if (json == null || json.isEmpty) {
return null;
}
final map = JsonUtils.extractMap(json);
if (map.isEmpty) {
return null;
}
return ApprovalDraftDetailDto.fromJson(map).toEntity();
}
}
String? _readString(dynamic value) {
if (value == null) {
return null;
}
if (value is String) {
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
return value.toString();
}
DateTime? _parseDate(dynamic value) {
if (value == null) {
return null;
}
if (value is DateTime) {
return value.toUtc();
}
if (value is String && value.isNotEmpty) {
return DateTime.tryParse(value)?.toUtc();
}
return null;
}
ApprovalDraftStatus _parseStatus(String? value) {
switch (value) {
case 'expired':
return ApprovalDraftStatus.expired;
case 'archived':
return ApprovalDraftStatus.archived;
case 'active':
default:
return ApprovalDraftStatus.active;
}
}

View File

@@ -2,6 +2,8 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/approval.dart';
import 'approval_audit_dto.dart';
import 'approval_step_dto.dart';
/// 결재 API 응답을 표현하는 DTO.
///
@@ -11,7 +13,9 @@ class ApprovalDto {
ApprovalDto({
this.id,
required this.approvalNo,
this.transactionId,
this.transactionNo,
this.transactionUpdatedAt,
required this.status,
this.currentStep,
required this.requester,
@@ -28,7 +32,9 @@ class ApprovalDto {
final int? id;
final String approvalNo;
final int? transactionId;
final String? transactionNo;
final DateTime? transactionUpdatedAt;
final ApprovalStatusDto status;
final ApprovalStepDto? currentStep;
final ApprovalRequesterDto requester;
@@ -38,7 +44,7 @@ class ApprovalDto {
final bool isActive;
final bool isDeleted;
final List<ApprovalStepDto> steps;
final List<ApprovalHistoryDto> histories;
final List<ApprovalAuditDto> histories;
final DateTime? createdAt;
final DateTime? updatedAt;
@@ -51,7 +57,7 @@ class ApprovalDto {
approvalEnvelope['status'],
approvalEnvelope['approval_status'],
]);
final requesterMap = _firstNonEmptyMap([
final rawRequesterMap = _firstNonEmptyMap([
json['requester'],
json['requested_by'],
approvalEnvelope['requester'],
@@ -86,14 +92,29 @@ class ApprovalDto {
[json, transactionMap, approvalEnvelope, envelopeTransactionMap],
const ['transaction_no', 'transactionNo'],
);
final transactionId =
json['transaction_id'] as int? ??
approvalEnvelope['transaction_id'] as int? ??
transactionMap['id'] as int? ??
envelopeTransactionMap['id'] as int?;
final transactionUpdatedAt = _parseDate(
transactionMap['updated_at'] ??
envelopeTransactionMap['updated_at'] ??
json['transaction_updated_at'] ??
approvalEnvelope['transaction_updated_at'],
);
return ApprovalDto(
id: json['id'] as int? ?? approvalEnvelope['id'] as int?,
approvalNo: approvalNo,
transactionId: transactionId,
transactionNo: transactionNo,
transactionUpdatedAt: transactionUpdatedAt,
status: ApprovalStatusDto.fromJson(statusMap),
currentStep: currentStepDto,
requester: ApprovalRequesterDto.fromJson(requesterMap),
requester: ApprovalRequesterDto.fromJson(
_resolveRequesterMap(json, approvalEnvelope, rawRequesterMap),
),
requestedAt:
_parseDate(
json['requested_at'] ?? approvalEnvelope['requested_at'],
@@ -113,7 +134,7 @@ class ApprovalDto {
false,
steps: stepsSource.map(ApprovalStepDto.fromJson).toList(growable: false),
histories: historiesSource
.map(ApprovalHistoryDto.fromJson)
.map(ApprovalAuditDto.fromJson)
.toList(growable: false),
createdAt: _parseDate(
json['created_at'] ?? approvalEnvelope['created_at'],
@@ -128,7 +149,9 @@ class ApprovalDto {
Approval toEntity() => Approval(
id: id,
approvalNo: approvalNo,
transactionId: transactionId,
transactionNo: transactionNo ?? '-',
transactionUpdatedAt: transactionUpdatedAt,
status: status.toEntity(),
currentStep: currentStep?.toEntity(),
requester: requester.toEntity(),
@@ -159,38 +182,6 @@ class ApprovalDto {
}
}
/// 결재 상태(Status) DTO.
class ApprovalStatusDto {
ApprovalStatusDto({required this.id, required this.name, this.color});
final int id;
final String name;
final String? color;
factory ApprovalStatusDto.fromJson(Map<String, dynamic> json) {
if (json['status'] is Map<String, dynamic>) {
return ApprovalStatusDto.fromJson(json['status'] as Map<String, dynamic>);
}
return ApprovalStatusDto(
id:
json['id'] as int? ??
json['status_id'] as int? ??
json['approval_status_id'] as int? ??
0,
name:
_readString(json['name']) ??
_readString(json['status_name']) ??
_readString(json['approval_status_name']) ??
_readString(json['status']) ??
'-',
color: _readString(json['color']),
);
}
/// DTO를 [ApprovalStatus]로 변환한다.
ApprovalStatus toEntity() => ApprovalStatus(id: id, name: name, color: color);
}
/// 결재 요청자 DTO.
class ApprovalRequesterDto {
ApprovalRequesterDto({
@@ -205,8 +196,11 @@ class ApprovalRequesterDto {
factory ApprovalRequesterDto.fromJson(Map<String, dynamic> json) {
return ApprovalRequesterDto(
id: json['id'] as int? ?? json['employee_id'] as int? ?? 0,
employeeNo: _readString(json['employee_no']) ?? '-',
id: JsonUtils.readInt(json, 'id', fallback: 0),
employeeNo:
_readString(json['employee_no']) ??
_readString(json['employee_id']) ??
'-',
name:
_readString(json['name']) ??
_readString(json['employee_name']) ??
@@ -219,194 +213,6 @@ class ApprovalRequesterDto {
ApprovalRequester(id: id, employeeNo: employeeNo, name: name);
}
/// 결재 승인자 DTO.
class ApprovalApproverDto {
ApprovalApproverDto({
required this.id,
required this.employeeNo,
required this.name,
});
final int id;
final String employeeNo;
final String name;
factory ApprovalApproverDto.fromJson(Map<String, dynamic> json) {
return ApprovalApproverDto(
id: json['id'] as int? ?? json['approver_id'] as int? ?? 0,
employeeNo: _readString(json['employee_no']) ?? '-',
name:
_readString(json['name']) ??
_readString(json['employee_name']) ??
'-',
);
}
/// DTO를 [ApprovalApprover]로 변환한다.
ApprovalApprover toEntity() =>
ApprovalApprover(id: id, employeeNo: employeeNo, name: name);
}
/// 결재 단계 DTO.
class ApprovalStepDto {
ApprovalStepDto({
this.id,
required this.stepOrder,
required this.approver,
required this.status,
required this.assignedAt,
this.decidedAt,
this.note,
this.isDeleted = false,
});
final int? id;
final int stepOrder;
final ApprovalApproverDto approver;
final ApprovalStatusDto status;
final DateTime assignedAt;
final DateTime? decidedAt;
final String? note;
final bool isDeleted;
factory ApprovalStepDto.fromJson(Map<String, dynamic> json) {
return ApprovalStepDto(
id: json['id'] as int?,
stepOrder: json['step_order'] as int? ?? 0,
approver: ApprovalApproverDto.fromJson(
(json['approver'] as Map<String, dynamic>? ?? const {}),
),
status: ApprovalStatusDto.fromJson(
(json['status'] as Map<String, dynamic>? ??
json['step_status'] as Map<String, dynamic>? ??
json['approval_status'] as Map<String, dynamic>? ??
const {}),
),
assignedAt: _parseDate(json['assigned_at']) ?? DateTime.now(),
decidedAt: _parseDate(json['decided_at']),
note: _readString(json['note']),
isDeleted:
json['is_deleted'] as bool? ??
(json['deleted_at'] != null ||
(json['is_active'] is bool && !(json['is_active'] as bool))),
);
}
/// DTO를 [ApprovalStep]으로 변환한다.
ApprovalStep toEntity() => ApprovalStep(
id: id,
stepOrder: stepOrder,
approver: approver.toEntity(),
status: status.toEntity(),
assignedAt: assignedAt,
decidedAt: decidedAt,
note: note,
isDeleted: isDeleted,
);
}
/// 결재 이력 DTO.
class ApprovalHistoryDto {
ApprovalHistoryDto({
this.id,
required this.action,
this.fromStatus,
required this.toStatus,
required this.approver,
required this.actionAt,
this.note,
});
final int? id;
final ApprovalActionDto action;
final ApprovalStatusDto? fromStatus;
final ApprovalStatusDto toStatus;
final ApprovalApproverDto approver;
final DateTime actionAt;
final String? note;
factory ApprovalHistoryDto.fromJson(Map<String, dynamic> json) {
final actionMap = _firstNonEmptyMap([
json['action'],
json['approval_action'],
json['step_action'],
]);
final fromStatusMap = _firstNonEmptyMap([
json['from_status'],
json['fromStatus'],
]);
final toStatusMap = _firstNonEmptyMap([
json['to_status'],
json['toStatus'],
]);
final approverMap = _firstNonEmptyMap([json['approver'], json['employee']]);
final fallbackAction = {
'id': json['approval_action_id'] ?? json['action_id'],
'name':
_readString(json['approval_action_name']) ??
_readString(json['action_name']) ??
_readString(json['action']) ??
'-',
};
return ApprovalHistoryDto(
id: json['id'] as int?,
action: ApprovalActionDto.fromJson(
actionMap.isEmpty ? fallbackAction : actionMap,
),
fromStatus: fromStatusMap.isEmpty
? null
: ApprovalStatusDto.fromJson(fromStatusMap),
toStatus: ApprovalStatusDto.fromJson(toStatusMap),
approver: ApprovalApproverDto.fromJson(approverMap),
actionAt:
_parseDate(json['action_at'] ?? json['actionAt']) ?? DateTime.now(),
note: _readString(json['note']),
);
}
/// DTO를 [ApprovalHistory]로 변환한다.
ApprovalHistory toEntity() => ApprovalHistory(
id: id,
action: action.toEntity(),
fromStatus: fromStatus?.toEntity(),
toStatus: toStatus.toEntity(),
approver: approver.toEntity(),
actionAt: actionAt,
note: note,
);
}
/// 결재 행위(Action) DTO.
class ApprovalActionDto {
ApprovalActionDto({required this.id, required this.name});
final int id;
final String name;
factory ApprovalActionDto.fromJson(Map<String, dynamic> json) {
if (json['action'] is Map<String, dynamic>) {
return ApprovalActionDto.fromJson(json['action'] as Map<String, dynamic>);
}
return ApprovalActionDto(
id:
json['id'] as int? ??
json['action_id'] as int? ??
json['approval_action_id'] as int? ??
0,
name:
_readString(json['name']) ??
_readString(json['action_name']) ??
_readString(json['approval_action_name']) ??
_readString(json['action']) ??
'-',
);
}
/// DTO를 [ApprovalAction]으로 변환한다.
ApprovalAction toEntity() => ApprovalAction(id: id, name: name);
}
List<Map<String, dynamic>> _asListOfMap(dynamic value) {
if (value is List) {
return value.whereType<Map<String, dynamic>>().toList(growable: false);
@@ -426,6 +232,60 @@ Map<String, dynamic> _firstNonEmptyMap(List<dynamic> candidates) {
return const {};
}
Map<String, dynamic> _resolveRequesterMap(
Map<String, dynamic> root,
Map<String, dynamic> envelope,
Map<String, dynamic> candidate,
) {
if (candidate.isNotEmpty) {
return candidate;
}
final resolved = <String, dynamic>{};
final rootRequestedBy = _mapOrEmpty(root['requested_by']);
if (rootRequestedBy.isNotEmpty) {
resolved.addAll(rootRequestedBy);
}
final envelopeRequestedBy = _mapOrEmpty(envelope['requested_by']);
if (resolved.isEmpty && envelopeRequestedBy.isNotEmpty) {
resolved.addAll(envelopeRequestedBy);
} else if (envelopeRequestedBy.isNotEmpty) {
for (final entry in envelopeRequestedBy.entries) {
resolved.putIfAbsent(entry.key, () => entry.value);
}
}
final fallbackId = _pickInt(
[resolved, rootRequestedBy, envelopeRequestedBy, root, envelope],
const ['requester_id', 'requested_by_id', 'id'],
);
if (fallbackId != null) {
resolved['id'] = fallbackId;
}
final fallbackEmployeeNo = _pickString(
[resolved, rootRequestedBy, envelopeRequestedBy, root, envelope],
const [
'employee_no',
'employee_id',
'requester_employee_no',
'requested_by_employee_no',
],
);
if (fallbackEmployeeNo != null) {
resolved['employee_no'] = fallbackEmployeeNo;
}
final fallbackName = _pickString(
[resolved, rootRequestedBy, envelopeRequestedBy, root, envelope],
const ['name', 'employee_name', 'requester_name', 'requested_by_name'],
);
if (fallbackName != null) {
resolved['name'] = fallbackName;
}
return resolved;
}
String? _pickString(List<dynamic> sources, List<String> keys) {
for (final source in sources) {
if (source is Map<String, dynamic>) {
@@ -440,6 +300,29 @@ String? _pickString(List<dynamic> sources, List<String> keys) {
return null;
}
int? _pickInt(List<dynamic> sources, List<String> keys) {
for (final source in sources) {
if (source is Map<String, dynamic>) {
for (final key in keys) {
final value = source[key];
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
if (value is String) {
final parsed = int.tryParse(value);
if (parsed != null) {
return parsed;
}
}
}
}
}
return null;
}
/// 문자열/DateTime 입력을 DateTime으로 변환한다.
DateTime? _parseDate(Object? value) {
if (value == null) return null;

View File

@@ -21,8 +21,8 @@ class ApprovalProceedStatusDto {
}
ApprovalProceedStatus toEntity() => ApprovalProceedStatus(
approvalId: approvalId,
canProceed: canProceed,
reason: reason,
);
approvalId: approvalId,
canProceed: canProceed,
reason: reason,
);
}

View File

@@ -0,0 +1,254 @@
import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/approval.dart';
import 'approval_audit_dto.dart';
/// 결재 상신(Submit) 요청 DTO.
class ApprovalSubmitRequestDto {
ApprovalSubmitRequestDto({required this.approval, required this.steps});
final ApprovalCreatePayloadDto approval;
final List<ApprovalStepInputDto> steps;
Map<String, dynamic> toJson() {
return {
'approval': approval.toJson(),
'steps': steps.map((e) => e.toJson()).toList(growable: false),
};
}
}
/// 결재 재상신 요청 DTO.
class ApprovalResubmitRequestDto {
ApprovalResubmitRequestDto({
required this.approvalId,
required this.actorId,
required this.steps,
this.note,
this.expectedUpdatedAt,
this.transactionExpectedUpdatedAt,
});
final int approvalId;
final int actorId;
final List<ApprovalStepInputDto> steps;
final String? note;
final DateTime? expectedUpdatedAt;
final DateTime? transactionExpectedUpdatedAt;
Map<String, dynamic> toJson() {
final sanitizedNote = note?.trim();
return {
'approval_id': approvalId,
'actor_id': actorId,
'steps': steps.map((e) => e.toJson()).toList(growable: false),
if (sanitizedNote != null && sanitizedNote.isNotEmpty)
'note': sanitizedNote,
if (expectedUpdatedAt != null)
'expected_updated_at': expectedUpdatedAt!.toUtc().toIso8601String(),
if (transactionExpectedUpdatedAt != null)
'transaction_expected_updated_at': transactionExpectedUpdatedAt!
.toUtc()
.toIso8601String(),
};
}
}
/// 결재 승인/반려 요청 DTO.
class ApprovalDecisionRequestDto {
ApprovalDecisionRequestDto({
required this.approvalId,
required this.actorId,
this.note,
this.expectedUpdatedAt,
});
final int approvalId;
final int actorId;
final String? note;
final DateTime? expectedUpdatedAt;
Map<String, dynamic> toJson() {
final sanitizedNote = note?.trim();
return {
'approval_id': approvalId,
'actor_id': actorId,
if (sanitizedNote != null && sanitizedNote.isNotEmpty)
'note': sanitizedNote,
if (expectedUpdatedAt != null)
'expected_updated_at': expectedUpdatedAt!.toUtc().toIso8601String(),
};
}
}
/// 결재 회수 요청 DTO.
class ApprovalRecallRequestDto {
ApprovalRecallRequestDto({
required this.approvalId,
required this.actorId,
this.note,
this.expectedUpdatedAt,
this.transactionExpectedUpdatedAt,
});
final int approvalId;
final int actorId;
final String? note;
final DateTime? expectedUpdatedAt;
final DateTime? transactionExpectedUpdatedAt;
Map<String, dynamic> toJson() {
final sanitizedNote = note?.trim();
return {
'approval_id': approvalId,
'actor_id': actorId,
if (sanitizedNote != null && sanitizedNote.isNotEmpty)
'note': sanitizedNote,
if (expectedUpdatedAt != null)
'expected_updated_at': expectedUpdatedAt!.toUtc().toIso8601String(),
if (transactionExpectedUpdatedAt != null)
'transaction_expected_updated_at': transactionExpectedUpdatedAt!
.toUtc()
.toIso8601String(),
};
}
}
/// 결재 본문 생성 DTO.
class ApprovalCreatePayloadDto {
ApprovalCreatePayloadDto({
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,
});
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;
Map<String, dynamic> toJson() {
final sanitizedNote = note?.trim();
final sanitizedTitle = title?.trim();
final sanitizedSummary = summary?.trim();
return {
'transaction_id': transactionId,
'template_id': templateId,
'approval_status_id': statusId,
'requested_by_id': requesterId,
'final_approver_id': finalApproverId,
if (requestedAt != null)
'requested_at': requestedAt!.toUtc().toIso8601String(),
if (decidedAt != null) 'decided_at': decidedAt!.toUtc().toIso8601String(),
if (cancelledAt != null)
'cancelled_at': cancelledAt!.toUtc().toIso8601String(),
if (lastActionAt != null)
'last_action_at': lastActionAt!.toUtc().toIso8601String(),
if (sanitizedTitle != null && sanitizedTitle.isNotEmpty)
'title': sanitizedTitle,
if (sanitizedSummary != null && sanitizedSummary.isNotEmpty)
'summary': sanitizedSummary,
if (sanitizedNote != null && sanitizedNote.isNotEmpty)
'note': sanitizedNote,
if (metadata != null && metadata!.isNotEmpty) 'metadata': metadata,
};
}
factory ApprovalCreatePayloadDto.fromSubmission(
ApprovalSubmissionInput input,
) {
return ApprovalCreatePayloadDto(
transactionId: input.transactionId,
templateId: input.templateId,
statusId: input.statusId,
requesterId: input.requesterId,
finalApproverId: input.finalApproverId,
requestedAt: input.requestedAt,
decidedAt: input.decidedAt,
cancelledAt: input.cancelledAt,
lastActionAt: input.lastActionAt,
title: input.title,
summary: input.summary,
note: input.note,
metadata: input.metadata,
);
}
}
/// 결재 단계 생성 입력 DTO.
class ApprovalStepInputDto {
ApprovalStepInputDto({
required this.stepOrder,
required this.approverId,
this.note,
});
final int stepOrder;
final int approverId;
final String? note;
factory ApprovalStepInputDto.fromDomain(ApprovalStepAssignmentItem item) {
return ApprovalStepInputDto(
stepOrder: item.stepOrder,
approverId: item.approverId,
note: item.note,
);
}
Map<String, dynamic> toJson() {
final sanitizedNote = note?.trim();
return {
'step_order': stepOrder,
'approver_id': approverId,
if (sanitizedNote != null && sanitizedNote.isNotEmpty)
'note': sanitizedNote,
};
}
}
/// 감사 로그 리스트 응답 DTO.
class ApprovalAuditListDto {
ApprovalAuditListDto({
required this.items,
required this.page,
required this.pageSize,
required this.total,
});
final List<ApprovalAuditDto> items;
final int page;
final int pageSize;
final int total;
factory ApprovalAuditListDto.fromJson(Map<String, dynamic>? json) {
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
final items = rawItems
.map((item) => ApprovalAuditDto.fromJson(item))
.toList(growable: false);
return ApprovalAuditListDto(
items: items,
page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: JsonUtils.readInt(json, 'total', fallback: items.length),
);
}
}

View File

@@ -0,0 +1,207 @@
import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/approval.dart';
/// 결재 상태(summary) DTO.
class ApprovalStatusDto {
ApprovalStatusDto({
required this.id,
required this.name,
this.color,
bool? isBlockingNext,
bool? isTerminal,
}) : isBlockingNext = isBlockingNext ?? true,
isTerminal = isTerminal ?? false;
final int id;
final String name;
final String? color;
final bool isBlockingNext;
final bool isTerminal;
factory ApprovalStatusDto.fromJson(Map<String, dynamic> json) {
if (json['status'] is Map<String, dynamic>) {
return ApprovalStatusDto.fromJson(json['status'] as Map<String, dynamic>);
}
final resolvedName =
_readString(json, 'name') ??
_readString(json, 'status_name') ??
_readString(json, 'statusName') ??
'-';
final rawColor =
_readString(json, 'color') ??
_readString(json, 'status_color') ??
_readString(json, 'statusColor');
return ApprovalStatusDto(
id: JsonUtils.readInt(json, 'id', fallback: 0),
name: resolvedName,
color: rawColor,
isBlockingNext:
_readBool(json, 'is_blocking_next', fallback: true) ??
_readBool(json, 'isBlockingNext', fallback: true) ??
true,
isTerminal:
_readBool(json, 'is_terminal', fallback: false) ??
_readBool(json, 'isTerminal', fallback: false) ??
false,
);
}
ApprovalStatus toEntity() => ApprovalStatus(
id: id,
name: name,
color: color,
isBlockingNext: isBlockingNext,
isTerminal: isTerminal,
);
}
/// 결재 사용자 요약 DTO.
class ApprovalApproverDto {
ApprovalApproverDto({
required this.id,
required this.employeeNo,
required this.name,
});
final int id;
final String employeeNo;
final String name;
factory ApprovalApproverDto.fromJson(Map<String, dynamic> json) {
final employeeNo =
_readString(json, 'employee_no') ??
_readString(json, 'employee_id', fallback: '-');
return ApprovalApproverDto(
id: JsonUtils.readInt(json, 'id', fallback: 0),
employeeNo: employeeNo ?? '-',
name: _readString(json, 'name', fallback: '-') ?? '-',
);
}
ApprovalApprover toEntity() =>
ApprovalApprover(id: id, employeeNo: employeeNo, name: name);
}
/// 결재 단계(summary) DTO.
class ApprovalStepDto {
ApprovalStepDto({
this.id,
this.requestId,
required this.stepOrder,
this.templateStepId,
this.approverRole,
required this.approver,
required this.status,
this.assignedAt,
this.decidedAt,
this.actionAt,
this.note,
this.isDeleted = false,
this.isOptional = false,
this.escalationMinutes,
this.metadata,
});
final int? id;
final int? requestId;
final int stepOrder;
final int? templateStepId;
final String? approverRole;
final ApprovalApproverDto approver;
final ApprovalStatusDto status;
final DateTime? assignedAt;
final DateTime? decidedAt;
final DateTime? actionAt;
final String? note;
final bool isDeleted;
final bool isOptional;
final int? escalationMinutes;
final Map<String, dynamic>? metadata;
factory ApprovalStepDto.fromJson(Map<String, dynamic> json) {
final statusMap =
_asMap(json['status']) ?? _asMap(json['step_status']) ?? const {};
final approverMap = _asMap(json['approver']) ?? const {};
final assignedAt = _parseDate(json['assigned_at']);
final decidedAt = _parseDate(json['decided_at']);
final actionAt = _parseDate(json['action_at']);
return ApprovalStepDto(
id: JsonUtils.readInt(json, 'id'),
requestId: JsonUtils.readInt(json, 'request_id'),
stepOrder: JsonUtils.readInt(json, 'step_order', fallback: 0),
templateStepId: JsonUtils.readInt(json, 'template_step_id'),
approverRole: _readString(json, 'approver_role'),
approver: ApprovalApproverDto.fromJson(approverMap),
status: ApprovalStatusDto.fromJson(statusMap),
assignedAt: assignedAt,
decidedAt: decidedAt,
actionAt: actionAt,
note: _readString(json, 'note'),
isDeleted:
_readBool(json, 'is_deleted') ??
(json['deleted_at'] != null ||
(json['is_active'] is bool && !(json['is_active'] as bool))),
isOptional: _readBool(json, 'is_optional', fallback: false) ?? false,
escalationMinutes: JsonUtils.readInt(json, 'escalation_minutes'),
metadata: _asMap(
json['metadata'],
)?.map((key, value) => MapEntry(key, value)),
);
}
ApprovalStep toEntity() => ApprovalStep(
id: id,
requestId: requestId,
stepOrder: stepOrder,
templateStepId: templateStepId,
approverRole: approverRole,
approver: approver.toEntity(),
status: status.toEntity(),
assignedAt: assignedAt ?? DateTime.now(),
decidedAt: decidedAt,
actionAt: actionAt,
note: note,
isDeleted: isDeleted,
isOptional: isOptional,
escalationMinutes: escalationMinutes,
metadata: metadata,
);
}
String? _readString(
Map<String, dynamic>? source,
String key, {
String? fallback,
}) {
if (source == null) return fallback;
final value = source[key];
if (value is String) return value;
if (value == null) return fallback;
return value.toString();
}
bool? _readBool(Map<String, dynamic>? source, String key, {bool? fallback}) {
if (source == null) return fallback;
final value = source[key];
if (value is bool) return value;
if (value is num) return value != 0;
if (value is String) {
final normalized = value.trim().toLowerCase();
if (normalized.isEmpty) return fallback;
return ['1', 'y', 'yes', 'true'].contains(normalized);
}
return fallback;
}
Map<String, dynamic>? _asMap(dynamic value) =>
value is Map<String, dynamic> ? value : null;
DateTime? _parseDate(Object? value) {
if (value == null) return null;
if (value is DateTime) return value;
if (value is String) return DateTime.tryParse(value);
return null;
}