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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/inventory/summary/application/inventory_service.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_detail.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_event.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_product.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary_list_result.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_vendor.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse_balance.dart';
import 'package:superport_v2/features/inventory/summary/domain/repositories/inventory_repository.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_filters.dart';
import 'package:superport_v2/features/inventory/summary/presentation/pages/inventory_summary_page.dart';
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
class _MockInventoryRepository extends Mock implements InventoryRepository {}
class _MockWarehouseRepository extends Mock implements WarehouseRepository {}
Widget _buildApp(Widget child) {
return MaterialApp(
home: ShadTheme(
data: ShadThemeData(
colorScheme: const ShadSlateColorScheme.light(),
brightness: Brightness.light,
),
child: Scaffold(body: child),
),
);
}
InventorySummaryListResult _buildSummaryResult() {
final warehouse = InventoryWarehouse(id: 1, code: 'WH-001', name: '본사');
final summary = InventorySummary(
product: InventoryProduct(
id: 10,
code: 'INV-10',
name: '테스트 장비',
vendor: const InventoryVendor(id: 55, name: '테스트 벤더'),
),
totalQuantity: 120,
warehouseBalances: [
InventoryWarehouseBalance(warehouse: warehouse, quantity: 80),
InventoryWarehouseBalance(
warehouse: InventoryWarehouse(id: 2, code: 'WH-002', name: '보관창고'),
quantity: 40,
),
],
recentEvent: InventoryEvent(
eventId: 900,
eventKind: 'receipt',
eventLabel: '입고',
deltaQuantity: 30,
occurredAt: DateTime.utc(2025, 1, 3, 9, 0),
warehouse: warehouse,
),
updatedAt: DateTime.utc(2025, 1, 3, 9, 15),
lastRefreshedAt: DateTime.utc(2025, 1, 3, 9, 5),
);
return InventorySummaryListResult(
result: PaginatedResult<InventorySummary>(
items: [summary],
page: 1,
pageSize: 50,
total: 1,
),
lastRefreshedAt: summary.lastRefreshedAt,
);
}
InventoryDetail _buildDetail() {
final warehouse1 = InventoryWarehouse(id: 1, code: 'WH-001', name: '본사');
final warehouse2 = InventoryWarehouse(id: 2, code: 'WH-002', name: '보관창고');
return InventoryDetail(
product: InventoryProduct(
id: 10,
code: 'INV-10',
name: '테스트 장비',
vendor: const InventoryVendor(id: 55, name: '테스트 벤더'),
),
totalQuantity: 120,
warehouseBalances: [
InventoryWarehouseBalance(warehouse: warehouse1, quantity: 80),
InventoryWarehouseBalance(warehouse: warehouse2, quantity: 40),
],
recentEvents: [
InventoryEvent(
eventId: 901,
eventKind: 'receipt',
eventLabel: '입고',
deltaQuantity: 20,
occurredAt: DateTime.utc(2025, 1, 3, 9, 10),
warehouse: warehouse1,
),
],
updatedAt: DateTime.utc(2025, 1, 3, 9, 20),
lastRefreshedAt: DateTime.utc(2025, 1, 3, 9, 5),
);
}
void _registerDependencies({
required InventoryRepository inventoryRepository,
required WarehouseRepository warehouseRepository,
}) {
GetIt.I.registerSingleton<InventoryService>(
InventoryService(repository: inventoryRepository),
);
GetIt.I.registerSingleton<WarehouseRepository>(warehouseRepository);
}
void _stubWarehouseList(_MockWarehouseRepository repository) {
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
includeZipcode: any(named: 'includeZipcode'),
),
).thenAnswer((invocation) async {
final page = invocation.namedArguments[const Symbol('page')] as int? ?? 1;
final items = page == 1
? [Warehouse(id: 1, warehouseCode: 'WH-001', warehouseName: '본사 창고')]
: const <Warehouse>[];
return PaginatedResult<Warehouse>(
items: items,
page: page,
pageSize:
invocation.namedArguments[const Symbol('pageSize')] as int? ?? 20,
total: items.length,
);
});
}
Future<void> _pumpInventoryPage(WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(1600, 1200));
await tester.pumpWidget(
_buildApp(
InventorySummaryPage(
routeUri: Uri(path: '/inventory/summary'),
debugRowHeight: 200,
),
),
);
await tester.pumpAndSettle();
}
void main() {
final binding = TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
registerFallbackValue(const InventorySummaryFilter());
registerFallbackValue(const InventoryDetailFilter());
});
setUp(() async {
final inventoryRepository = _MockInventoryRepository();
final warehouseRepository = _MockWarehouseRepository();
_registerDependencies(
inventoryRepository: inventoryRepository,
warehouseRepository: warehouseRepository,
);
_stubWarehouseList(warehouseRepository);
when(
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
).thenAnswer((_) async => _buildSummaryResult());
when(
() =>
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
).thenAnswer((_) async => _buildDetail());
});
tearDown(() async {
await binding.setSurfaceSize(null);
await GetIt.I.reset();
});
testWidgets('Inventory summary page matches golden', (tester) async {
await _pumpInventoryPage(tester);
await expectLater(
find.byType(InventorySummaryPage),
matchesGoldenFile('goldens/inventory_summary_page_default.png'),
);
});
testWidgets('Inventory detail sheet matches golden', (tester) async {
await _pumpInventoryPage(tester);
await tester.tap(find.text('테스트 장비'));
await tester.pumpAndSettle();
await expectLater(
find.byType(InventorySummaryPage),
matchesGoldenFile('goldens/inventory_summary_detail_sheet.png'),
);
});
}

