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:
@@ -0,0 +1,97 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_detail.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';
|
||||
|
||||
class FakeInventoryRepository implements InventoryRepository {
|
||||
InventorySummaryListResult? summaryResult;
|
||||
InventoryDetail? detailResult;
|
||||
Object? summaryError;
|
||||
Object? detailError;
|
||||
InventorySummaryFilter? lastSummaryFilter;
|
||||
InventoryDetailFilter? lastDetailFilter;
|
||||
|
||||
@override
|
||||
Future<InventoryDetail> fetchDetail(
|
||||
int productId, {
|
||||
InventoryDetailFilter? filter,
|
||||
}) async {
|
||||
lastDetailFilter = filter;
|
||||
if (detailError != null) {
|
||||
throw detailError!;
|
||||
}
|
||||
return detailResult ?? buildDetail(productId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<InventorySummaryListResult> listSummaries({
|
||||
InventorySummaryFilter? filter,
|
||||
}) async {
|
||||
lastSummaryFilter = filter;
|
||||
if (summaryError != null) {
|
||||
throw summaryError!;
|
||||
}
|
||||
return summaryResult ?? buildSummaryResult();
|
||||
}
|
||||
}
|
||||
|
||||
InventorySummaryListResult buildSummaryResult() {
|
||||
final product = InventoryProduct(
|
||||
id: 1,
|
||||
code: 'P-1',
|
||||
name: '장비',
|
||||
vendor: const InventoryVendor(id: 9, name: '벤더'),
|
||||
);
|
||||
final refreshedAt = DateTime.utc(2025, 1, 1, 12);
|
||||
final summary = InventorySummary(
|
||||
product: product,
|
||||
totalQuantity: 10,
|
||||
warehouseBalances: [
|
||||
InventoryWarehouseBalance(
|
||||
warehouse: const InventoryWarehouse(id: 1, code: 'WH-1', name: '본사'),
|
||||
quantity: 10,
|
||||
),
|
||||
],
|
||||
recentEvent: null,
|
||||
updatedAt: DateTime.utc(2025, 1, 1),
|
||||
lastRefreshedAt: refreshedAt,
|
||||
);
|
||||
final paginated = PaginatedResult<InventorySummary>(
|
||||
items: [summary],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
);
|
||||
return InventorySummaryListResult(
|
||||
result: paginated,
|
||||
lastRefreshedAt: refreshedAt,
|
||||
);
|
||||
}
|
||||
|
||||
InventoryDetail buildDetail(int productId) {
|
||||
final product = InventoryProduct(
|
||||
id: productId,
|
||||
code: 'P-$productId',
|
||||
name: '제품$productId',
|
||||
vendor: const InventoryVendor(id: 9, name: '벤더'),
|
||||
);
|
||||
return InventoryDetail(
|
||||
product: product,
|
||||
totalQuantity: 5,
|
||||
warehouseBalances: [
|
||||
InventoryWarehouseBalance(
|
||||
warehouse: const InventoryWarehouse(id: 1, code: 'WH-1', name: '본사'),
|
||||
quantity: 5,
|
||||
),
|
||||
],
|
||||
recentEvents: const [],
|
||||
updatedAt: DateTime.utc(2025, 1, 2),
|
||||
lastRefreshedAt: DateTime.utc(2025, 1, 2),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/application/inventory_service.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_filters.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/presentation/controllers/inventory_detail_controller.dart';
|
||||
|
||||
import 'fake_inventory_repository.dart';
|
||||
|
||||
void main() {
|
||||
group('InventoryDetailController', () {
|
||||
late FakeInventoryRepository repository;
|
||||
late InventoryDetailController controller;
|
||||
|
||||
setUp(() {
|
||||
repository = FakeInventoryRepository();
|
||||
controller = InventoryDetailController(
|
||||
service: InventoryService(repository: repository),
|
||||
);
|
||||
});
|
||||
|
||||
test('fetch는 상세 정보를 로드하고 캐시한다', () async {
|
||||
await controller.fetch(1);
|
||||
|
||||
expect(controller.detailOf(1), isNotNull);
|
||||
expect(controller.isLoading(1), isFalse);
|
||||
expect(controller.errorOf(1), isNull);
|
||||
expect(repository.lastDetailFilter, isNotNull);
|
||||
});
|
||||
|
||||
test('동일 필터로 재요청 시 추가 호출을 건너뛴다', () async {
|
||||
await controller.fetch(2);
|
||||
repository.detailError = Exception('should not be thrown');
|
||||
|
||||
await controller.fetch(2);
|
||||
|
||||
expect(controller.errorOf(2), isNull);
|
||||
});
|
||||
|
||||
test('필터 변경 시 강제로 다시 조회한다', () async {
|
||||
await controller.fetch(3);
|
||||
repository.detailError = Exception('boom');
|
||||
|
||||
await controller.updateEventLimit(3, 50);
|
||||
|
||||
expect(controller.errorOf(3), contains('boom'));
|
||||
expect(repository.lastDetailFilter?.eventLimit, 50);
|
||||
});
|
||||
|
||||
test('오류를 명시적으로 초기화할 수 있다', () async {
|
||||
repository.detailError = Exception('boom');
|
||||
await controller.fetch(5, filter: const InventoryDetailFilter());
|
||||
|
||||
expect(controller.errorOf(5), isNotNull);
|
||||
|
||||
controller.clearError(5);
|
||||
|
||||
expect(controller.errorOf(5), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/application/inventory_service.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/presentation/controllers/inventory_summary_controller.dart';
|
||||
|
||||
import 'fake_inventory_repository.dart';
|
||||
|
||||
void main() {
|
||||
group('InventorySummaryController', () {
|
||||
late FakeInventoryRepository repository;
|
||||
late InventorySummaryController controller;
|
||||
|
||||
setUp(() {
|
||||
repository = FakeInventoryRepository();
|
||||
controller = InventorySummaryController(
|
||||
service: InventoryService(repository: repository),
|
||||
);
|
||||
});
|
||||
|
||||
test('fetch 저장 시 결과와 페이징 상태를 갱신한다', () async {
|
||||
repository.summaryResult = buildSummaryResult();
|
||||
|
||||
await controller.fetch();
|
||||
|
||||
expect(controller.result, isNotNull);
|
||||
expect(controller.result!.items, isNotEmpty);
|
||||
expect(controller.isLoading, isFalse);
|
||||
expect(controller.errorMessage, isNull);
|
||||
expect(repository.lastSummaryFilter?.page, 1);
|
||||
expect(controller.lastRefreshedAt, DateTime.utc(2025, 1, 1, 12));
|
||||
});
|
||||
|
||||
test('쿼리/정렬/필터 업데이트가 상태에 반영된다', () {
|
||||
controller
|
||||
..updateQuery(' camera ')
|
||||
..updateProductName('렌즈')
|
||||
..updateVendorName('슈퍼')
|
||||
..updateWarehouse(7)
|
||||
..toggleIncludeEmpty(true)
|
||||
..updateSort('total_quantity', order: 'asc')
|
||||
..updatePageSize(30);
|
||||
|
||||
expect(controller.query, 'camera');
|
||||
expect(controller.productName, '렌즈');
|
||||
expect(controller.vendorName, '슈퍼');
|
||||
expect(controller.warehouseId, 7);
|
||||
expect(controller.includeEmpty, isTrue);
|
||||
expect(controller.sort, 'total_quantity');
|
||||
expect(controller.order, 'asc');
|
||||
expect(controller.pageSize, 30);
|
||||
});
|
||||
|
||||
test('요청 실패 시 오류 메시지를 저장한다', () async {
|
||||
repository.summaryError = Exception('boom');
|
||||
|
||||
await controller.fetch();
|
||||
|
||||
expect(controller.errorMessage, contains('boom'));
|
||||
expect(controller.isLoading, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user