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

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