feat(inventory): 재고 현황 요약/상세 플로우를 릴리스

- lib/features/inventory/summary 계층과 warehouse select 위젯을 추가해 목록/상세, 자동 새로고침, 필터, 상세 시트를 구현

- PermissionBootstrapper, scope 파서, 라우트 가드로 inventory.view 기반 권한 부여와 메뉴 노출을 통합(lib/core, lib/main.dart 등)

- Inventory Summary API/QA/Audit 문서와 PR 템플릿, CHANGELOG를 신규 스펙과 검증 커맨드로 업데이트

- DTO 직렬화 의존성을 추가하고 Golden·Widget·단위 테스트를 작성했으며 flutter analyze / flutter test --coverage를 통과
This commit is contained in:
JiWoong Sul
2025-11-09 01:13:02 +09:00
parent 486ab8706f
commit 47cc62a33d
72 changed files with 5453 additions and 1021 deletions

View File

@@ -0,0 +1,118 @@
import 'package:superport_v2/core/permissions/permission_manager.dart';
import '../../features/auth/domain/entities/auth_session.dart';
import '../../features/masters/group/domain/entities/group.dart';
import '../../features/masters/group/domain/repositories/group_repository.dart';
import '../../features/masters/group_permission/application/permission_synchronizer.dart';
import '../../features/masters/group_permission/domain/repositories/group_permission_repository.dart';
/// 세션 정보와 그룹 권한을 기반으로 [PermissionManager]를 초기화하는 부트스트랩 도우미.
class PermissionBootstrapper {
PermissionBootstrapper({
required PermissionManager manager,
required GroupRepository groupRepository,
required GroupPermissionRepository groupPermissionRepository,
}) : _manager = manager,
_groupRepository = groupRepository,
_groupPermissionRepository = groupPermissionRepository;
final PermissionManager _manager;
final GroupRepository _groupRepository;
final GroupPermissionRepository _groupPermissionRepository;
/// 세션의 권한 목록과 그룹 권한을 적용한다.
Future<void> apply(AuthSession session) async {
_manager.clearServerPermissions();
final aggregated = <String, Set<PermissionAction>>{};
var hasMenuPermission = false;
void merge(Map<String, Set<PermissionAction>> map) {
if (map.isEmpty) {
return;
}
for (final entry in map.entries) {
final target = aggregated.putIfAbsent(
entry.key,
() => <PermissionAction>{},
);
target.addAll(entry.value);
if (!entry.key.startsWith('scope:')) {
hasMenuPermission = true;
}
}
}
for (final permission in session.permissions) {
merge(permission.toPermissionMap());
}
if (!hasMenuPermission) {
final map = await _loadGroupPermissions(
groupId: session.user.primaryGroupId,
);
merge(map);
}
if (aggregated.isNotEmpty) {
_manager.applyServerPermissions(aggregated);
return;
}
await _synchronizePermissions(groupId: session.user.primaryGroupId);
}
Future<void> _synchronizePermissions({int? groupId}) async {
final targetGroupId = await _resolveGroupId(groupId);
if (targetGroupId == null) {
return;
}
final synchronizer = PermissionSynchronizer(
repository: _groupPermissionRepository,
manager: _manager,
);
await synchronizer.syncForGroup(targetGroupId);
}
Future<Map<String, Set<PermissionAction>>> _loadGroupPermissions({
int? groupId,
}) async {
final targetGroupId = await _resolveGroupId(groupId);
if (targetGroupId == null) {
return const {};
}
final synchronizer = PermissionSynchronizer(
repository: _groupPermissionRepository,
manager: _manager,
);
return synchronizer.fetchPermissionMap(targetGroupId);
}
Future<int?> _resolveGroupId(int? groupId) async {
if (groupId != null) {
return groupId;
}
final defaultGroups = await _groupRepository.list(
page: 1,
pageSize: 1,
isDefault: true,
);
var targetGroup = _firstGroupWithId(defaultGroups.items);
if (targetGroup == null) {
final fallbackGroups = await _groupRepository.list(page: 1, pageSize: 1);
targetGroup = _firstGroupWithId(fallbackGroups.items);
}
return targetGroup?.id;
}
Group? _firstGroupWithId(List<Group> groups) {
for (final group in groups) {
if (group.id != null) {
return group;
}
}
return null;
}
}

View File

@@ -41,6 +41,10 @@ class PermissionManager extends ChangeNotifier {
return server.contains(action);
}
if (key.startsWith('scope:')) {
return false;
}
return Environment.hasPermission(key, action.name);
}

View File

@@ -11,6 +11,8 @@ class PermissionResources {
static const String approvalSteps = '/approval-steps';
static const String approvalHistories = '/approval-histories';
static const String approvalTemplates = '/approval/templates';
static const String inventorySummary = '/inventory/summary';
static const String inventoryScope = 'scope:inventory.view';
static const String groupMenuPermissions = '/group-menu-permissions';
static const String vendors = '/vendors';
static const String products = '/products';
@@ -41,6 +43,7 @@ class PermissionResources {
'/approvals/templates': approvalTemplates,
'/approval/templates': approvalTemplates,
'/approval-templates': approvalTemplates,
'/inventory/summary': inventorySummary,
'/masters/group-permissions': groupMenuPermissions,
'/group-menu-permissions': groupMenuPermissions,
'/masters/vendors': vendors,
@@ -83,35 +86,39 @@ class PermissionResources {
if (trimmed.isEmpty) {
return '';
}
var lowered = trimmed.toLowerCase();
final lowered = trimmed.toLowerCase();
if (lowered.startsWith('scope:')) {
return lowered;
}
var normalized = lowered;
// 절대 URL이 들어오면 path 부분만 추출한다.
final uri = Uri.tryParse(lowered);
final uri = Uri.tryParse(normalized);
if (uri != null && uri.hasScheme) {
lowered = uri.path;
normalized = uri.path;
}
// 쿼리스트링이나 프래그먼트를 제거해 순수 경로만 남긴다.
final queryIndex = lowered.indexOf('?');
final queryIndex = normalized.indexOf('?');
if (queryIndex != -1) {
lowered = lowered.substring(0, queryIndex);
normalized = normalized.substring(0, queryIndex);
}
final hashIndex = lowered.indexOf('#');
final hashIndex = normalized.indexOf('#');
if (hashIndex != -1) {
lowered = lowered.substring(0, hashIndex);
normalized = normalized.substring(0, hashIndex);
}
if (!lowered.startsWith('/')) {
lowered = '/$lowered';
if (!normalized.startsWith('/')) {
normalized = '/$normalized';
}
while (lowered.contains('//')) {
lowered = lowered.replaceAll('//', '/');
while (normalized.contains('//')) {
normalized = normalized.replaceAll('//', '/');
}
if (lowered.length > 1 && lowered.endsWith('/')) {
lowered = lowered.substring(0, lowered.length - 1);
if (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.substring(0, normalized.length - 1);
}
return lowered;
return normalized;
}
}