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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
/// 재고 이벤트와 연결된 거래처 유형.
enum InventoryCounterpartyType { vendor, customer, unknown }
/// 재고 이벤트의 거래처 정보를 표현한다.
class InventoryCounterparty {
const InventoryCounterparty({required this.type, this.name});
final InventoryCounterpartyType type;
final String? name;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
/// 재고 요약에서 사용하는 공급사 정보를 표현하는 값 객체.
class InventoryVendor {
const InventoryVendor({this.id, required this.name});
/// 공급사 식별자. 미정의일 수 있다.
final int? id;
/// 공급사 명칭.
final String name;
}

View File

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

View File

@@ -0,0 +1,15 @@
import 'inventory_warehouse.dart';
/// 특정 창고의 재고 수량을 나타내는 모델.
class InventoryWarehouseBalance {
const InventoryWarehouseBalance({
required this.warehouse,
required this.quantity,
});
/// 창고 정보.
final InventoryWarehouse warehouse;
/// 창고 내 잔량.
final int quantity;
}

View File

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

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

File diff suppressed because it is too large Load Diff