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,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),
);
}

View File

@@ -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);
});
});
}

View File

@@ -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);
});
});
}