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:
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
|
||||
import '../permissions/permission_resources.dart';
|
||||
|
||||
/// 사이드바/내비게이션용 페이지 정보.
|
||||
class AppPageDescriptor {
|
||||
const AppPageDescriptor({
|
||||
@@ -8,12 +10,14 @@ class AppPageDescriptor {
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.summary,
|
||||
this.extraRequiredResources = const [],
|
||||
});
|
||||
|
||||
final String path;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final String summary;
|
||||
final List<String> extraRequiredResources;
|
||||
}
|
||||
|
||||
/// 메뉴 섹션을 나타내는 데이터 클래스.
|
||||
@@ -30,6 +34,9 @@ const loginRoutePath = '/login';
|
||||
/// 대시보드 라우트 경로.
|
||||
const dashboardRoutePath = '/dashboard';
|
||||
|
||||
/// 재고 현황 라우트 경로.
|
||||
const inventorySummaryRoutePath = '/inventory/summary';
|
||||
|
||||
/// 네비게이션 구성을 정의한 섹션 목록.
|
||||
const appSections = <AppSectionDescriptor>[
|
||||
AppSectionDescriptor(
|
||||
@@ -43,6 +50,18 @@ const appSections = <AppSectionDescriptor>[
|
||||
),
|
||||
],
|
||||
),
|
||||
AppSectionDescriptor(
|
||||
label: '재고',
|
||||
pages: [
|
||||
AppPageDescriptor(
|
||||
path: inventorySummaryRoutePath,
|
||||
label: '재고 현황',
|
||||
icon: lucide.LucideIcons.chartNoAxesColumnIncreasing,
|
||||
summary: '제품별 총 재고, 창고 잔량, 최근 이벤트를 한 화면에서 확인합니다.',
|
||||
extraRequiredResources: [PermissionResources.inventoryScope],
|
||||
),
|
||||
],
|
||||
),
|
||||
AppSectionDescriptor(
|
||||
label: '입·출고',
|
||||
pages: [
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'api_client.dart';
|
||||
|
||||
/// API 경로 상수 모음
|
||||
/// - 버전 prefix 등을 중앙에서 관리해 중복을 방지한다.
|
||||
class ApiRoutes {
|
||||
@@ -24,4 +26,12 @@ class ApiRoutes {
|
||||
.replaceAll(RegExp(r'/+$'), '');
|
||||
return '$approvalRoot/$sanitized';
|
||||
}
|
||||
|
||||
/// 재고 현황 요약 목록 경로.
|
||||
static const inventorySummary = '$apiV1/inventory/summary';
|
||||
|
||||
/// 재고 현황 단건 경로를 조합한다.
|
||||
static String inventorySummaryDetail(Object productId) {
|
||||
return ApiClient.buildPath(inventorySummary, [productId]);
|
||||
}
|
||||
}
|
||||
|
||||
118
lib/core/permissions/permission_bootstrapper.dart
Normal file
118
lib/core/permissions/permission_bootstrapper.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||
|
||||
import '../../features/auth/domain/entities/auth_session.dart';
|
||||
import '../../features/masters/group/domain/entities/group.dart';
|
||||
import '../../features/masters/group/domain/repositories/group_repository.dart';
|
||||
import '../../features/masters/group_permission/application/permission_synchronizer.dart';
|
||||
import '../../features/masters/group_permission/domain/repositories/group_permission_repository.dart';
|
||||
|
||||
/// 세션 정보와 그룹 권한을 기반으로 [PermissionManager]를 초기화하는 부트스트랩 도우미.
|
||||
class PermissionBootstrapper {
|
||||
PermissionBootstrapper({
|
||||
required PermissionManager manager,
|
||||
required GroupRepository groupRepository,
|
||||
required GroupPermissionRepository groupPermissionRepository,
|
||||
}) : _manager = manager,
|
||||
_groupRepository = groupRepository,
|
||||
_groupPermissionRepository = groupPermissionRepository;
|
||||
|
||||
final PermissionManager _manager;
|
||||
final GroupRepository _groupRepository;
|
||||
final GroupPermissionRepository _groupPermissionRepository;
|
||||
|
||||
/// 세션의 권한 목록과 그룹 권한을 적용한다.
|
||||
Future<void> apply(AuthSession session) async {
|
||||
_manager.clearServerPermissions();
|
||||
|
||||
final aggregated = <String, Set<PermissionAction>>{};
|
||||
var hasMenuPermission = false;
|
||||
|
||||
void merge(Map<String, Set<PermissionAction>> map) {
|
||||
if (map.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final entry in map.entries) {
|
||||
final target = aggregated.putIfAbsent(
|
||||
entry.key,
|
||||
() => <PermissionAction>{},
|
||||
);
|
||||
target.addAll(entry.value);
|
||||
if (!entry.key.startsWith('scope:')) {
|
||||
hasMenuPermission = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final permission in session.permissions) {
|
||||
merge(permission.toPermissionMap());
|
||||
}
|
||||
|
||||
if (!hasMenuPermission) {
|
||||
final map = await _loadGroupPermissions(
|
||||
groupId: session.user.primaryGroupId,
|
||||
);
|
||||
merge(map);
|
||||
}
|
||||
|
||||
if (aggregated.isNotEmpty) {
|
||||
_manager.applyServerPermissions(aggregated);
|
||||
return;
|
||||
}
|
||||
|
||||
await _synchronizePermissions(groupId: session.user.primaryGroupId);
|
||||
}
|
||||
|
||||
Future<void> _synchronizePermissions({int? groupId}) async {
|
||||
final targetGroupId = await _resolveGroupId(groupId);
|
||||
if (targetGroupId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final synchronizer = PermissionSynchronizer(
|
||||
repository: _groupPermissionRepository,
|
||||
manager: _manager,
|
||||
);
|
||||
await synchronizer.syncForGroup(targetGroupId);
|
||||
}
|
||||
|
||||
Future<Map<String, Set<PermissionAction>>> _loadGroupPermissions({
|
||||
int? groupId,
|
||||
}) async {
|
||||
final targetGroupId = await _resolveGroupId(groupId);
|
||||
if (targetGroupId == null) {
|
||||
return const {};
|
||||
}
|
||||
final synchronizer = PermissionSynchronizer(
|
||||
repository: _groupPermissionRepository,
|
||||
manager: _manager,
|
||||
);
|
||||
return synchronizer.fetchPermissionMap(targetGroupId);
|
||||
}
|
||||
|
||||
Future<int?> _resolveGroupId(int? groupId) async {
|
||||
if (groupId != null) {
|
||||
return groupId;
|
||||
}
|
||||
final defaultGroups = await _groupRepository.list(
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
isDefault: true,
|
||||
);
|
||||
var targetGroup = _firstGroupWithId(defaultGroups.items);
|
||||
|
||||
if (targetGroup == null) {
|
||||
final fallbackGroups = await _groupRepository.list(page: 1, pageSize: 1);
|
||||
targetGroup = _firstGroupWithId(fallbackGroups.items);
|
||||
}
|
||||
return targetGroup?.id;
|
||||
}
|
||||
|
||||
Group? _firstGroupWithId(List<Group> groups) {
|
||||
for (final group in groups) {
|
||||
if (group.id != null) {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@ class PermissionManager extends ChangeNotifier {
|
||||
return server.contains(action);
|
||||
}
|
||||
|
||||
if (key.startsWith('scope:')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Environment.hasPermission(key, action.name);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ class PermissionResources {
|
||||
static const String approvalSteps = '/approval-steps';
|
||||
static const String approvalHistories = '/approval-histories';
|
||||
static const String approvalTemplates = '/approval/templates';
|
||||
static const String inventorySummary = '/inventory/summary';
|
||||
static const String inventoryScope = 'scope:inventory.view';
|
||||
static const String groupMenuPermissions = '/group-menu-permissions';
|
||||
static const String vendors = '/vendors';
|
||||
static const String products = '/products';
|
||||
@@ -41,6 +43,7 @@ class PermissionResources {
|
||||
'/approvals/templates': approvalTemplates,
|
||||
'/approval/templates': approvalTemplates,
|
||||
'/approval-templates': approvalTemplates,
|
||||
'/inventory/summary': inventorySummary,
|
||||
'/masters/group-permissions': groupMenuPermissions,
|
||||
'/group-menu-permissions': groupMenuPermissions,
|
||||
'/masters/vendors': vendors,
|
||||
@@ -83,35 +86,39 @@ class PermissionResources {
|
||||
if (trimmed.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
var lowered = trimmed.toLowerCase();
|
||||
final lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith('scope:')) {
|
||||
return lowered;
|
||||
}
|
||||
var normalized = lowered;
|
||||
|
||||
// 절대 URL이 들어오면 path 부분만 추출한다.
|
||||
final uri = Uri.tryParse(lowered);
|
||||
final uri = Uri.tryParse(normalized);
|
||||
if (uri != null && uri.hasScheme) {
|
||||
lowered = uri.path;
|
||||
normalized = uri.path;
|
||||
}
|
||||
|
||||
// 쿼리스트링이나 프래그먼트를 제거해 순수 경로만 남긴다.
|
||||
final queryIndex = lowered.indexOf('?');
|
||||
final queryIndex = normalized.indexOf('?');
|
||||
if (queryIndex != -1) {
|
||||
lowered = lowered.substring(0, queryIndex);
|
||||
normalized = normalized.substring(0, queryIndex);
|
||||
}
|
||||
final hashIndex = lowered.indexOf('#');
|
||||
final hashIndex = normalized.indexOf('#');
|
||||
if (hashIndex != -1) {
|
||||
lowered = lowered.substring(0, hashIndex);
|
||||
normalized = normalized.substring(0, hashIndex);
|
||||
}
|
||||
|
||||
if (!lowered.startsWith('/')) {
|
||||
lowered = '/$lowered';
|
||||
if (!normalized.startsWith('/')) {
|
||||
normalized = '/$normalized';
|
||||
}
|
||||
|
||||
while (lowered.contains('//')) {
|
||||
lowered = lowered.replaceAll('//', '/');
|
||||
while (normalized.contains('//')) {
|
||||
normalized = normalized.replaceAll('//', '/');
|
||||
}
|
||||
|
||||
if (lowered.length > 1 && lowered.endsWith('/')) {
|
||||
lowered = lowered.substring(0, lowered.length - 1);
|
||||
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.substring(0, normalized.length - 1);
|
||||
}
|
||||
return lowered;
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../features/dashboard/presentation/pages/dashboard_page.dart';
|
||||
import '../../features/inventory/inbound/presentation/pages/inbound_page.dart';
|
||||
import '../../features/inventory/outbound/presentation/pages/outbound_page.dart';
|
||||
import '../../features/inventory/rental/presentation/pages/rental_page.dart';
|
||||
import '../../features/inventory/summary/presentation/pages/inventory_summary_page.dart';
|
||||
import '../../features/login/presentation/pages/login_page.dart';
|
||||
import '../../features/masters/customer/presentation/pages/customer_page.dart';
|
||||
import '../../features/masters/group/presentation/pages/group_page.dart';
|
||||
@@ -25,6 +26,7 @@ import '../../features/util/postal_search/presentation/pages/postal_search_page.
|
||||
import '../../widgets/app_shell.dart';
|
||||
import '../constants/app_sections.dart';
|
||||
import '../permissions/permission_manager.dart';
|
||||
import '../permissions/permission_resources.dart';
|
||||
import 'auth_guard.dart';
|
||||
|
||||
/// 전역 네비게이터 키(로그인/셸 라우터 공용).
|
||||
@@ -66,6 +68,21 @@ final appRouter = GoRouter(
|
||||
name: 'dashboard',
|
||||
builder: (context, state) => const DashboardPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: inventorySummaryRoutePath,
|
||||
name: 'inventory-summary',
|
||||
redirect: (context, state) {
|
||||
if (!AuthGuard.can(inventorySummaryRoutePath)) {
|
||||
return dashboardRoutePath;
|
||||
}
|
||||
if (!AuthGuard.can(PermissionResources.inventoryScope)) {
|
||||
return dashboardRoutePath;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
builder: (context, state) =>
|
||||
InventorySummaryPage(routeUri: state.uri),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/inventory/inbound',
|
||||
name: 'inventory-inbound',
|
||||
|
||||
@@ -880,10 +880,7 @@ class _TemplateToolbar extends StatelessWidget {
|
||||
);
|
||||
|
||||
if (!canApplyTemplate) {
|
||||
applyButton = Tooltip(
|
||||
message: '템플릿을 적용할 권한이 없습니다.',
|
||||
child: applyButton,
|
||||
);
|
||||
applyButton = Tooltip(message: '템플릿을 적용할 권한이 없습니다.', child: applyButton);
|
||||
}
|
||||
|
||||
return Column(
|
||||
|
||||
@@ -29,11 +29,7 @@ class ApprovalFormInitializer {
|
||||
controller.setRequester(defaultRequester);
|
||||
}
|
||||
if (draft != null) {
|
||||
await _applyDraft(
|
||||
controller,
|
||||
draft,
|
||||
repository ?? _resolveRepository(),
|
||||
);
|
||||
await _applyDraft(controller, draft, repository ?? _resolveRepository());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -508,7 +508,6 @@ class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> {
|
||||
}
|
||||
idController.dispose();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _InfoBadge extends StatelessWidget {
|
||||
|
||||
@@ -27,12 +27,11 @@ class ApprovalApproverCandidateDto {
|
||||
: null;
|
||||
return ApprovalApproverCandidateDto(
|
||||
id: json['id'] as int? ?? JsonUtils.readInt(json, 'user_id', fallback: 0),
|
||||
employeeNo: json['employee_id'] as String? ??
|
||||
employeeNo:
|
||||
json['employee_id'] as String? ??
|
||||
json['employee_no'] as String? ??
|
||||
'-',
|
||||
name: json['name'] as String? ??
|
||||
json['employee_name'] as String? ??
|
||||
'-',
|
||||
name: json['name'] as String? ?? json['employee_name'] as String? ?? '-',
|
||||
team: group?['group_name'] as String? ?? json['team'] as String?,
|
||||
email: json['email'] as String?,
|
||||
phone: json['phone'] as String? ?? json['mobile_no'] as String?,
|
||||
|
||||
@@ -25,14 +25,19 @@ class AuthSessionDto {
|
||||
final expires = _parseDate(_readString(json, 'expires_at'));
|
||||
final userMap = _readMap(json, 'user');
|
||||
final permissionList = _readList(json, 'permissions');
|
||||
final permissionDtos = permissionList
|
||||
.map(AuthPermissionDto.fromJson)
|
||||
.toList(growable: true);
|
||||
final scopeCodes = _readScopeCodes(json);
|
||||
for (final scope in scopeCodes) {
|
||||
permissionDtos.add(AuthPermissionDto.fromScope(scope));
|
||||
}
|
||||
return AuthSessionDto(
|
||||
accessToken: token ?? '',
|
||||
refreshToken: refresh ?? '',
|
||||
expiresAt: expires,
|
||||
user: _parseUser(userMap),
|
||||
permissions: permissionList
|
||||
.map(AuthPermissionDto.fromJson)
|
||||
.toList(growable: false),
|
||||
permissions: List<AuthPermissionDto>.unmodifiable(permissionDtos),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,6 +92,14 @@ class AuthPermissionDto {
|
||||
);
|
||||
}
|
||||
|
||||
factory AuthPermissionDto.fromScope(String scope) {
|
||||
final normalized = scope.trim();
|
||||
if (normalized.isEmpty) {
|
||||
throw const FormatException('권한 스코프 코드가 비어 있습니다.');
|
||||
}
|
||||
return AuthPermissionDto(resource: normalized, actions: const ['view']);
|
||||
}
|
||||
|
||||
AuthPermission toEntity() =>
|
||||
AuthPermission(resource: resource, actions: actions);
|
||||
}
|
||||
@@ -131,6 +144,65 @@ List<Map<String, dynamic>> _readList(Map<String, dynamic> source, String key) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
Set<String> _readScopeCodes(Map<String, dynamic> source) {
|
||||
final codes = <String>{};
|
||||
|
||||
void addCode(String? raw) {
|
||||
final normalized = raw == null ? null : _normalizeScopeCode(raw);
|
||||
if (normalized != null) {
|
||||
codes.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
void parse(dynamic value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
if (value is String) {
|
||||
addCode(value);
|
||||
return;
|
||||
}
|
||||
if (value is Iterable) {
|
||||
for (final item in value) {
|
||||
parse(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value is Map<String, dynamic>) {
|
||||
for (final key in const [
|
||||
'scope_code',
|
||||
'scope',
|
||||
'code',
|
||||
'permission_code',
|
||||
'permission',
|
||||
'name',
|
||||
'scopeCode',
|
||||
'permissionCode',
|
||||
'scopeName',
|
||||
]) {
|
||||
final candidate = value[key];
|
||||
if (candidate is String && candidate.trim().isNotEmpty) {
|
||||
addCode(candidate);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (final entry in value.entries) {
|
||||
final candidate = entry.value;
|
||||
if (candidate is String && candidate.trim().isNotEmpty) {
|
||||
addCode(candidate);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parse(source['permission_codes']);
|
||||
parse(source['permission_scopes']);
|
||||
parse(source['group_permission_scopes']);
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _readMap(Map<String, dynamic> source, String key) {
|
||||
final value = source[key];
|
||||
if (value is Map<String, dynamic>) {
|
||||
@@ -162,3 +234,15 @@ int? _readOptionalInt(Map<String, dynamic>? source, String key) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _normalizeScopeCode(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith('scope:')) {
|
||||
return lowered;
|
||||
}
|
||||
return 'scope:$lowered';
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ class AuthPermission {
|
||||
Map<String, Set<PermissionAction>> toPermissionMap() {
|
||||
final normalized = PermissionResources.normalize(resource);
|
||||
final actionSet = <PermissionAction>{};
|
||||
final isScope = normalized.startsWith('scope:');
|
||||
for (final raw in actions) {
|
||||
final parsed = _parseAction(raw);
|
||||
if (parsed == null) {
|
||||
@@ -22,6 +23,9 @@ class AuthPermission {
|
||||
}
|
||||
actionSet.add(parsed);
|
||||
}
|
||||
if (actionSet.isEmpty && isScope) {
|
||||
actionSet.add(PermissionAction.view);
|
||||
}
|
||||
if (actionSet.isEmpty) {
|
||||
return <String, Set<PermissionAction>>{};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import '../domain/entities/inventory_detail.dart';
|
||||
import '../domain/entities/inventory_filters.dart';
|
||||
import '../domain/entities/inventory_summary_list_result.dart';
|
||||
import '../domain/repositories/inventory_repository.dart';
|
||||
|
||||
/// 재고 현황 API를 호출하는 애플리케이션 서비스.
|
||||
class InventoryService {
|
||||
const InventoryService({required InventoryRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final InventoryRepository _repository;
|
||||
|
||||
/// 재고 요약 목록을 조회한다.
|
||||
Future<InventorySummaryListResult> fetchSummaries({
|
||||
InventorySummaryFilter? filter,
|
||||
}) {
|
||||
return _repository.listSummaries(filter: filter);
|
||||
}
|
||||
|
||||
/// 특정 제품 상세를 조회한다.
|
||||
Future<InventoryDetail> fetchDetail(
|
||||
int productId, {
|
||||
InventoryDetailFilter? filter,
|
||||
}) {
|
||||
return _repository.fetchDetail(productId, filter: filter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../../domain/entities/inventory_counterparty.dart';
|
||||
import '../../domain/entities/inventory_event.dart';
|
||||
import '../../domain/entities/inventory_product.dart';
|
||||
import '../../domain/entities/inventory_transaction_reference.dart';
|
||||
import '../../domain/entities/inventory_vendor.dart';
|
||||
import '../../domain/entities/inventory_warehouse.dart';
|
||||
import '../../domain/entities/inventory_warehouse_balance.dart';
|
||||
|
||||
part 'inventory_common_dtos.g.dart';
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventoryVendorDto {
|
||||
const InventoryVendorDto({this.id, this.vendorName});
|
||||
|
||||
final int? id;
|
||||
final String? vendorName;
|
||||
|
||||
factory InventoryVendorDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryVendorDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryVendorDtoToJson(this);
|
||||
|
||||
InventoryVendor toEntity() {
|
||||
return InventoryVendor(id: id, name: (vendorName ?? '').trim());
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventoryProductDto {
|
||||
const InventoryProductDto({
|
||||
required this.id,
|
||||
this.productCode,
|
||||
this.productName,
|
||||
this.vendor,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String? productCode;
|
||||
final String? productName;
|
||||
final InventoryVendorDto? vendor;
|
||||
|
||||
factory InventoryProductDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryProductDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryProductDtoToJson(this);
|
||||
|
||||
InventoryProduct toEntity() {
|
||||
return InventoryProduct(
|
||||
id: id,
|
||||
code: (productCode ?? '').trim(),
|
||||
name: (productName ?? '').trim(),
|
||||
vendor: vendor?.toEntity(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventoryWarehouseDto {
|
||||
const InventoryWarehouseDto({
|
||||
required this.id,
|
||||
this.warehouseCode,
|
||||
this.warehouseName,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String? warehouseCode;
|
||||
final String? warehouseName;
|
||||
|
||||
factory InventoryWarehouseDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryWarehouseDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryWarehouseDtoToJson(this);
|
||||
|
||||
InventoryWarehouse toEntity() => InventoryWarehouse(
|
||||
id: id,
|
||||
code: (warehouseCode ?? '').trim(),
|
||||
name: (warehouseName ?? '').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventoryWarehouseBalanceDto {
|
||||
const InventoryWarehouseBalanceDto({
|
||||
required this.warehouse,
|
||||
required this.quantity,
|
||||
});
|
||||
|
||||
final InventoryWarehouseDto warehouse;
|
||||
@JsonKey(fromJson: _parseQuantity)
|
||||
final int quantity;
|
||||
|
||||
factory InventoryWarehouseBalanceDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryWarehouseBalanceDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryWarehouseBalanceDtoToJson(this);
|
||||
|
||||
InventoryWarehouseBalance toEntity() => InventoryWarehouseBalance(
|
||||
warehouse: warehouse.toEntity(),
|
||||
quantity: quantity,
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake)
|
||||
class InventoryCounterpartyDto {
|
||||
const InventoryCounterpartyDto({this.type, this.name});
|
||||
|
||||
final String? type;
|
||||
final String? name;
|
||||
|
||||
factory InventoryCounterpartyDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryCounterpartyDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryCounterpartyDtoToJson(this);
|
||||
|
||||
InventoryCounterparty toEntity() {
|
||||
final normalized = (type ?? '').toLowerCase();
|
||||
InventoryCounterpartyType resolvedType;
|
||||
switch (normalized) {
|
||||
case 'vendor':
|
||||
resolvedType = InventoryCounterpartyType.vendor;
|
||||
break;
|
||||
case 'customer':
|
||||
resolvedType = InventoryCounterpartyType.customer;
|
||||
break;
|
||||
default:
|
||||
resolvedType = InventoryCounterpartyType.unknown;
|
||||
break;
|
||||
}
|
||||
return InventoryCounterparty(type: resolvedType, name: name?.trim());
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake)
|
||||
class InventoryTransactionRefDto {
|
||||
const InventoryTransactionRefDto({required this.id, this.transactionNo});
|
||||
|
||||
final int id;
|
||||
final String? transactionNo;
|
||||
|
||||
factory InventoryTransactionRefDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryTransactionRefDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryTransactionRefDtoToJson(this);
|
||||
|
||||
InventoryTransactionReference toEntity() => InventoryTransactionReference(
|
||||
id: id,
|
||||
transactionNo: (transactionNo ?? '').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake)
|
||||
class InventoryEventLineRefDto {
|
||||
const InventoryEventLineRefDto({
|
||||
required this.id,
|
||||
this.lineNo,
|
||||
this.quantity,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int? lineNo;
|
||||
@JsonKey(fromJson: _parseNullableQuantity)
|
||||
final int? quantity;
|
||||
|
||||
factory InventoryEventLineRefDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryEventLineRefDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryEventLineRefDtoToJson(this);
|
||||
|
||||
InventoryEventLineReference toEntity() => InventoryEventLineReference(
|
||||
id: id,
|
||||
lineNo: lineNo ?? 0,
|
||||
quantity: quantity ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventoryEventDto {
|
||||
const InventoryEventDto({
|
||||
required this.eventId,
|
||||
required this.eventKind,
|
||||
required this.eventLabel,
|
||||
required this.deltaQuantity,
|
||||
required this.occurredAt,
|
||||
this.counterparty,
|
||||
this.warehouse,
|
||||
this.transaction,
|
||||
this.line,
|
||||
});
|
||||
|
||||
final int eventId;
|
||||
final String eventKind;
|
||||
final String eventLabel;
|
||||
@JsonKey(fromJson: _parseQuantity)
|
||||
final int deltaQuantity;
|
||||
final DateTime occurredAt;
|
||||
final InventoryCounterpartyDto? counterparty;
|
||||
final InventoryWarehouseDto? warehouse;
|
||||
final InventoryTransactionRefDto? transaction;
|
||||
final InventoryEventLineRefDto? line;
|
||||
|
||||
factory InventoryEventDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryEventDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryEventDtoToJson(this);
|
||||
|
||||
InventoryEvent toEntity() => InventoryEvent(
|
||||
eventId: eventId,
|
||||
eventKind: eventKind,
|
||||
eventLabel: eventLabel,
|
||||
deltaQuantity: deltaQuantity,
|
||||
occurredAt: occurredAt,
|
||||
counterparty: counterparty?.toEntity(),
|
||||
warehouse: warehouse?.toEntity(),
|
||||
transaction: transaction?.toEntity(),
|
||||
line: line?.toEntity(),
|
||||
);
|
||||
}
|
||||
|
||||
int _parseQuantity(dynamic value) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.round();
|
||||
}
|
||||
if (value is String) {
|
||||
final sanitized = value.replaceAll(',', '').trim();
|
||||
if (sanitized.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
final parsed = num.tryParse(sanitized);
|
||||
if (parsed != null) {
|
||||
return parsed.round();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int? _parseNullableQuantity(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return _parseQuantity(value);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'inventory_common_dtos.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
InventoryVendorDto _$InventoryVendorDtoFromJson(Map<String, dynamic> json) =>
|
||||
InventoryVendorDto(
|
||||
id: (json['id'] as num?)?.toInt(),
|
||||
vendorName: json['vendor_name'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryVendorDtoToJson(InventoryVendorDto instance) =>
|
||||
<String, dynamic>{'id': instance.id, 'vendor_name': instance.vendorName};
|
||||
|
||||
InventoryProductDto _$InventoryProductDtoFromJson(Map<String, dynamic> json) =>
|
||||
InventoryProductDto(
|
||||
id: (json['id'] as num).toInt(),
|
||||
productCode: json['product_code'] as String?,
|
||||
productName: json['product_name'] as String?,
|
||||
vendor: json['vendor'] == null
|
||||
? null
|
||||
: InventoryVendorDto.fromJson(json['vendor'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryProductDtoToJson(
|
||||
InventoryProductDto instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'product_code': instance.productCode,
|
||||
'product_name': instance.productName,
|
||||
'vendor': instance.vendor?.toJson(),
|
||||
};
|
||||
|
||||
InventoryWarehouseDto _$InventoryWarehouseDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventoryWarehouseDto(
|
||||
id: (json['id'] as num).toInt(),
|
||||
warehouseCode: json['warehouse_code'] as String?,
|
||||
warehouseName: json['warehouse_name'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryWarehouseDtoToJson(
|
||||
InventoryWarehouseDto instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'warehouse_code': instance.warehouseCode,
|
||||
'warehouse_name': instance.warehouseName,
|
||||
};
|
||||
|
||||
InventoryWarehouseBalanceDto _$InventoryWarehouseBalanceDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventoryWarehouseBalanceDto(
|
||||
warehouse: InventoryWarehouseDto.fromJson(
|
||||
json['warehouse'] as Map<String, dynamic>,
|
||||
),
|
||||
quantity: _parseQuantity(json['quantity']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryWarehouseBalanceDtoToJson(
|
||||
InventoryWarehouseBalanceDto instance,
|
||||
) => <String, dynamic>{
|
||||
'warehouse': instance.warehouse.toJson(),
|
||||
'quantity': instance.quantity,
|
||||
};
|
||||
|
||||
InventoryCounterpartyDto _$InventoryCounterpartyDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventoryCounterpartyDto(
|
||||
type: json['type'] as String?,
|
||||
name: json['name'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryCounterpartyDtoToJson(
|
||||
InventoryCounterpartyDto instance,
|
||||
) => <String, dynamic>{'type': instance.type, 'name': instance.name};
|
||||
|
||||
InventoryTransactionRefDto _$InventoryTransactionRefDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventoryTransactionRefDto(
|
||||
id: (json['id'] as num).toInt(),
|
||||
transactionNo: json['transaction_no'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryTransactionRefDtoToJson(
|
||||
InventoryTransactionRefDto instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'transaction_no': instance.transactionNo,
|
||||
};
|
||||
|
||||
InventoryEventLineRefDto _$InventoryEventLineRefDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventoryEventLineRefDto(
|
||||
id: (json['id'] as num).toInt(),
|
||||
lineNo: (json['line_no'] as num?)?.toInt(),
|
||||
quantity: _parseNullableQuantity(json['quantity']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryEventLineRefDtoToJson(
|
||||
InventoryEventLineRefDto instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'line_no': instance.lineNo,
|
||||
'quantity': instance.quantity,
|
||||
};
|
||||
|
||||
InventoryEventDto _$InventoryEventDtoFromJson(Map<String, dynamic> json) =>
|
||||
InventoryEventDto(
|
||||
eventId: (json['event_id'] as num).toInt(),
|
||||
eventKind: json['event_kind'] as String,
|
||||
eventLabel: json['event_label'] as String,
|
||||
deltaQuantity: _parseQuantity(json['delta_quantity']),
|
||||
occurredAt: DateTime.parse(json['occurred_at'] as String),
|
||||
counterparty: json['counterparty'] == null
|
||||
? null
|
||||
: InventoryCounterpartyDto.fromJson(
|
||||
json['counterparty'] as Map<String, dynamic>,
|
||||
),
|
||||
warehouse: json['warehouse'] == null
|
||||
? null
|
||||
: InventoryWarehouseDto.fromJson(
|
||||
json['warehouse'] as Map<String, dynamic>,
|
||||
),
|
||||
transaction: json['transaction'] == null
|
||||
? null
|
||||
: InventoryTransactionRefDto.fromJson(
|
||||
json['transaction'] as Map<String, dynamic>,
|
||||
),
|
||||
line: json['line'] == null
|
||||
? null
|
||||
: InventoryEventLineRefDto.fromJson(
|
||||
json['line'] as Map<String, dynamic>,
|
||||
),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryEventDtoToJson(InventoryEventDto instance) =>
|
||||
<String, dynamic>{
|
||||
'event_id': instance.eventId,
|
||||
'event_kind': instance.eventKind,
|
||||
'event_label': instance.eventLabel,
|
||||
'delta_quantity': instance.deltaQuantity,
|
||||
'occurred_at': instance.occurredAt.toIso8601String(),
|
||||
'counterparty': instance.counterparty?.toJson(),
|
||||
'warehouse': instance.warehouse?.toJson(),
|
||||
'transaction': instance.transaction?.toJson(),
|
||||
'line': instance.line?.toJson(),
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../../domain/entities/inventory_detail.dart';
|
||||
import 'inventory_common_dtos.dart';
|
||||
|
||||
part 'inventory_detail_response.g.dart';
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventoryDetailResponse {
|
||||
const InventoryDetailResponse({required this.data});
|
||||
|
||||
final InventoryDetailDataDto data;
|
||||
|
||||
factory InventoryDetailResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryDetailResponseFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryDetailResponseToJson(this);
|
||||
|
||||
InventoryDetail toEntity() => data.toEntity();
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventoryDetailDataDto {
|
||||
const InventoryDetailDataDto({
|
||||
required this.product,
|
||||
required this.totalQuantity,
|
||||
List<InventoryWarehouseBalanceDto>? warehouseBalances,
|
||||
List<InventoryEventDto>? recentEvents,
|
||||
this.updatedAt,
|
||||
this.lastRefreshedAt,
|
||||
}) : warehouseBalances = warehouseBalances ?? const [],
|
||||
recentEvents = recentEvents ?? const [];
|
||||
|
||||
final InventoryProductDto product;
|
||||
@JsonKey(fromJson: _parseQuantity)
|
||||
final int totalQuantity;
|
||||
@JsonKey(defaultValue: <InventoryWarehouseBalanceDto>[])
|
||||
final List<InventoryWarehouseBalanceDto> warehouseBalances;
|
||||
@JsonKey(defaultValue: <InventoryEventDto>[])
|
||||
final List<InventoryEventDto> recentEvents;
|
||||
final DateTime? updatedAt;
|
||||
final DateTime? lastRefreshedAt;
|
||||
|
||||
factory InventoryDetailDataDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryDetailDataDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryDetailDataDtoToJson(this);
|
||||
|
||||
InventoryDetail toEntity() => InventoryDetail(
|
||||
product: product.toEntity(),
|
||||
totalQuantity: totalQuantity,
|
||||
warehouseBalances: warehouseBalances
|
||||
.map((balance) => balance.toEntity())
|
||||
.toList(growable: false),
|
||||
recentEvents: recentEvents
|
||||
.map((event) => event.toEntity())
|
||||
.toList(growable: false),
|
||||
updatedAt: updatedAt,
|
||||
lastRefreshedAt: lastRefreshedAt,
|
||||
);
|
||||
}
|
||||
|
||||
int _parseQuantity(dynamic value) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.round();
|
||||
}
|
||||
if (value is String) {
|
||||
final sanitized = value.replaceAll(',', '').trim();
|
||||
if (sanitized.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
final parsed = num.tryParse(sanitized);
|
||||
if (parsed != null) {
|
||||
return parsed.round();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'inventory_detail_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
InventoryDetailResponse _$InventoryDetailResponseFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventoryDetailResponse(
|
||||
data: InventoryDetailDataDto.fromJson(json['data'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryDetailResponseToJson(
|
||||
InventoryDetailResponse instance,
|
||||
) => <String, dynamic>{'data': instance.data.toJson()};
|
||||
|
||||
InventoryDetailDataDto _$InventoryDetailDataDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventoryDetailDataDto(
|
||||
product: InventoryProductDto.fromJson(
|
||||
json['product'] as Map<String, dynamic>,
|
||||
),
|
||||
totalQuantity: _parseQuantity(json['total_quantity']),
|
||||
warehouseBalances:
|
||||
(json['warehouse_balances'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => InventoryWarehouseBalanceDto.fromJson(
|
||||
e as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
recentEvents:
|
||||
(json['recent_events'] as List<dynamic>?)
|
||||
?.map((e) => InventoryEventDto.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
updatedAt: json['updated_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['updated_at'] as String),
|
||||
lastRefreshedAt: json['last_refreshed_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['last_refreshed_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryDetailDataDtoToJson(
|
||||
InventoryDetailDataDto instance,
|
||||
) => <String, dynamic>{
|
||||
'product': instance.product.toJson(),
|
||||
'total_quantity': instance.totalQuantity,
|
||||
'warehouse_balances': instance.warehouseBalances
|
||||
.map((e) => e.toJson())
|
||||
.toList(),
|
||||
'recent_events': instance.recentEvents.map((e) => e.toJson()).toList(),
|
||||
'updated_at': instance.updatedAt?.toIso8601String(),
|
||||
'last_refreshed_at': instance.lastRefreshedAt?.toIso8601String(),
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
|
||||
import '../../domain/entities/inventory_summary.dart';
|
||||
import '../../domain/entities/inventory_summary_list_result.dart';
|
||||
import 'inventory_common_dtos.dart';
|
||||
|
||||
part 'inventory_summary_response.g.dart';
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventorySummaryResponse {
|
||||
const InventorySummaryResponse({
|
||||
List<InventorySummaryItemDto>? items,
|
||||
this.page = 1,
|
||||
this.pageSize = 0,
|
||||
this.total = 0,
|
||||
this.lastRefreshedAt,
|
||||
}) : items = items ?? const [];
|
||||
|
||||
@JsonKey(defaultValue: <InventorySummaryItemDto>[])
|
||||
final List<InventorySummaryItemDto> items;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final int total;
|
||||
final DateTime? lastRefreshedAt;
|
||||
|
||||
factory InventorySummaryResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventorySummaryResponseFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventorySummaryResponseToJson(this);
|
||||
|
||||
InventorySummaryListResult toEntity() {
|
||||
final summaries = items
|
||||
.map((item) => item.toEntity())
|
||||
.toList(growable: false);
|
||||
final paginated = PaginatedResult<InventorySummary>(
|
||||
items: summaries,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
);
|
||||
return InventorySummaryListResult(
|
||||
result: paginated,
|
||||
lastRefreshedAt:
|
||||
lastRefreshedAt ??
|
||||
(summaries.isNotEmpty ? summaries.first.lastRefreshedAt : null),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventorySummaryItemDto {
|
||||
const InventorySummaryItemDto({
|
||||
required this.product,
|
||||
required this.totalQuantity,
|
||||
List<InventoryWarehouseBalanceDto>? warehouseBalances,
|
||||
this.recentEvent,
|
||||
this.updatedAt,
|
||||
this.lastRefreshedAt,
|
||||
}) : warehouseBalances = warehouseBalances ?? const [];
|
||||
|
||||
final InventoryProductDto product;
|
||||
@JsonKey(fromJson: _parseQuantity)
|
||||
final int totalQuantity;
|
||||
@JsonKey(defaultValue: <InventoryWarehouseBalanceDto>[])
|
||||
final List<InventoryWarehouseBalanceDto> warehouseBalances;
|
||||
final InventoryEventDto? recentEvent;
|
||||
final DateTime? updatedAt;
|
||||
final DateTime? lastRefreshedAt;
|
||||
|
||||
factory InventorySummaryItemDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventorySummaryItemDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventorySummaryItemDtoToJson(this);
|
||||
|
||||
InventorySummary toEntity() {
|
||||
final balances = warehouseBalances
|
||||
.map((balance) => balance.toEntity())
|
||||
.toList(growable: false);
|
||||
return InventorySummary(
|
||||
product: product.toEntity(),
|
||||
totalQuantity: totalQuantity,
|
||||
warehouseBalances: balances,
|
||||
recentEvent: recentEvent?.toEntity(),
|
||||
updatedAt: updatedAt,
|
||||
lastRefreshedAt: lastRefreshedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int _parseQuantity(dynamic value) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.round();
|
||||
}
|
||||
if (value is String) {
|
||||
final sanitized = value.replaceAll(',', '').trim();
|
||||
if (sanitized.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
final parsed = num.tryParse(sanitized);
|
||||
if (parsed != null) {
|
||||
return parsed.round();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'inventory_summary_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
InventorySummaryResponse _$InventorySummaryResponseFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventorySummaryResponse(
|
||||
items:
|
||||
(json['items'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => InventorySummaryItemDto.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
page: (json['page'] as num?)?.toInt() ?? 1,
|
||||
pageSize: (json['page_size'] as num?)?.toInt() ?? 0,
|
||||
total: (json['total'] as num?)?.toInt() ?? 0,
|
||||
lastRefreshedAt: json['last_refreshed_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['last_refreshed_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventorySummaryResponseToJson(
|
||||
InventorySummaryResponse instance,
|
||||
) => <String, dynamic>{
|
||||
'items': instance.items.map((e) => e.toJson()).toList(),
|
||||
'page': instance.page,
|
||||
'page_size': instance.pageSize,
|
||||
'total': instance.total,
|
||||
'last_refreshed_at': instance.lastRefreshedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
InventorySummaryItemDto _$InventorySummaryItemDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventorySummaryItemDto(
|
||||
product: InventoryProductDto.fromJson(
|
||||
json['product'] as Map<String, dynamic>,
|
||||
),
|
||||
totalQuantity: _parseQuantity(json['total_quantity']),
|
||||
warehouseBalances:
|
||||
(json['warehouse_balances'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => InventoryWarehouseBalanceDto.fromJson(
|
||||
e as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
recentEvent: json['recent_event'] == null
|
||||
? null
|
||||
: InventoryEventDto.fromJson(
|
||||
json['recent_event'] as Map<String, dynamic>,
|
||||
),
|
||||
updatedAt: json['updated_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['updated_at'] as String),
|
||||
lastRefreshedAt: json['last_refreshed_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['last_refreshed_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventorySummaryItemDtoToJson(
|
||||
InventorySummaryItemDto instance,
|
||||
) => <String, dynamic>{
|
||||
'product': instance.product.toJson(),
|
||||
'total_quantity': instance.totalQuantity,
|
||||
'warehouse_balances': instance.warehouseBalances
|
||||
.map((e) => e.toJson())
|
||||
.toList(),
|
||||
'recent_event': instance.recentEvent?.toJson(),
|
||||
'updated_at': instance.updatedAt?.toIso8601String(),
|
||||
'last_refreshed_at': instance.lastRefreshedAt?.toIso8601String(),
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:superport_v2/core/network/api_client.dart';
|
||||
import 'package:superport_v2/core/network/api_routes.dart';
|
||||
|
||||
import '../../domain/entities/inventory_detail.dart';
|
||||
import '../../domain/entities/inventory_filters.dart';
|
||||
import '../../domain/entities/inventory_summary_list_result.dart';
|
||||
import '../../domain/repositories/inventory_repository.dart';
|
||||
import '../dtos/inventory_detail_response.dart';
|
||||
import '../dtos/inventory_summary_response.dart';
|
||||
|
||||
/// 재고 현황 API를 호출하는 원격 저장소 구현체.
|
||||
class InventoryRepositoryRemote implements InventoryRepository {
|
||||
InventoryRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
static const _summaryPath = ApiRoutes.inventorySummary;
|
||||
|
||||
@override
|
||||
Future<InventorySummaryListResult> listSummaries({
|
||||
InventorySummaryFilter? filter,
|
||||
}) async {
|
||||
final effectiveFilter = filter ?? const InventorySummaryFilter();
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_summaryPath,
|
||||
query: effectiveFilter.toQuery(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
final body = response.data ?? const <String, dynamic>{};
|
||||
return InventorySummaryResponse.fromJson(body).toEntity();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<InventoryDetail> fetchDetail(
|
||||
int productId, {
|
||||
InventoryDetailFilter? filter,
|
||||
}) async {
|
||||
final effectiveFilter = filter ?? const InventoryDetailFilter();
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
ApiRoutes.inventorySummaryDetail(productId),
|
||||
query: effectiveFilter.toQuery(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
final body = response.data ?? const <String, dynamic>{};
|
||||
return InventoryDetailResponse.fromJson(body).toEntity();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/// 재고 이벤트와 연결된 거래처 유형.
|
||||
enum InventoryCounterpartyType { vendor, customer, unknown }
|
||||
|
||||
/// 재고 이벤트의 거래처 정보를 표현한다.
|
||||
class InventoryCounterparty {
|
||||
const InventoryCounterparty({required this.type, this.name});
|
||||
|
||||
final InventoryCounterpartyType type;
|
||||
final String? name;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'inventory_event.dart';
|
||||
import 'inventory_product.dart';
|
||||
import 'inventory_warehouse_balance.dart';
|
||||
|
||||
/// 재고 현황 단건 조회 결과.
|
||||
class InventoryDetail {
|
||||
const InventoryDetail({
|
||||
required this.product,
|
||||
required this.totalQuantity,
|
||||
required this.warehouseBalances,
|
||||
required this.recentEvents,
|
||||
this.updatedAt,
|
||||
this.lastRefreshedAt,
|
||||
});
|
||||
|
||||
final InventoryProduct product;
|
||||
final int totalQuantity;
|
||||
final List<InventoryWarehouseBalance> warehouseBalances;
|
||||
final List<InventoryEvent> recentEvents;
|
||||
final DateTime? updatedAt;
|
||||
final DateTime? lastRefreshedAt;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'inventory_counterparty.dart';
|
||||
import 'inventory_transaction_reference.dart';
|
||||
import 'inventory_warehouse.dart';
|
||||
|
||||
/// 재고 변동 이벤트 요약/상세 정보.
|
||||
class InventoryEvent {
|
||||
const InventoryEvent({
|
||||
required this.eventId,
|
||||
required this.eventKind,
|
||||
required this.eventLabel,
|
||||
required this.deltaQuantity,
|
||||
required this.occurredAt,
|
||||
this.counterparty,
|
||||
this.warehouse,
|
||||
this.transaction,
|
||||
this.line,
|
||||
});
|
||||
|
||||
/// 이벤트 식별자.
|
||||
final int eventId;
|
||||
|
||||
/// 이벤트 종류(`receipt`, `issue`, `rental_out`, `rental_return`).
|
||||
final String eventKind;
|
||||
|
||||
/// 현지화된 이벤트 라벨.
|
||||
final String eventLabel;
|
||||
|
||||
/// 수량 증감.
|
||||
final int deltaQuantity;
|
||||
|
||||
/// 발생 시각(UTC).
|
||||
final DateTime occurredAt;
|
||||
|
||||
/// 거래처 정보.
|
||||
final InventoryCounterparty? counterparty;
|
||||
|
||||
/// 이벤트가 발생한 창고 정보.
|
||||
final InventoryWarehouse? warehouse;
|
||||
|
||||
/// 연결된 전표 정보.
|
||||
final InventoryTransactionReference? transaction;
|
||||
|
||||
/// 연결된 라인 정보.
|
||||
final InventoryEventLineReference? line;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/// 재고 요약 목록 조회 필터.
|
||||
class InventorySummaryFilter {
|
||||
const InventorySummaryFilter({
|
||||
this.page = 1,
|
||||
this.pageSize = 50,
|
||||
this.query,
|
||||
this.productName,
|
||||
this.vendorName,
|
||||
this.warehouseId,
|
||||
this.includeEmpty = false,
|
||||
this.updatedSince,
|
||||
this.sort,
|
||||
this.order,
|
||||
});
|
||||
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final String? query;
|
||||
final String? productName;
|
||||
final String? vendorName;
|
||||
final int? warehouseId;
|
||||
final bool includeEmpty;
|
||||
final DateTime? updatedSince;
|
||||
final String? sort;
|
||||
final String? order;
|
||||
|
||||
/// API 요청에 사용할 쿼리 파라미터 맵을 생성한다.
|
||||
Map<String, dynamic> toQuery() {
|
||||
final queryMap = <String, dynamic>{'page': page, 'page_size': pageSize};
|
||||
void put(String key, dynamic value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
if (value is String) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return;
|
||||
}
|
||||
queryMap[key] = trimmed;
|
||||
return;
|
||||
}
|
||||
queryMap[key] = value;
|
||||
}
|
||||
|
||||
put('q', query);
|
||||
put('product_name', productName);
|
||||
put('vendor_name', vendorName);
|
||||
if (warehouseId != null) {
|
||||
queryMap['warehouse_id'] = warehouseId;
|
||||
}
|
||||
if (includeEmpty) {
|
||||
queryMap['include_empty'] = 'true';
|
||||
}
|
||||
if (updatedSince != null) {
|
||||
queryMap['updated_since'] = updatedSince!.toUtc().toIso8601String();
|
||||
}
|
||||
put('sort', sort);
|
||||
final normalizedOrder = order?.trim().toLowerCase();
|
||||
if (normalizedOrder != null && normalizedOrder.isNotEmpty) {
|
||||
queryMap['order'] = normalizedOrder;
|
||||
}
|
||||
return queryMap;
|
||||
}
|
||||
}
|
||||
|
||||
/// 재고 단건 조회 필터.
|
||||
class InventoryDetailFilter {
|
||||
const InventoryDetailFilter({this.warehouseId, this.eventLimit = 20});
|
||||
|
||||
final int? warehouseId;
|
||||
final int eventLimit;
|
||||
|
||||
Map<String, dynamic> toQuery() {
|
||||
final map = <String, dynamic>{'event_limit': eventLimit};
|
||||
if (warehouseId != null) {
|
||||
map['warehouse_id'] = warehouseId;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'inventory_vendor.dart';
|
||||
|
||||
/// 재고 요약/상세 뷰에서 공통으로 사용하는 제품 정보.
|
||||
class InventoryProduct {
|
||||
const InventoryProduct({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
this.vendor,
|
||||
});
|
||||
|
||||
/// 제품 식별자.
|
||||
final int id;
|
||||
|
||||
/// 제품 코드.
|
||||
final String code;
|
||||
|
||||
/// 제품 명칭.
|
||||
final String name;
|
||||
|
||||
/// 공급사 정보. 없을 수도 있다.
|
||||
final InventoryVendor? vendor;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'inventory_event.dart';
|
||||
import 'inventory_product.dart';
|
||||
import 'inventory_warehouse_balance.dart';
|
||||
|
||||
/// 재고 현황 목록 항목 엔티티.
|
||||
class InventorySummary {
|
||||
const InventorySummary({
|
||||
required this.product,
|
||||
required this.totalQuantity,
|
||||
required this.warehouseBalances,
|
||||
this.recentEvent,
|
||||
this.updatedAt,
|
||||
this.lastRefreshedAt,
|
||||
});
|
||||
|
||||
final InventoryProduct product;
|
||||
final int totalQuantity;
|
||||
final List<InventoryWarehouseBalance> warehouseBalances;
|
||||
final InventoryEvent? recentEvent;
|
||||
final DateTime? updatedAt;
|
||||
final DateTime? lastRefreshedAt;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
|
||||
import 'inventory_summary.dart';
|
||||
|
||||
/// 재고 요약 목록과 뷰 리프레시 메타데이터를 함께 담는 결과 모델.
|
||||
class InventorySummaryListResult {
|
||||
const InventorySummaryListResult({
|
||||
required this.result,
|
||||
this.lastRefreshedAt,
|
||||
});
|
||||
|
||||
final PaginatedResult<InventorySummary> result;
|
||||
final DateTime? lastRefreshedAt;
|
||||
|
||||
InventorySummaryListResult copyWith({
|
||||
PaginatedResult<InventorySummary>? result,
|
||||
DateTime? lastRefreshedAt,
|
||||
}) {
|
||||
return InventorySummaryListResult(
|
||||
result: result ?? this.result,
|
||||
lastRefreshedAt: lastRefreshedAt ?? this.lastRefreshedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// 재고 이벤트가 속한 전표 정보를 요약한 참조 모델.
|
||||
class InventoryTransactionReference {
|
||||
const InventoryTransactionReference({
|
||||
required this.id,
|
||||
required this.transactionNo,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String transactionNo;
|
||||
}
|
||||
|
||||
/// 재고 이벤트 라인 정보 참조 모델.
|
||||
class InventoryEventLineReference {
|
||||
const InventoryEventLineReference({
|
||||
required this.id,
|
||||
required this.lineNo,
|
||||
required this.quantity,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int lineNo;
|
||||
final int quantity;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/// 재고 요약에서 사용하는 공급사 정보를 표현하는 값 객체.
|
||||
class InventoryVendor {
|
||||
const InventoryVendor({this.id, required this.name});
|
||||
|
||||
/// 공급사 식별자. 미정의일 수 있다.
|
||||
final int? id;
|
||||
|
||||
/// 공급사 명칭.
|
||||
final String name;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/// 재고 현황에서 참조하는 창고 정보를 표현한다.
|
||||
class InventoryWarehouse {
|
||||
const InventoryWarehouse({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
/// 창고 식별자.
|
||||
final int id;
|
||||
|
||||
/// 창고 코드.
|
||||
final String code;
|
||||
|
||||
/// 창고 명칭.
|
||||
final String name;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'inventory_warehouse.dart';
|
||||
|
||||
/// 특정 창고의 재고 수량을 나타내는 모델.
|
||||
class InventoryWarehouseBalance {
|
||||
const InventoryWarehouseBalance({
|
||||
required this.warehouse,
|
||||
required this.quantity,
|
||||
});
|
||||
|
||||
/// 창고 정보.
|
||||
final InventoryWarehouse warehouse;
|
||||
|
||||
/// 창고 내 잔량.
|
||||
final int quantity;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import '../entities/inventory_detail.dart';
|
||||
import '../entities/inventory_filters.dart';
|
||||
import '../entities/inventory_summary_list_result.dart';
|
||||
|
||||
/// 재고 현황 데이터를 제공하는 저장소 인터페이스.
|
||||
abstract class InventoryRepository {
|
||||
/// 재고 요약 목록을 조회한다.
|
||||
Future<InventorySummaryListResult> listSummaries({
|
||||
InventorySummaryFilter? filter,
|
||||
});
|
||||
|
||||
/// 특정 제품의 상세 정보를 조회한다.
|
||||
Future<InventoryDetail> fetchDetail(
|
||||
int productId, {
|
||||
InventoryDetailFilter? filter,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -9,13 +9,12 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../../../../core/constants/app_sections.dart';
|
||||
import '../../../../core/network/api_error.dart';
|
||||
import '../../../../core/network/failure.dart';
|
||||
import '../../../../core/permissions/permission_bootstrapper.dart';
|
||||
import '../../../../core/permissions/permission_manager.dart';
|
||||
import '../../../auth/application/auth_service.dart';
|
||||
import '../../../auth/domain/entities/auth_session.dart';
|
||||
import '../../../auth/domain/entities/login_request.dart';
|
||||
import '../../../masters/group/domain/entities/group.dart';
|
||||
import '../../../masters/group/domain/repositories/group_repository.dart';
|
||||
import '../../../masters/group_permission/application/permission_synchronizer.dart';
|
||||
import '../../../masters/group_permission/domain/repositories/group_permission_repository.dart';
|
||||
|
||||
/// Superport 로그인 화면. 간단한 유효성 검증 후 대시보드로 이동한다.
|
||||
@@ -356,66 +355,11 @@ class _LoginPageState extends State<LoginPage> {
|
||||
}
|
||||
|
||||
Future<void> _applyPermissions(AuthSession session) async {
|
||||
final manager = PermissionScope.of(context);
|
||||
manager.clearServerPermissions();
|
||||
|
||||
final aggregated = <String, Set<PermissionAction>>{};
|
||||
for (final permission in session.permissions) {
|
||||
final map = permission.toPermissionMap();
|
||||
for (final entry in map.entries) {
|
||||
aggregated
|
||||
.putIfAbsent(entry.key, () => <PermissionAction>{})
|
||||
.addAll(entry.value);
|
||||
}
|
||||
}
|
||||
if (aggregated.isNotEmpty) {
|
||||
manager.applyServerPermissions(aggregated);
|
||||
return;
|
||||
}
|
||||
|
||||
await _synchronizePermissions(groupId: session.user.primaryGroupId);
|
||||
}
|
||||
|
||||
Future<void> _synchronizePermissions({int? groupId}) async {
|
||||
final manager = PermissionScope.of(context);
|
||||
manager.clearServerPermissions();
|
||||
|
||||
final groupRepository = GetIt.I<GroupRepository>();
|
||||
int? targetGroupId = groupId;
|
||||
|
||||
if (targetGroupId == null) {
|
||||
final defaultGroups = await groupRepository.list(
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
isDefault: true,
|
||||
);
|
||||
var targetGroup = _firstGroupWithId(defaultGroups.items);
|
||||
|
||||
if (targetGroup == null) {
|
||||
final fallbackGroups = await groupRepository.list(page: 1, pageSize: 1);
|
||||
targetGroup = _firstGroupWithId(fallbackGroups.items);
|
||||
}
|
||||
targetGroupId = targetGroup?.id;
|
||||
}
|
||||
|
||||
if (targetGroupId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final permissionRepository = GetIt.I<GroupPermissionRepository>();
|
||||
final synchronizer = PermissionSynchronizer(
|
||||
repository: permissionRepository,
|
||||
manager: manager,
|
||||
final bootstrapper = PermissionBootstrapper(
|
||||
manager: PermissionScope.of(context),
|
||||
groupRepository: GetIt.I<GroupRepository>(),
|
||||
groupPermissionRepository: GetIt.I<GroupPermissionRepository>(),
|
||||
);
|
||||
await synchronizer.syncForGroup(targetGroupId);
|
||||
}
|
||||
|
||||
Group? _firstGroupWithId(List<Group> groups) {
|
||||
for (final group in groups) {
|
||||
if (group.id != null) {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
await bootstrapper.apply(session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,22 @@ class PermissionSynchronizer {
|
||||
|
||||
/// 지정한 [groupId]의 메뉴 권한을 조회해 [PermissionManager]에 적용한다.
|
||||
Future<void> syncForGroup(int groupId) async {
|
||||
final permissionMap = await fetchPermissionMap(groupId);
|
||||
_manager.applyServerPermissions(permissionMap);
|
||||
}
|
||||
|
||||
/// 지정한 [groupId]의 메뉴 권한을 조회해 맵 형태로 반환한다.
|
||||
Future<Map<String, Set<PermissionAction>>> fetchPermissionMap(
|
||||
int groupId,
|
||||
) async {
|
||||
final collected = await _collectPermissions(groupId);
|
||||
if (collected.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
return buildPermissionMap(collected);
|
||||
}
|
||||
|
||||
Future<List<GroupPermission>> _collectPermissions(int groupId) async {
|
||||
final collected = <GroupPermission>[];
|
||||
var page = 1;
|
||||
|
||||
@@ -45,7 +61,6 @@ class PermissionSynchronizer {
|
||||
page += 1;
|
||||
}
|
||||
|
||||
final permissionMap = buildPermissionMap(collected);
|
||||
_manager.applyServerPermissions(permissionMap);
|
||||
return collected;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,8 @@ class UserDetailDialogResult {
|
||||
}
|
||||
|
||||
typedef UserCreateCallback = Future<UserAccount?> Function(UserInput input);
|
||||
typedef UserUpdateCallback = Future<UserAccount?> Function(
|
||||
int id,
|
||||
UserInput input,
|
||||
);
|
||||
typedef UserUpdateCallback =
|
||||
Future<UserAccount?> Function(int id, UserInput input);
|
||||
typedef UserDeleteCallback = Future<bool> Function(int id);
|
||||
typedef UserRestoreCallback = Future<UserAccount?> Function(int id);
|
||||
typedef UserResetPasswordCallback = Future<UserAccount?> Function(int id);
|
||||
@@ -141,10 +139,8 @@ Future<UserDetailDialogResult?> showUserDetailDialog({
|
||||
id: _UserDetailSections.overview,
|
||||
label: '상세',
|
||||
icon: LucideIcons.info,
|
||||
builder: (_) => _UserOverviewSection(
|
||||
user: detailUser,
|
||||
dateFormat: dateFormat,
|
||||
),
|
||||
builder: (_) =>
|
||||
_UserOverviewSection(user: detailUser, dateFormat: dateFormat),
|
||||
),
|
||||
if (isDetail)
|
||||
SuperportDetailDialogSection(
|
||||
@@ -217,9 +213,7 @@ Future<UserDetailDialogResult?> showUserDetailDialog({
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '이메일',
|
||||
value: detailUser.email?.isEmpty ?? true
|
||||
? '-'
|
||||
: detailUser.email!,
|
||||
value: detailUser.email?.isEmpty ?? true ? '-' : detailUser.email!,
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '연락처',
|
||||
@@ -229,17 +223,13 @@ Future<UserDetailDialogResult?> showUserDetailDialog({
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '비고',
|
||||
value: detailUser.note?.isEmpty ?? true
|
||||
? '-'
|
||||
: detailUser.note!,
|
||||
value: detailUser.note?.isEmpty ?? true ? '-' : detailUser.note!,
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '비밀번호 변경일시',
|
||||
value: detailUser.passwordUpdatedAt == null
|
||||
? '-'
|
||||
: dateFormat.format(
|
||||
detailUser.passwordUpdatedAt!.toLocal(),
|
||||
),
|
||||
: dateFormat.format(detailUser.passwordUpdatedAt!.toLocal()),
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '생성일시',
|
||||
@@ -285,10 +275,7 @@ class _UserDetailSections {
|
||||
|
||||
/// 사용자 주요 정보를 표시하는 섹션이다.
|
||||
class _UserOverviewSection extends StatelessWidget {
|
||||
const _UserOverviewSection({
|
||||
required this.user,
|
||||
required this.dateFormat,
|
||||
});
|
||||
const _UserOverviewSection({required this.user, required this.dateFormat});
|
||||
|
||||
final UserAccount user;
|
||||
final intl.DateFormat dateFormat;
|
||||
@@ -339,10 +326,7 @@ class _UserOverviewSection extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
rows[i].value,
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
child: Text(rows[i].value, style: theme.textTheme.small),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -426,8 +410,9 @@ class _UserSecurityContentState extends State<_UserSecurityContent> {
|
||||
label: '비밀번호 변경일시',
|
||||
value: widget.user.passwordUpdatedAt == null
|
||||
? '-'
|
||||
: widget.dateFormat
|
||||
.format(widget.user.passwordUpdatedAt!.toLocal()),
|
||||
: widget.dateFormat.format(
|
||||
widget.user.passwordUpdatedAt!.toLocal(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_KeyValueColumn(
|
||||
@@ -571,8 +556,7 @@ class _UserFormState extends State<_UserForm> {
|
||||
_groupIdNotifier = ValueNotifier<int?>(user?.group?.id);
|
||||
_isActiveNotifier = ValueNotifier<bool>(user?.isActive ?? true);
|
||||
|
||||
if (_groupIdNotifier.value == null &&
|
||||
widget.groupOptions.length == 1) {
|
||||
if (_groupIdNotifier.value == null && widget.groupOptions.length == 1) {
|
||||
_groupIdNotifier.value = widget.groupOptions.first.id;
|
||||
}
|
||||
}
|
||||
@@ -613,8 +597,7 @@ class _UserFormState extends State<_UserForm> {
|
||||
}
|
||||
},
|
||||
),
|
||||
if (_employeeError != null)
|
||||
_ErrorText(_employeeError!),
|
||||
if (_employeeError != null) _ErrorText(_employeeError!),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -37,6 +37,9 @@ import 'features/dashboard/data/repositories/dashboard_repository_remote.dart';
|
||||
import 'features/dashboard/domain/repositories/dashboard_repository.dart';
|
||||
import 'features/inventory/lookups/data/repositories/inventory_lookup_repository_remote.dart';
|
||||
import 'features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
|
||||
import 'features/inventory/summary/application/inventory_service.dart';
|
||||
import 'features/inventory/summary/data/repositories/inventory_repository_remote.dart';
|
||||
import 'features/inventory/summary/domain/repositories/inventory_repository.dart';
|
||||
import 'features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart';
|
||||
import 'features/inventory/transactions/data/repositories/transaction_customer_repository_remote.dart';
|
||||
import 'features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart';
|
||||
@@ -236,6 +239,12 @@ void _registerApprovalDependencies() {
|
||||
|
||||
void _registerInventoryDependencies() {
|
||||
sl
|
||||
..registerLazySingleton<InventoryRepository>(
|
||||
() => InventoryRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||
)
|
||||
..registerLazySingleton<InventoryService>(
|
||||
() => InventoryService(repository: sl<InventoryRepository>()),
|
||||
)
|
||||
..registerLazySingleton<InventoryLookupRepository>(
|
||||
() => InventoryLookupRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||
)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'core/config/environment.dart';
|
||||
import 'core/permissions/permission_bootstrapper.dart';
|
||||
import 'core/permissions/permission_manager.dart';
|
||||
import 'core/routing/app_router.dart';
|
||||
import 'core/theme/superport_shad_theme.dart';
|
||||
import 'core/theme/theme_controller.dart';
|
||||
import 'features/auth/application/auth_service.dart';
|
||||
import 'features/masters/group/domain/repositories/group_repository.dart';
|
||||
import 'features/masters/group_permission/domain/repositories/group_permission_repository.dart';
|
||||
import 'injection_container.dart';
|
||||
|
||||
/// Superport 애플리케이션 진입점. 환경 초기화 후 앱 위젯을 실행한다.
|
||||
@@ -50,6 +55,7 @@ class _SuperportAppState extends State<SuperportApp> {
|
||||
GetIt.I.unregister<PermissionManager>();
|
||||
}
|
||||
GetIt.I.registerSingleton<PermissionManager>(_permissionManager);
|
||||
unawaited(_restorePermissions());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -90,4 +96,18 @@ class _SuperportAppState extends State<SuperportApp> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _restorePermissions() async {
|
||||
final authService = GetIt.I<AuthService>();
|
||||
final session = authService.session;
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
final bootstrapper = PermissionBootstrapper(
|
||||
manager: _permissionManager,
|
||||
groupRepository: GetIt.I<GroupRepository>(),
|
||||
groupPermissionRepository: GetIt.I<GroupPermissionRepository>(),
|
||||
);
|
||||
await bootstrapper.apply(session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class AppShell extends StatelessWidget {
|
||||
final filteredPages = <AppPageDescriptor>[
|
||||
for (final section in appSections)
|
||||
for (final page in section.pages)
|
||||
if (manager.can(page.path, PermissionAction.view)) page,
|
||||
if (_hasPageAccess(manager, page)) page,
|
||||
];
|
||||
final pages = filteredPages.isEmpty ? allAppPages : filteredPages;
|
||||
final themeController = ThemeControllerScope.of(context);
|
||||
@@ -404,6 +404,19 @@ int _selectedIndex(String location, List<AppPageDescriptor> pages) {
|
||||
return prefix == -1 ? 0 : prefix;
|
||||
}
|
||||
|
||||
bool _hasPageAccess(PermissionManager manager, AppPageDescriptor page) {
|
||||
final requirements = <String>{page.path, ...page.extraRequiredResources};
|
||||
for (final resource in requirements) {
|
||||
if (resource.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
if (!manager.can(resource, PermissionAction.view)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 계정 정보를 확인하고 로그아웃을 수행하는 상단바 버튼.
|
||||
class _AccountMenuButton extends StatelessWidget {
|
||||
const _AccountMenuButton({required this.service});
|
||||
|
||||
Reference in New Issue
Block a user