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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import '../../../../core/network/api_error.dart';
|
||||
|
||||
/// 결재 열람 권한이 없을 때 던지는 예외.
|
||||
///
|
||||
/// - 목록/상세 API가 `403`(`APPROVAL_ACCESS_DENIED`)을 반환하면 이 예외로 변환한다.
|
||||
class ApprovalAccessDeniedException implements Exception {
|
||||
const ApprovalAccessDeniedException({
|
||||
this.message = '결재를 조회할 권한이 없습니다.',
|
||||
this.cause,
|
||||
});
|
||||
|
||||
/// 사용자에게 노출할 안내 메시지.
|
||||
final String message;
|
||||
|
||||
/// 원본 API 예외.
|
||||
final ApiException? cause;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ApprovalAccessDeniedException(message: $message, cause: $cause)';
|
||||
}
|
||||
@@ -15,6 +15,14 @@ class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository {
|
||||
final ApiClient _api;
|
||||
|
||||
static const _basePath = '${ApiRoutes.apiV1}/approval-histories';
|
||||
static const _defaultInclude = <String>[
|
||||
'approval',
|
||||
'step',
|
||||
'approval_action',
|
||||
'approver',
|
||||
'from_status',
|
||||
'to_status',
|
||||
];
|
||||
|
||||
/// 결재 이력 목록을 조회한다.
|
||||
@override
|
||||
@@ -22,18 +30,24 @@ class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository {
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String? query,
|
||||
String? action,
|
||||
int? approvalActionId,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
}) async {
|
||||
final resolvedQuery = ApiClient.buildQuery(
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
q: query,
|
||||
include: _defaultInclude,
|
||||
filters: {
|
||||
if (from != null) 'action_from': from,
|
||||
if (to != null) 'action_to': to,
|
||||
if (approvalActionId != null) 'approval_action_id': approvalActionId,
|
||||
},
|
||||
);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_basePath,
|
||||
query: {
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (from != null) 'action_from': from.toIso8601String(),
|
||||
if (to != null) 'action_to': to.toIso8601String(),
|
||||
},
|
||||
query: resolvedQuery.isEmpty ? null : resolvedQuery,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ abstract class ApprovalHistoryRepository {
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String? query,
|
||||
String? action,
|
||||
int? approvalActionId,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
});
|
||||
|
||||
@@ -57,6 +57,8 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
DateTime? _auditFrom;
|
||||
DateTime? _auditTo;
|
||||
final Map<String, ApprovalAction> _auditActions = <String, ApprovalAction>{};
|
||||
final Map<String, int> _actionIdsByCode = <String, int>{};
|
||||
bool _hasLoadedActionCatalog = false;
|
||||
bool _isSelectionForbidden = false;
|
||||
|
||||
PaginatedResult<ApprovalHistoryRecord>? get result => _result;
|
||||
@@ -101,18 +103,13 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
try {
|
||||
final resolvedPage = _resolvePage(page, _result);
|
||||
final action = switch (_actionFilter) {
|
||||
ApprovalHistoryActionFilter.all => null,
|
||||
ApprovalHistoryActionFilter.approve => 'approve',
|
||||
ApprovalHistoryActionFilter.reject => 'reject',
|
||||
ApprovalHistoryActionFilter.comment => 'comment',
|
||||
};
|
||||
final approvalActionId = await _resolveActionIdForFilter(_actionFilter);
|
||||
|
||||
final response = await _repository.list(
|
||||
page: resolvedPage,
|
||||
pageSize: _pageSize,
|
||||
query: _query.trim().isEmpty ? null : _query.trim(),
|
||||
action: action,
|
||||
approvalActionId: approvalActionId,
|
||||
from: _from,
|
||||
to: _to,
|
||||
);
|
||||
@@ -158,6 +155,7 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
continue;
|
||||
}
|
||||
actionMap.putIfAbsent(code, () => log.action);
|
||||
_actionIdsByCode[code] = log.action.id;
|
||||
}
|
||||
if (actionMap.isNotEmpty) {
|
||||
_auditActions
|
||||
@@ -443,6 +441,60 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
_auditFrom != null ||
|
||||
_auditTo != null;
|
||||
|
||||
Future<int?> _resolveActionIdForFilter(
|
||||
ApprovalHistoryActionFilter filter,
|
||||
) async {
|
||||
final code = _codeForFilter(filter);
|
||||
if (code == null) {
|
||||
return null;
|
||||
}
|
||||
final cached = _actionIdsByCode[code];
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
final auditAction = _auditActions[code];
|
||||
if (auditAction != null) {
|
||||
final id = auditAction.id;
|
||||
_actionIdsByCode[code] = id;
|
||||
return id;
|
||||
}
|
||||
await _ensureActionCatalogLoaded();
|
||||
return _actionIdsByCode[code];
|
||||
}
|
||||
|
||||
String? _codeForFilter(ApprovalHistoryActionFilter filter) {
|
||||
return switch (filter) {
|
||||
ApprovalHistoryActionFilter.all => null,
|
||||
ApprovalHistoryActionFilter.approve => 'approve',
|
||||
ApprovalHistoryActionFilter.reject => 'reject',
|
||||
ApprovalHistoryActionFilter.comment => 'comment',
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _ensureActionCatalogLoaded() async {
|
||||
if (_hasLoadedActionCatalog) {
|
||||
return;
|
||||
}
|
||||
final approvalRepository = _approvalRepository;
|
||||
if (approvalRepository == null) {
|
||||
_hasLoadedActionCatalog = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final actions = await approvalRepository.listActions();
|
||||
for (final action in actions) {
|
||||
final code = action.code?.trim();
|
||||
if (code == null || code.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
_actionIdsByCode.putIfAbsent(code, () => action.id);
|
||||
}
|
||||
_hasLoadedActionCatalog = true;
|
||||
} catch (_) {
|
||||
// 재시도를 위해 로드 여부 플래그를 유지한다.
|
||||
}
|
||||
}
|
||||
|
||||
int? _resolveAuditActionId() {
|
||||
final code = _auditActionCode?.trim();
|
||||
if (code == null || code.isEmpty) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../domain/entities/approval.dart';
|
||||
import '../../domain/entities/approval_draft.dart';
|
||||
import '../../domain/entities/approval_proceed_status.dart';
|
||||
import '../../domain/entities/approval_template.dart';
|
||||
import '../../domain/errors/approval_access_denied_exception.dart';
|
||||
import '../../domain/repositories/approval_repository.dart';
|
||||
import '../../domain/repositories/approval_template_repository.dart';
|
||||
import '../../domain/usecases/get_approval_draft_use_case.dart';
|
||||
@@ -90,6 +91,8 @@ class ApprovalController extends ChangeNotifier {
|
||||
ApprovalProceedStatus? _proceedStatus;
|
||||
ApprovalSubmissionInput? _submissionDraft;
|
||||
String? _errorMessage;
|
||||
bool _accessDenied = false;
|
||||
String? _accessDeniedMessage;
|
||||
ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all;
|
||||
int? _transactionIdFilter;
|
||||
int? _requestedById;
|
||||
@@ -114,6 +117,8 @@ class ApprovalController extends ChangeNotifier {
|
||||
bool get isPerformingAction => _isPerformingAction;
|
||||
int? get processingStepId => _processingStepId;
|
||||
String? get errorMessage => _errorMessage;
|
||||
bool get isAccessDenied => _accessDenied;
|
||||
String? get accessDeniedMessage => _accessDeniedMessage;
|
||||
ApprovalStatusFilter get statusFilter => _statusFilter;
|
||||
int? get transactionIdFilter => _transactionIdFilter;
|
||||
int? get requestedById => _requestedById;
|
||||
@@ -169,6 +174,12 @@ class ApprovalController extends ChangeNotifier {
|
||||
|
||||
Map<String, LookupItem> get statusLookup => _statusLookup;
|
||||
|
||||
/// 결재 열람 제한 플래그를 초기화한다.
|
||||
void acknowledgeAccessDenied() {
|
||||
_accessDenied = false;
|
||||
_accessDeniedMessage = null;
|
||||
}
|
||||
|
||||
/// 필터 조건과 페이지 정보를 기반으로 결재 목록을 조회한다.
|
||||
///
|
||||
/// [page]가 1보다 작으면 1페이지로 보정한다. 조회 실패 시 [_errorMessage]에
|
||||
@@ -211,8 +222,16 @@ class ApprovalController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
if (error is ApprovalAccessDeniedException) {
|
||||
_accessDenied = true;
|
||||
_accessDeniedMessage = error.message;
|
||||
_result = null;
|
||||
_selected = null;
|
||||
_proceedStatus = null;
|
||||
} else {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
}
|
||||
} finally {
|
||||
_isLoadingList = false;
|
||||
notifyListeners();
|
||||
@@ -391,11 +410,18 @@ class ApprovalController extends ChangeNotifier {
|
||||
await _loadProceedStatus(detail.id!);
|
||||
}
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
debugPrint(
|
||||
'[ApprovalController] 결재 상세 조회 실패: ${failure.describe()}',
|
||||
); // 에러 발생 시 콘솔에 남겨 즉시 파악할 수 있도록 한다.
|
||||
_errorMessage = failure.describe();
|
||||
if (error is ApprovalAccessDeniedException) {
|
||||
_accessDenied = true;
|
||||
_accessDeniedMessage = error.message;
|
||||
_selected = null;
|
||||
_proceedStatus = null;
|
||||
} else {
|
||||
final failure = Failure.from(error);
|
||||
debugPrint(
|
||||
'[ApprovalController] 결재 상세 조회 실패: ${failure.describe()}',
|
||||
); // 에러 발생 시 콘솔에 남겨 즉시 파악할 수 있도록 한다.
|
||||
_errorMessage = failure.describe();
|
||||
}
|
||||
} finally {
|
||||
_isLoadingDetail = false;
|
||||
notifyListeners();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
@@ -88,6 +89,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
InventoryEmployeeSuggestion? _selectedRequester;
|
||||
final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
|
||||
String? _lastError;
|
||||
String? _lastAccessDeniedMessage;
|
||||
int? _selectedTemplateId;
|
||||
String? _pendingRouteSelection;
|
||||
|
||||
@@ -138,6 +140,21 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
|
||||
void _handleControllerUpdate() {
|
||||
final error = _controller.errorMessage;
|
||||
if (_controller.isAccessDenied) {
|
||||
final message = _controller.accessDeniedMessage ?? '결재를 조회할 권한이 없습니다.';
|
||||
if (mounted) {
|
||||
if (_lastAccessDeniedMessage != message) {
|
||||
SuperportToast.warning(context, message);
|
||||
_lastAccessDeniedMessage = message;
|
||||
}
|
||||
final router = GoRouter.maybeOf(context);
|
||||
router?.go(dashboardRoutePath);
|
||||
}
|
||||
_controller.acknowledgeAccessDenied();
|
||||
return;
|
||||
} else {
|
||||
_lastAccessDeniedMessage = null;
|
||||
}
|
||||
if (error != null && error != _lastError && mounted) {
|
||||
_lastError = error;
|
||||
SuperportToast.error(context, error);
|
||||
|
||||
Reference in New Issue
Block a user