View File

@@ -0,0 +1,409 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/inventory/summary/application/inventory_service.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_counterparty.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_detail.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_event.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_filters.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_product.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary_list_result.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_vendor.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse.dart';
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse_balance.dart';
import 'package:superport_v2/features/inventory/summary/domain/repositories/inventory_repository.dart';
import 'package:superport_v2/features/inventory/summary/presentation/pages/inventory_summary_page.dart';
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
class _MockInventoryRepository extends Mock implements InventoryRepository {}
class _MockWarehouseRepository extends Mock implements WarehouseRepository {}
Widget _buildApp(Widget child) {
return MaterialApp(
home: ShadTheme(
data: ShadThemeData(
colorScheme: const ShadSlateColorScheme.light(),
brightness: Brightness.light,
),
child: Scaffold(body: child),
),
);
}
void _registerDependencies({
required InventoryRepository inventoryRepository,
required WarehouseRepository warehouseRepository,
}) {
GetIt.I.registerSingleton<InventoryService>(
InventoryService(repository: inventoryRepository),
);
GetIt.I.registerSingleton<WarehouseRepository>(warehouseRepository);
}
void _stubWarehouseList(_MockWarehouseRepository repository) {
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
includeZipcode: any(named: 'includeZipcode'),
),
).thenAnswer((invocation) async {
final page = invocation.namedArguments[const Symbol('page')] as int? ?? 1;
final items = page == 1
? [Warehouse(id: 1, warehouseCode: 'WH-001', warehouseName: '본사 창고')]
: const <Warehouse>[];
return PaginatedResult<Warehouse>(
items: items,
page: page,
pageSize:
invocation.namedArguments[const Symbol('pageSize')] as int? ?? 20,
total: items.length,
);
});
}
InventorySummaryListResult _buildSummaryResult() {
final product = InventoryProduct(
id: 10,
code: 'INV-10',
name: '테스트 장비',
vendor: const InventoryVendor(id: 55, name: '테스트 벤더'),
);
final warehouse = InventoryWarehouse(id: 1, code: 'WH-001', name: '본사');
final summary = InventorySummary(
product: product,
totalQuantity: 120,
warehouseBalances: [
InventoryWarehouseBalance(warehouse: warehouse, quantity: 80),
InventoryWarehouseBalance(
warehouse: InventoryWarehouse(id: 2, code: 'WH-002', name: '보관창고'),
quantity: 40,
),
],
recentEvent: InventoryEvent(
eventId: 900,
eventKind: 'receipt',
eventLabel: '입고',
deltaQuantity: 30,
occurredAt: DateTime.utc(2025, 1, 3, 9, 0),
counterparty: const InventoryCounterparty(
type: InventoryCounterpartyType.vendor,
name: 'QA 파트너',
),
warehouse: warehouse,
),
updatedAt: DateTime.utc(2025, 1, 3, 9, 15),
lastRefreshedAt: DateTime.utc(2025, 1, 3, 9, 5),
);
return InventorySummaryListResult(
result: PaginatedResult<InventorySummary>(
items: [summary],
page: 1,
pageSize: 50,
total: 1,
),
lastRefreshedAt: summary.lastRefreshedAt,
);
}
InventoryDetail _buildDetail() {
final warehouse1 = InventoryWarehouse(id: 1, code: 'WH-001', name: '본사');
final warehouse2 = InventoryWarehouse(id: 2, code: 'WH-002', name: '보관창고');
return InventoryDetail(
product: InventoryProduct(
id: 10,
code: 'INV-10',
name: '테스트 장비',
vendor: const InventoryVendor(id: 55, name: '테스트 벤더'),
),
totalQuantity: 120,
warehouseBalances: [
InventoryWarehouseBalance(warehouse: warehouse1, quantity: 80),
InventoryWarehouseBalance(warehouse: warehouse2, quantity: 40),
],
recentEvents: [
InventoryEvent(
eventId: 901,
eventKind: 'receipt',
eventLabel: '입고',
deltaQuantity: 20,
occurredAt: DateTime.utc(2025, 1, 3, 9, 10),
counterparty: const InventoryCounterparty(
type: InventoryCounterpartyType.vendor,
name: 'QA 파트너',
),
warehouse: warehouse1,
),
],
updatedAt: DateTime.utc(2025, 1, 3, 9, 20),
lastRefreshedAt: DateTime.utc(2025, 1, 3, 9, 5),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
registerFallbackValue(const InventorySummaryFilter());
registerFallbackValue(const InventoryDetailFilter());
});
tearDown(() async {
await GetIt.I.reset();
});
testWidgets('자동 새로고침 토글이 주기적 재조회 동작을 제어한다', (tester) async {
final inventoryRepository = _MockInventoryRepository();
final warehouseRepository = _MockWarehouseRepository();
final summaryResult = _buildSummaryResult();
final detail = _buildDetail();
var listCallCount = 0;
when(
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
).thenAnswer((_) async {
listCallCount += 1;
return summaryResult;
});
when(
() =>
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
).thenAnswer((_) async => detail);
_stubWarehouseList(warehouseRepository);
_registerDependencies(
inventoryRepository: inventoryRepository,
warehouseRepository: warehouseRepository,
);
await tester.pumpWidget(
_buildApp(
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
),
);
await tester.pumpAndSettle();
expect(listCallCount, 1);
expect(find.text('테스트 장비'), findsOneWidget);
expect(find.textContaining('마지막 리프레시'), findsOneWidget);
expect(find.text('자동 새로고침'), findsOneWidget);
await tester.pump(const Duration(seconds: 31));
await tester.pump();
expect(listCallCount, 2);
await tester.tap(find.bySemanticsLabel('자동 새로고침 전환'));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 31));
await tester.pump();
expect(listCallCount, 2);
});
testWidgets('행을 탭하면 상세 시트에서 창고 차트와 최근 이벤트를 확인할 수 있다', (tester) async {
final inventoryRepository = _MockInventoryRepository();
final warehouseRepository = _MockWarehouseRepository();
final summaryResult = _buildSummaryResult();
final detail = _buildDetail();
when(
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
).thenAnswer((_) async => summaryResult);
when(
() =>
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
).thenAnswer((_) async => detail);
_stubWarehouseList(warehouseRepository);
_registerDependencies(
inventoryRepository: inventoryRepository,
warehouseRepository: warehouseRepository,
);
await tester.pumpWidget(
_buildApp(
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('테스트 장비'));
await tester.pumpAndSettle();
expect(find.text('창고 잔량'), findsOneWidget);
expect(find.byType(LinearProgressIndicator), findsWidgets);
expect(find.text('최근 이벤트'), findsOneWidget);
expect(find.textContaining('거래처: QA 파트너'), findsOneWidget);
});
testWidgets('권한 오류가 발생하면 경고 배너를 노출한다', (tester) async {
final inventoryRepository = _MockInventoryRepository();
final warehouseRepository = _MockWarehouseRepository();
when(
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
).thenThrow(Exception('재고 조회 권한이 없습니다.'));
_stubWarehouseList(warehouseRepository);
_registerDependencies(
inventoryRepository: inventoryRepository,
warehouseRepository: warehouseRepository,
);
await tester.pumpWidget(
_buildApp(
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('재고 조회 권한이 없습니다.'), findsOneWidget);
});
testWidgets('검색 적용 시 입력값이 필터에 반영된다', (tester) async {
final inventoryRepository = _MockInventoryRepository();
final warehouseRepository = _MockWarehouseRepository();
final summaryResult = _buildSummaryResult();
final detail = _buildDetail();
final capturedFilters = <InventorySummaryFilter>[];
when(
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
).thenAnswer((invocation) async {
final filter =
invocation.namedArguments[const Symbol('filter')]
as InventorySummaryFilter?;
if (filter != null) {
capturedFilters.add(filter);
}
return summaryResult;
});
when(
() =>
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
).thenAnswer((_) async => detail);
_stubWarehouseList(warehouseRepository);
_registerDependencies(
inventoryRepository: inventoryRepository,
warehouseRepository: warehouseRepository,
);
await tester.pumpWidget(
_buildApp(
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
),
);
await tester.pumpAndSettle();
expect(capturedFilters, isNotEmpty);
await tester.enterText(
find.byKey(const Key('inventory_filter_query_field')),
'카메라',
);
await tester.pump();
await tester.tap(find.byKey(const Key('inventory_filter_apply')));
await tester.pumpAndSettle();
expect(capturedFilters.length, greaterThanOrEqualTo(2));
final latest = capturedFilters.last;
expect(latest.query, '카메라');
expect(latest.page, 1);
});
testWidgets('목록이 비어 있으면 안내 문구를 노출한다', (tester) async {
final inventoryRepository = _MockInventoryRepository();
final warehouseRepository = _MockWarehouseRepository();
final emptyResult = InventorySummaryListResult(
result: PaginatedResult<InventorySummary>(
items: const [],
page: 1,
pageSize: 50,
total: 0,
),
lastRefreshedAt: DateTime.utc(2025, 1, 3, 9, 0),
);
when(
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
).thenAnswer((_) async => emptyResult);
when(
() =>
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
).thenAnswer((_) async => _buildDetail());
_stubWarehouseList(warehouseRepository);
_registerDependencies(
inventoryRepository: inventoryRepository,
warehouseRepository: warehouseRepository,
);
await tester.pumpWidget(
_buildApp(
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
),
);
await tester.pumpAndSettle();
expect(find.text('조건에 맞는 재고 데이터가 없습니다.'), findsOneWidget);
});
testWidgets('총 수량 헤더를 탭하면 정렬 파라미터가 토글된다', (tester) async {
final inventoryRepository = _MockInventoryRepository();
final warehouseRepository = _MockWarehouseRepository();
final summaryResult = _buildSummaryResult();
final detail = _buildDetail();
final recordedFilters = <InventorySummaryFilter>[];
when(
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
).thenAnswer((invocation) async {
final filter =
invocation.namedArguments[const Symbol('filter')]
as InventorySummaryFilter?;
if (filter != null) {
recordedFilters.add(filter);
}
return summaryResult;
});
when(
() =>
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
).thenAnswer((_) async => detail);
_stubWarehouseList(warehouseRepository);
_registerDependencies(
inventoryRepository: inventoryRepository,
warehouseRepository: warehouseRepository,
);
await tester.pumpWidget(
_buildApp(
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
),
);
await tester.pumpAndSettle();
expect(recordedFilters, isNotEmpty);
// 첫 정렬: 총 수량 헤더 탭 → 오름차순
await tester.tap(find.text('총 수량').first);
await tester.pumpAndSettle();
final ascFilter = recordedFilters.last;
expect(ascFilter.sort, 'total_quantity');
expect(ascFilter.order, 'asc');
// 두 번째 탭 → 내림차순
await tester.tap(find.text('총 수량').first);
await tester.pumpAndSettle();
final descFilter = recordedFilters.last;
expect(descFilter.sort, 'total_quantity');
expect(descFilter.order, 'desc');
});
}