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

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