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,137 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../../summary/application/inventory_service.dart';
import '../../../summary/domain/entities/inventory_detail.dart';
import '../../../summary/domain/entities/inventory_filters.dart';
/// 재고 현황 단건 상태를 관리하는 컨트롤러.
class InventoryDetailController extends ChangeNotifier {
InventoryDetailController({required InventoryService service})
: _service = service;
final InventoryService _service;
final Map<int, _InventoryDetailState> _states = {};
InventoryDetail? detailOf(int productId) => _states[productId]?.detail;
InventoryDetailFilter filterOf(int productId) =>
_states[productId]?.filter ?? const InventoryDetailFilter();
bool isLoading(int productId) => _states[productId]?.isLoading ?? false;
String? errorOf(int productId) => _states[productId]?.errorMessage;
/// 단건 상세를 조회한다. [force]가 true면 캐시 여부와 관계없이 재조회한다.
Future<void> fetch(
int productId, {
InventoryDetailFilter? filter,
bool force = false,
}) async {
final current = _states[productId];
final effectiveFilter =
filter ?? current?.filter ?? const InventoryDetailFilter();
if (!force &&
current != null &&
current.detail != null &&
!_hasFilterChanged(current.filter, effectiveFilter) &&
!current.isLoading &&
current.errorMessage == null) {
return;
}
_states[productId] =
(current ?? _InventoryDetailState(filter: effectiveFilter)).copyWith(
isLoading: true,
errorMessage: null,
filter: effectiveFilter,
);
notifyListeners();
try {
final detail = await _service.fetchDetail(
productId,
filter: effectiveFilter,
);
_states[productId] = _states[productId]!.copyWith(
detail: detail,
isLoading: false,
errorMessage: null,
filter: effectiveFilter,
);
} catch (error) {
final failure = Failure.from(error);
_states[productId] = _states[productId]!.copyWith(
isLoading: false,
errorMessage: failure.describe(),
);
}
notifyListeners();
}
/// 이벤트 개수 제한을 변경하고 다시 조회한다.
Future<void> updateEventLimit(int productId, int limit) {
final current = filterOf(productId);
final next = InventoryDetailFilter(
warehouseId: current.warehouseId,
eventLimit: limit,
);
return fetch(productId, filter: next, force: true);
}
/// 특정 창고 기준으로 상세를 조회한다.
Future<void> updateWarehouseFilter(int productId, int? warehouseId) {
final current = filterOf(productId);
final next = InventoryDetailFilter(
warehouseId: warehouseId,
eventLimit: current.eventLimit,
);
return fetch(productId, filter: next, force: true);
}
void clearError(int productId) {
final state = _states[productId];
if (state == null || state.errorMessage == null) {
return;
}
_states[productId] = state.copyWith(errorMessage: null);
notifyListeners();
}
bool _hasFilterChanged(
InventoryDetailFilter previous,
InventoryDetailFilter next,
) {
return previous.warehouseId != next.warehouseId ||
previous.eventLimit != next.eventLimit;
}
}
class _InventoryDetailState {
const _InventoryDetailState({
required this.filter,
this.detail,
this.isLoading = false,
this.errorMessage,
});
final InventoryDetailFilter filter;
final InventoryDetail? detail;
final bool isLoading;
final String? errorMessage;
_InventoryDetailState copyWith({
InventoryDetailFilter? filter,
InventoryDetail? detail,
bool? isLoading,
String? errorMessage,
}) {
return _InventoryDetailState(
filter: filter ?? this.filter,
detail: detail ?? this.detail,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
);
}
}

View File

@@ -0,0 +1,178 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../../summary/application/inventory_service.dart';
import '../../../summary/domain/entities/inventory_filters.dart';
import '../../../summary/domain/entities/inventory_summary.dart';
/// 재고 현황 목록 상태를 관리하는 컨트롤러.
class InventorySummaryController extends ChangeNotifier {
InventorySummaryController({required InventoryService service})
: _service = service;
static const int defaultPageSize = 50;
final InventoryService _service;
PaginatedResult<InventorySummary>? _result;
bool _isLoading = false;
String? _errorMessage;
int _page = 1;
int _pageSize = defaultPageSize;
String _query = '';
String? _productName;
String? _vendorName;
int? _warehouseId;
bool _includeEmpty = false;
DateTime? _updatedSince;
String? _sort;
String? _order;
DateTime? _lastRefreshedAt;
PaginatedResult<InventorySummary>? get result => _result;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
int get page => _page;
int get pageSize => _pageSize;
String get query => _query;
String? get productName => _productName;
String? get vendorName => _vendorName;
int? get warehouseId => _warehouseId;
bool get includeEmpty => _includeEmpty;
DateTime? get updatedSince => _updatedSince;
String? get sort => _sort;
String? get order => _order;
DateTime? get lastRefreshedAt => _lastRefreshedAt;
/// 목록을 조회한다.
Future<void> fetch({int? page}) async {
final targetPage = page ?? _page;
_setLoading(true);
_errorMessage = null;
try {
final filter = _buildFilter(targetPage);
final response = await _service.fetchSummaries(filter: filter);
final paginated = response.result;
_result = paginated;
_lastRefreshedAt = response.lastRefreshedAt;
_page = paginated.page;
if (paginated.pageSize > 0) {
_pageSize = paginated.pageSize;
}
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_setLoading(false);
}
}
/// 현재 조건으로 다시 조회한다.
Future<void> refresh() => fetch(page: _page);
void updateQuery(String value) {
final trimmed = value.trim();
if (_query == trimmed) {
return;
}
_query = trimmed;
notifyListeners();
}
void updateProductName(String? value) {
final trimmed = value?.trim();
if (_productName == trimmed) {
return;
}
_productName = trimmed?.isEmpty ?? true ? null : trimmed;
notifyListeners();
}
void updateVendorName(String? value) {
final trimmed = value?.trim();
if (_vendorName == trimmed) {
return;
}
_vendorName = trimmed?.isEmpty ?? true ? null : trimmed;
notifyListeners();
}
void updateWarehouse(int? warehouseId) {
if (_warehouseId == warehouseId) {
return;
}
_warehouseId = warehouseId;
notifyListeners();
}
void toggleIncludeEmpty(bool value) {
if (_includeEmpty == value) {
return;
}
_includeEmpty = value;
notifyListeners();
}
void updateUpdatedSince(DateTime? value) {
if (_updatedSince == value) {
return;
}
_updatedSince = value;
notifyListeners();
}
void updateSort(String? value, {String? order}) {
var changed = false;
if (_sort != value) {
_sort = value;
changed = true;
}
if (order != null && _order != order) {
_order = order;
changed = true;
}
if (changed) {
notifyListeners();
}
}
void updatePageSize(int size) {
if (size <= 0 || _pageSize == size) {
return;
}
_pageSize = size;
notifyListeners();
}
void clearError() {
if (_errorMessage == null) {
return;
}
_errorMessage = null;
notifyListeners();
}
InventorySummaryFilter _buildFilter(int targetPage) {
return InventorySummaryFilter(
page: targetPage < 1 ? 1 : targetPage,
pageSize: _pageSize,
query: _query.isEmpty ? null : _query,
productName: _productName,
vendorName: _vendorName,
warehouseId: _warehouseId,
includeEmpty: _includeEmpty,
updatedSince: _updatedSince,
sort: _sort,
order: _order,
);
}
void _setLoading(bool value) {
if (_isLoading == value) {
return;
}
_isLoading = value;
notifyListeners();
}
}