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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ abstract class ApprovalHistoryRepository {
int page = 1,
int pageSize = 20,
String? query,
String? action,
int? approvalActionId,
DateTime? from,
DateTime? to,
});

View File

@@ -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) {

View File

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

View File

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