feat(approvals): 결재 접근 차단 대응과 전표 전이 메모 전달 강화
- approvals 모듈에서 APPROVAL_ACCESS_DENIED 응답을 포착하여 ApprovalAccessDeniedException으로 변환하고 접근 거부 시 토스트·대시보드 리다이렉트를 처리 - approval history 조회가 서버 action id에 맞춰 필터링되도록 repository·controller·테스트를 보강 - 재고 트랜잭션 상태 전이 API 호출에 note를 전달하도록 repository·컨트롤러·통합/단위 테스트를 업데이트 - 승인 플로우 QA 체크리스트 및 연동 문서를 최신 계약과 테스트 흐름으로 업데이트
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/network/api_client.dart';
|
||||
import 'package:superport_v2/core/network/api_error.dart';
|
||||
import 'package:superport_v2/core/network/api_routes.dart';
|
||||
|
||||
import '../../domain/entities/approval.dart';
|
||||
import '../../domain/entities/approval_proceed_status.dart';
|
||||
import '../../domain/errors/approval_access_denied_exception.dart';
|
||||
import '../../domain/repositories/approval_repository.dart';
|
||||
import '../dtos/approval_audit_dto.dart';
|
||||
import '../dtos/approval_dto.dart';
|
||||
@@ -55,12 +57,14 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
if (includePending) 'include_pending': includePending,
|
||||
},
|
||||
);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_basePath,
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDto.parsePaginated(response.data ?? const {});
|
||||
return _guardApprovalAccess(() async {
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_basePath,
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDto.parsePaginated(response.data ?? const {});
|
||||
});
|
||||
}
|
||||
|
||||
/// 결재 상세를 조회한다. 단계/이력 포함 여부를 쿼리 파라미터로 제어한다.
|
||||
@@ -78,12 +82,14 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
includeParts.add('histories');
|
||||
}
|
||||
final query = ApiClient.buildQuery(include: includeParts);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
ApiClient.buildPath(_basePath, [id]),
|
||||
query: query.isEmpty ? null : query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
return _guardApprovalAccess(() async {
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
ApiClient.buildPath(_basePath, [id]),
|
||||
query: query.isEmpty ? null : query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -544,4 +550,83 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
});
|
||||
return histories;
|
||||
}
|
||||
|
||||
Future<T> _guardApprovalAccess<T>(Future<T> Function() action) async {
|
||||
try {
|
||||
return await action();
|
||||
} on ApiException catch (error) {
|
||||
if (_isApprovalAccessDenied(error)) {
|
||||
throw ApprovalAccessDeniedException(
|
||||
message: _accessDeniedMessage(error),
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isApprovalAccessDenied(ApiException error) {
|
||||
if (error.code != ApiErrorCode.forbidden) {
|
||||
return false;
|
||||
}
|
||||
final serverCode = _extractServerErrorCode(error);
|
||||
if (serverCode != null &&
|
||||
serverCode.toUpperCase() == 'APPROVAL_ACCESS_DENIED') {
|
||||
return true;
|
||||
}
|
||||
final message = error.message.trim().toLowerCase();
|
||||
if (message.contains('approval access denied')) {
|
||||
return true;
|
||||
}
|
||||
final reasons = error.details?.values
|
||||
.whereType<String>()
|
||||
.map((value) => value.toLowerCase())
|
||||
.toList(growable: false);
|
||||
if (reasons != null &&
|
||||
reasons.any((value) => value.contains('approval access denied'))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
String _accessDeniedMessage(ApiException error) {
|
||||
final message = error.message.trim();
|
||||
if (message.isNotEmpty) {
|
||||
return message;
|
||||
}
|
||||
final code = _extractServerErrorCode(error);
|
||||
if (code != null && code.isNotEmpty) {
|
||||
return '결재를 조회할 권한이 없습니다. (code: $code)';
|
||||
}
|
||||
return '결재를 조회할 권한이 없습니다. 관리자에게 권한을 요청하세요.';
|
||||
}
|
||||
|
||||
String? _extractServerErrorCode(ApiException error) {
|
||||
final details = error.details;
|
||||
if (details != null) {
|
||||
final detailCode = details['code'] ?? details['error_code'];
|
||||
if (detailCode is String && detailCode.trim().isNotEmpty) {
|
||||
return detailCode.trim();
|
||||
}
|
||||
}
|
||||
final data = error.cause?.response?.data;
|
||||
return _readErrorCodeFromPayload(data);
|
||||
}
|
||||
|
||||
String? _readErrorCodeFromPayload(dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final direct = data['error_code'] ?? data['code'];
|
||||
if (direct is String && direct.trim().isNotEmpty) {
|
||||
return direct.trim();
|
||||
}
|
||||
final errorNode = data['error'];
|
||||
if (errorNode is Map<String, dynamic>) {
|
||||
final nested = errorNode['code'] ?? errorNode['error_code'];
|
||||
if (nested is String && nested.trim().isNotEmpty) {
|
||||
return nested.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user