- 환경/라우터 모듈에 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
347 lines
10 KiB
Dart
347 lines
10 KiB
Dart
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.
|
|
///
|
|
/// - 원본 JSON 형식을 유지하면서 도메인 엔티티 변환을 제공한다.
|
|
/// - 일부 필드는 누락 가능성을 고려하여 기본값을 지정한다.
|
|
class ApprovalDto {
|
|
ApprovalDto({
|
|
this.id,
|
|
required this.approvalNo,
|
|
this.transactionId,
|
|
this.transactionNo,
|
|
this.transactionUpdatedAt,
|
|
required this.status,
|
|
this.currentStep,
|
|
required this.requester,
|
|
required this.requestedAt,
|
|
this.decidedAt,
|
|
this.note,
|
|
this.isActive = true,
|
|
this.isDeleted = false,
|
|
this.steps = const [],
|
|
this.histories = const [],
|
|
this.createdAt,
|
|
this.updatedAt,
|
|
});
|
|
|
|
final int? id;
|
|
final String approvalNo;
|
|
final int? transactionId;
|
|
final String? transactionNo;
|
|
final DateTime? transactionUpdatedAt;
|
|
final ApprovalStatusDto status;
|
|
final ApprovalStepDto? currentStep;
|
|
final ApprovalRequesterDto requester;
|
|
final DateTime requestedAt;
|
|
final DateTime? decidedAt;
|
|
final String? note;
|
|
final bool isActive;
|
|
final bool isDeleted;
|
|
final List<ApprovalStepDto> steps;
|
|
final List<ApprovalAuditDto> histories;
|
|
final DateTime? createdAt;
|
|
final DateTime? updatedAt;
|
|
|
|
/// API 응답 JSON을 [ApprovalDto]로 변환한다.
|
|
factory ApprovalDto.fromJson(Map<String, dynamic> json) {
|
|
final approvalEnvelope = _mapOrEmpty(json['approval']);
|
|
final statusMap = _firstNonEmptyMap([
|
|
json['status'],
|
|
json['approval_status'],
|
|
approvalEnvelope['status'],
|
|
approvalEnvelope['approval_status'],
|
|
]);
|
|
final rawRequesterMap = _firstNonEmptyMap([
|
|
json['requester'],
|
|
json['requested_by'],
|
|
approvalEnvelope['requester'],
|
|
approvalEnvelope['requested_by'],
|
|
]);
|
|
final currentStepMap = _firstNonEmptyMap([
|
|
json['current_step'],
|
|
json['currentStep'],
|
|
approvalEnvelope['current_step'],
|
|
]);
|
|
final transactionMap = _mapOrEmpty(json['transaction']);
|
|
final envelopeTransactionMap = _mapOrEmpty(approvalEnvelope['transaction']);
|
|
var stepsSource = _asListOfMap(json['steps']);
|
|
if (stepsSource.isEmpty) {
|
|
stepsSource = _asListOfMap(approvalEnvelope['steps']);
|
|
}
|
|
var historiesSource = _asListOfMap(json['histories']);
|
|
if (historiesSource.isEmpty) {
|
|
historiesSource = _asListOfMap(approvalEnvelope['histories']);
|
|
}
|
|
final currentStepDto = currentStepMap.isEmpty
|
|
? null
|
|
: ApprovalStepDto.fromJson(currentStepMap);
|
|
|
|
final approvalNo =
|
|
_pickString(
|
|
[json, approvalEnvelope],
|
|
const ['approval_no', 'approvalNo'],
|
|
) ??
|
|
'-';
|
|
final transactionNo = _pickString(
|
|
[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(
|
|
_resolveRequesterMap(json, approvalEnvelope, rawRequesterMap),
|
|
),
|
|
requestedAt:
|
|
_parseDate(
|
|
json['requested_at'] ?? approvalEnvelope['requested_at'],
|
|
) ??
|
|
DateTime.now(),
|
|
decidedAt: _parseDate(
|
|
json['decided_at'] ?? approvalEnvelope['decided_at'],
|
|
),
|
|
note: _readString(json['note']) ?? _readString(approvalEnvelope['note']),
|
|
isActive:
|
|
(json['is_active'] as bool?) ??
|
|
(approvalEnvelope['is_active'] as bool?) ??
|
|
true,
|
|
isDeleted:
|
|
(json['is_deleted'] as bool?) ??
|
|
(approvalEnvelope['is_deleted'] as bool?) ??
|
|
false,
|
|
steps: stepsSource.map(ApprovalStepDto.fromJson).toList(growable: false),
|
|
histories: historiesSource
|
|
.map(ApprovalAuditDto.fromJson)
|
|
.toList(growable: false),
|
|
createdAt: _parseDate(
|
|
json['created_at'] ?? approvalEnvelope['created_at'],
|
|
),
|
|
updatedAt: _parseDate(
|
|
json['updated_at'] ?? approvalEnvelope['updated_at'],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// DTO를 도메인 [Approval] 엔티티로 변환한다.
|
|
Approval toEntity() => Approval(
|
|
id: id,
|
|
approvalNo: approvalNo,
|
|
transactionId: transactionId,
|
|
transactionNo: transactionNo ?? '-',
|
|
transactionUpdatedAt: transactionUpdatedAt,
|
|
status: status.toEntity(),
|
|
currentStep: currentStep?.toEntity(),
|
|
requester: requester.toEntity(),
|
|
requestedAt: requestedAt,
|
|
decidedAt: decidedAt,
|
|
note: note,
|
|
isActive: isActive,
|
|
isDeleted: isDeleted,
|
|
steps: steps.map((e) => e.toEntity()).toList(),
|
|
histories: histories.map((e) => e.toEntity()).toList(),
|
|
createdAt: createdAt,
|
|
updatedAt: updatedAt,
|
|
);
|
|
|
|
/// 페이징 응답을 파싱해 [PaginatedResult]로 변환한다.
|
|
static PaginatedResult<Approval> parsePaginated(Map<String, dynamic>? json) {
|
|
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
|
final items = rawItems
|
|
.map(ApprovalDto.fromJson)
|
|
.map((dto) => dto.toEntity())
|
|
.toList(growable: false);
|
|
return PaginatedResult<Approval>(
|
|
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),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 결재 요청자 DTO.
|
|
class ApprovalRequesterDto {
|
|
ApprovalRequesterDto({
|
|
required this.id,
|
|
required this.employeeNo,
|
|
required this.name,
|
|
});
|
|
|
|
final int id;
|
|
final String employeeNo;
|
|
final String name;
|
|
|
|
factory ApprovalRequesterDto.fromJson(Map<String, dynamic> json) {
|
|
return ApprovalRequesterDto(
|
|
id: JsonUtils.readInt(json, 'id', fallback: 0),
|
|
employeeNo:
|
|
_readString(json['employee_no']) ??
|
|
_readString(json['employee_id']) ??
|
|
'-',
|
|
name:
|
|
_readString(json['name']) ??
|
|
_readString(json['employee_name']) ??
|
|
'-',
|
|
);
|
|
}
|
|
|
|
/// DTO를 [ApprovalRequester]로 변환한다.
|
|
ApprovalRequester toEntity() =>
|
|
ApprovalRequester(id: id, employeeNo: employeeNo, name: name);
|
|
}
|
|
|
|
List<Map<String, dynamic>> _asListOfMap(dynamic value) {
|
|
if (value is List) {
|
|
return value.whereType<Map<String, dynamic>>().toList(growable: false);
|
|
}
|
|
return const [];
|
|
}
|
|
|
|
Map<String, dynamic> _mapOrEmpty(dynamic value) =>
|
|
value is Map<String, dynamic> ? value : const {};
|
|
|
|
Map<String, dynamic> _firstNonEmptyMap(List<dynamic> candidates) {
|
|
for (final candidate in candidates) {
|
|
if (candidate is Map<String, dynamic> && candidate.isNotEmpty) {
|
|
return candidate;
|
|
}
|
|
}
|
|
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>) {
|
|
for (final key in keys) {
|
|
final value = source[key];
|
|
if (value is String && value.isNotEmpty) {
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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;
|
|
if (value is DateTime) return value;
|
|
if (value is String) return DateTime.tryParse(value);
|
|
return null;
|
|
}
|
|
|
|
String? _readString(dynamic value) {
|
|
if (value == null) {
|
|
return null;
|
|
}
|
|
if (value is String) {
|
|
final trimmed = value.trim();
|
|
return trimmed.isEmpty ? null : trimmed;
|
|
}
|
|
if (value is num || value is bool) {
|
|
return value.toString();
|
|
}
|
|
return null;
|
|
}
|