feat(approvals): 결재 접근 차단 대응과 전표 전이 메모 전달 강화
- approvals 모듈에서 APPROVAL_ACCESS_DENIED 응답을 포착하여 ApprovalAccessDeniedException으로 변환하고 접근 거부 시 토스트·대시보드 리다이렉트를 처리 - approval history 조회가 서버 action id에 맞춰 필터링되도록 repository·controller·테스트를 보강 - 재고 트랜잭션 상태 전이 API 호출에 note를 전달하도록 repository·컨트롤러·통합/단위 테스트를 업데이트 - 승인 플로우 QA 체크리스트 및 연동 문서를 최신 계약과 테스트 흐름으로 업데이트
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user