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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user