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,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user