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

@@ -25,14 +25,19 @@ class AuthSessionDto {
final expires = _parseDate(_readString(json, 'expires_at'));
final userMap = _readMap(json, 'user');
final permissionList = _readList(json, 'permissions');
final permissionDtos = permissionList
.map(AuthPermissionDto.fromJson)
.toList(growable: true);
final scopeCodes = _readScopeCodes(json);
for (final scope in scopeCodes) {
permissionDtos.add(AuthPermissionDto.fromScope(scope));
}
return AuthSessionDto(
accessToken: token ?? '',
refreshToken: refresh ?? '',
expiresAt: expires,
user: _parseUser(userMap),
permissions: permissionList
.map(AuthPermissionDto.fromJson)
.toList(growable: false),
permissions: List<AuthPermissionDto>.unmodifiable(permissionDtos),
);
}
@@ -87,6 +92,14 @@ class AuthPermissionDto {
);
}
factory AuthPermissionDto.fromScope(String scope) {
final normalized = scope.trim();
if (normalized.isEmpty) {
throw const FormatException('권한 스코프 코드가 비어 있습니다.');
}
return AuthPermissionDto(resource: normalized, actions: const ['view']);
}
AuthPermission toEntity() =>
AuthPermission(resource: resource, actions: actions);
}
@@ -131,6 +144,65 @@ List<Map<String, dynamic>> _readList(Map<String, dynamic> source, String key) {
return const [];
}
Set<String> _readScopeCodes(Map<String, dynamic> source) {
final codes = <String>{};
void addCode(String? raw) {
final normalized = raw == null ? null : _normalizeScopeCode(raw);
if (normalized != null) {
codes.add(normalized);
}
}
void parse(dynamic value) {
if (value == null) {
return;
}
if (value is String) {
addCode(value);
return;
}
if (value is Iterable) {
for (final item in value) {
parse(item);
}
return;
}
if (value is Map<String, dynamic>) {
for (final key in const [
'scope_code',
'scope',
'code',
'permission_code',
'permission',
'name',
'scopeCode',
'permissionCode',
'scopeName',
]) {
final candidate = value[key];
if (candidate is String && candidate.trim().isNotEmpty) {
addCode(candidate);
return;
}
}
for (final entry in value.entries) {
final candidate = entry.value;
if (candidate is String && candidate.trim().isNotEmpty) {
addCode(candidate);
return;
}
}
}
}
parse(source['permission_codes']);
parse(source['permission_scopes']);
parse(source['group_permission_scopes']);
return codes;
}
Map<String, dynamic> _readMap(Map<String, dynamic> source, String key) {
final value = source[key];
if (value is Map<String, dynamic>) {
@@ -162,3 +234,15 @@ int? _readOptionalInt(Map<String, dynamic>? source, String key) {
}
return null;
}
String? _normalizeScopeCode(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return null;
}
final lowered = trimmed.toLowerCase();
if (lowered.startsWith('scope:')) {
return lowered;
}
return 'scope:$lowered';
}

View File

@@ -15,6 +15,7 @@ class AuthPermission {
Map<String, Set<PermissionAction>> toPermissionMap() {
final normalized = PermissionResources.normalize(resource);
final actionSet = <PermissionAction>{};
final isScope = normalized.startsWith('scope:');
for (final raw in actions) {
final parsed = _parseAction(raw);
if (parsed == null) {
@@ -22,6 +23,9 @@ class AuthPermission {
}
actionSet.add(parsed);
}
if (actionSet.isEmpty && isScope) {
actionSet.add(PermissionAction.view);
}
if (actionSet.isEmpty) {
return <String, Set<PermissionAction>>{};
}