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:
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
|
||||
import '../permissions/permission_resources.dart';
|
||||
|
||||
/// 사이드바/내비게이션용 페이지 정보.
|
||||
class AppPageDescriptor {
|
||||
const AppPageDescriptor({
|
||||
@@ -8,12 +10,14 @@ class AppPageDescriptor {
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.summary,
|
||||
this.extraRequiredResources = const [],
|
||||
});
|
||||
|
||||
final String path;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final String summary;
|
||||
final List<String> extraRequiredResources;
|
||||
}
|
||||
|
||||
/// 메뉴 섹션을 나타내는 데이터 클래스.
|
||||
@@ -30,6 +34,9 @@ const loginRoutePath = '/login';
|
||||
/// 대시보드 라우트 경로.
|
||||
const dashboardRoutePath = '/dashboard';
|
||||
|
||||
/// 재고 현황 라우트 경로.
|
||||
const inventorySummaryRoutePath = '/inventory/summary';
|
||||
|
||||
/// 네비게이션 구성을 정의한 섹션 목록.
|
||||
const appSections = <AppSectionDescriptor>[
|
||||
AppSectionDescriptor(
|
||||
@@ -43,6 +50,18 @@ const appSections = <AppSectionDescriptor>[
|
||||
),
|
||||
],
|
||||
),
|
||||
AppSectionDescriptor(
|
||||
label: '재고',
|
||||
pages: [
|
||||
AppPageDescriptor(
|
||||
path: inventorySummaryRoutePath,
|
||||
label: '재고 현황',
|
||||
icon: lucide.LucideIcons.chartNoAxesColumnIncreasing,
|
||||
summary: '제품별 총 재고, 창고 잔량, 최근 이벤트를 한 화면에서 확인합니다.',
|
||||
extraRequiredResources: [PermissionResources.inventoryScope],
|
||||
),
|
||||
],
|
||||
),
|
||||
AppSectionDescriptor(
|
||||
label: '입·출고',
|
||||
pages: [
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'api_client.dart';
|
||||
|
||||
/// API 경로 상수 모음
|
||||
/// - 버전 prefix 등을 중앙에서 관리해 중복을 방지한다.
|
||||
class ApiRoutes {
|
||||
@@ -24,4 +26,12 @@ class ApiRoutes {
|
||||
.replaceAll(RegExp(r'/+$'), '');
|
||||
return '$approvalRoot/$sanitized';
|
||||
}
|
||||
|
||||
/// 재고 현황 요약 목록 경로.
|
||||
static const inventorySummary = '$apiV1/inventory/summary';
|
||||
|
||||
/// 재고 현황 단건 경로를 조합한다.
|
||||
static String inventorySummaryDetail(Object productId) {
|
||||
return ApiClient.buildPath(inventorySummary, [productId]);
|
||||
}
|
||||
}
|
||||
|
||||
118
lib/core/permissions/permission_bootstrapper.dart
Normal file
118
lib/core/permissions/permission_bootstrapper.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@ class PermissionManager extends ChangeNotifier {
|
||||
return server.contains(action);
|
||||
}
|
||||
|
||||
if (key.startsWith('scope:')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Environment.hasPermission(key, action.name);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../features/dashboard/presentation/pages/dashboard_page.dart';
|
||||
import '../../features/inventory/inbound/presentation/pages/inbound_page.dart';
|
||||
import '../../features/inventory/outbound/presentation/pages/outbound_page.dart';
|
||||
import '../../features/inventory/rental/presentation/pages/rental_page.dart';
|
||||
import '../../features/inventory/summary/presentation/pages/inventory_summary_page.dart';
|
||||
import '../../features/login/presentation/pages/login_page.dart';
|
||||
import '../../features/masters/customer/presentation/pages/customer_page.dart';
|
||||
import '../../features/masters/group/presentation/pages/group_page.dart';
|
||||
@@ -25,6 +26,7 @@ import '../../features/util/postal_search/presentation/pages/postal_search_page.
|
||||
import '../../widgets/app_shell.dart';
|
||||
import '../constants/app_sections.dart';
|
||||
import '../permissions/permission_manager.dart';
|
||||
import '../permissions/permission_resources.dart';
|
||||
import 'auth_guard.dart';
|
||||
|
||||
/// 전역 네비게이터 키(로그인/셸 라우터 공용).
|
||||
@@ -66,6 +68,21 @@ final appRouter = GoRouter(
|
||||
name: 'dashboard',
|
||||
builder: (context, state) => const DashboardPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: inventorySummaryRoutePath,
|
||||
name: 'inventory-summary',
|
||||
redirect: (context, state) {
|
||||
if (!AuthGuard.can(inventorySummaryRoutePath)) {
|
||||
return dashboardRoutePath;
|
||||
}
|
||||
if (!AuthGuard.can(PermissionResources.inventoryScope)) {
|
||||
return dashboardRoutePath;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
builder: (context, state) =>
|
||||
InventorySummaryPage(routeUri: state.uri),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/inventory/inbound',
|
||||
name: 'inventory-inbound',
|
||||
|
||||
Reference in New Issue
Block a user