feat(approvals): 결재 접근 차단 대응과 전표 전이 메모 전달 강화

- approvals 모듈에서 APPROVAL_ACCESS_DENIED 응답을 포착하여 ApprovalAccessDeniedException으로 변환하고 접근 거부 시 토스트·대시보드 리다이렉트를 처리

- approval history 조회가 서버 action id에 맞춰 필터링되도록 repository·controller·테스트를 보강

- 재고 트랜잭션 상태 전이 API 호출에 note를 전달하도록 repository·컨트롤러·통합/단위 테스트를 업데이트

- 승인 플로우 QA 체크리스트 및 연동 문서를 최신 계약과 테스트 흐름으로 업데이트
This commit is contained in:
JiWoong Sul
2025-10-31 16:43:14 +09:00
parent d76f765814
commit 3e83408aa7
35 changed files with 1056 additions and 470 deletions

View File

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