refactor: 인벤토리 테이블 스펙과 도메인 계층 정비

This commit is contained in:
JiWoong Sul
2025-10-14 18:09:26 +09:00
parent 8d3b2c1e20
commit 1325109fba
32 changed files with 5550 additions and 290 deletions

View File

@@ -0,0 +1,451 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/features/inventory/inbound/presentation/models/inbound_record.dart';
import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart';
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
import 'package:superport_v2/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart';
/// 입고 목록 데이터를 가져오고 상태를 노출하는 컨트롤러.
class InboundController extends ChangeNotifier {
InboundController({
required StockTransactionRepository transactionRepository,
required TransactionLineRepository lineRepository,
required InventoryLookupRepository lookupRepository,
List<String> fallbackStatusOptions = const ['작성중', '승인대기', '승인완료'],
List<String> transactionTypeKeywords = const ['입고', 'inbound'],
}) : _transactionRepository = transactionRepository,
_lineRepository = lineRepository,
_lookupRepository = lookupRepository,
_fallbackStatusOptions = List<String>.unmodifiable(
fallbackStatusOptions,
),
_transactionTypeKeywords = List<String>.unmodifiable(
transactionTypeKeywords,
) {
_statusOptions = List<String>.from(_fallbackStatusOptions);
}
final StockTransactionRepository _transactionRepository;
final TransactionLineRepository _lineRepository;
final InventoryLookupRepository _lookupRepository;
final List<String> _fallbackStatusOptions;
final List<String> _transactionTypeKeywords;
late List<String> _statusOptions;
final Map<String, LookupItem> _statusLookup = {};
LookupItem? _transactionType;
PaginatedResult<StockTransaction>? _result;
List<InboundRecord> _records = const [];
bool _isLoading = false;
String? _errorMessage;
StockTransactionListFilter? _lastFilter;
final Set<int> _processingTransactionIds = <int>{};
UnmodifiableListView<String> get statusOptions =>
UnmodifiableListView(_statusOptions);
UnmodifiableMapView<String, LookupItem> get statusLookup =>
UnmodifiableMapView(_statusLookup);
LookupItem? get transactionType => _transactionType;
PaginatedResult<StockTransaction>? get result => _result;
UnmodifiableListView<InboundRecord> get records =>
UnmodifiableListView(_records);
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
List<String> get fallbackStatusOptions => _fallbackStatusOptions;
StockTransactionListFilter? get lastFilter => _lastFilter;
UnmodifiableSetView<int> get processingTransactionIds =>
UnmodifiableSetView(_processingTransactionIds);
/// 트랜잭션 상태 목록을 서버에서 읽어온다.
Future<void> loadStatusOptions() async {
try {
final items = await _lookupRepository.fetchTransactionStatuses();
final normalized = items
.map((item) => item.name.trim())
.where((name) => name.isNotEmpty)
.toSet()
.toList(growable: false);
if (normalized.isEmpty) {
return;
}
_statusOptions = normalized;
_statusLookup
..clear()
..addEntries(
items
.where((item) => item.name.trim().isNotEmpty)
.map((item) => MapEntry(item.name.trim(), item)),
);
notifyListeners();
} catch (_) {
// 실패 시 폴백 값을 유지한다.
}
}
/// 입고 트랜잭션 타입을 조회한다.
Future<bool> resolveTransactionType() async {
if (_transactionType != null) {
return true;
}
try {
final items = await _lookupRepository.fetchTransactionTypes();
final matched = _matchLookup(items, _transactionTypeKeywords);
if (matched == null) {
return false;
}
_transactionType = matched;
notifyListeners();
return true;
} catch (_) {
return false;
}
}
/// 필터에 맞는 입고 트랜잭션 목록을 조회한다.
Future<void> fetchTransactions({
required StockTransactionListFilter filter,
}) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
_lastFilter = filter;
final result = await _transactionRepository.list(filter: filter);
_result = result;
_records = result.items
.map(InboundRecord.fromTransaction)
.toList(growable: false);
} catch (error) {
_records = const [];
_result = PaginatedResult<StockTransaction>(
items: const [],
page: filter.page,
pageSize: filter.pageSize,
total: 0,
);
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 마지막으로 사용한 필터를 기준으로 목록을 새로고침한다.
Future<void> refresh({StockTransactionListFilter? filter}) async {
final target = filter ?? _lastFilter;
if (target == null) {
return;
}
await fetchTransactions(filter: target);
}
/// 재고 트랜잭션을 생성하고 필요 시 목록을 갱신한다.
Future<InboundRecord> createTransaction(
StockTransactionCreateInput input, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.create(input),
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 재고 트랜잭션을 수정한다.
Future<InboundRecord> updateTransaction(
int id,
StockTransactionUpdateInput input, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.update(id, input),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 재고 트랜잭션을 삭제한다.
Future<void> deleteTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) async {
await _executeVoidMutation(
() => _transactionRepository.delete(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 삭제된 재고 트랜잭션을 복구한다.
Future<InboundRecord> restoreTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.restore(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 재고 트랜잭션을 상신(submit)한다.
Future<InboundRecord> submitTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.submit(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 재고 트랜잭션을 완료 처리한다.
Future<InboundRecord> completeTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.complete(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 재고 트랜잭션을 승인 처리한다.
Future<InboundRecord> approveTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.approve(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 재고 트랜잭션을 반려 처리한다.
Future<InboundRecord> rejectTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.reject(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 재고 트랜잭션을 취소 처리한다.
Future<InboundRecord> cancelTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.cancel(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
Future<InboundRecord> _executeMutation(
Future<StockTransaction> Function() mutation, {
int? trackedId,
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) async {
if (trackedId != null) {
_processingTransactionIds.add(trackedId);
notifyListeners();
}
_errorMessage = null;
try {
final transaction = await mutation();
if (refreshAfter) {
await _refreshAfterMutation(refreshFilter);
} else {
_upsertRecord(transaction);
}
return InboundRecord.fromTransaction(transaction);
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
rethrow;
} finally {
if (trackedId != null) {
_processingTransactionIds.remove(trackedId);
notifyListeners();
}
}
}
Future<void> _executeVoidMutation(
Future<void> Function() mutation, {
required int trackedId,
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) async {
_processingTransactionIds.add(trackedId);
notifyListeners();
_errorMessage = null;
try {
await mutation();
if (refreshAfter) {
await _refreshAfterMutation(refreshFilter);
} else {
_removeRecord(trackedId);
}
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
rethrow;
} finally {
_processingTransactionIds.remove(trackedId);
notifyListeners();
}
}
Future<void> _refreshAfterMutation(
StockTransactionListFilter? refreshFilter,
) async {
final filter = refreshFilter ?? _lastFilter;
if (filter == null) {
return;
}
await fetchTransactions(filter: filter);
}
void _upsertRecord(StockTransaction transaction) {
final record = InboundRecord.fromTransaction(transaction);
final updatedRecords = _records.toList();
final transactionId = transaction.id;
if (transactionId != null) {
final index = updatedRecords.indexWhere(
(item) => item.id == transactionId,
);
if (index >= 0) {
updatedRecords[index] = record;
} else {
updatedRecords.insert(0, record);
}
} else {
updatedRecords.insert(0, record);
}
_records = updatedRecords;
final currentResult = _result;
if (currentResult != null) {
final transactions = currentResult.items.toList();
if (transactionId != null) {
final index = transactions.indexWhere(
(item) => item.id == transactionId,
);
if (index >= 0) {
transactions[index] = transaction;
} else {
transactions.insert(0, transaction);
}
} else {
transactions.insert(0, transaction);
}
_result = currentResult.copyWith(items: transactions);
}
notifyListeners();
}
void _removeRecord(int transactionId) {
final updatedRecords = _records.toList()
..removeWhere((record) => record.id == transactionId);
_records = updatedRecords;
final currentResult = _result;
if (currentResult != null) {
final transactions = currentResult.items
.where((item) => item.id != transactionId)
.toList();
_result = currentResult.copyWith(items: transactions);
}
notifyListeners();
}
LookupItem? _matchLookup(List<LookupItem> items, List<String> keywords) {
if (items.isEmpty) {
return null;
}
final normalizedKeywords = keywords
.map((keyword) => keyword.toLowerCase())
.toList(growable: false);
LookupItem? fallback;
for (final item in items) {
final name = item.name.toLowerCase();
final code = item.code?.toLowerCase();
for (final keyword in normalizedKeywords) {
if (name == keyword || name.contains(keyword)) {
return item;
}
if (code != null && (code == keyword || code.contains(keyword))) {
return item;
}
}
if (fallback == null && item.isDefault) {
fallback = item;
}
}
return fallback ?? items.first;
}
/// 라인 추가/수정/삭제 계획을 순차적으로 실행한다.
Future<void> syncTransactionLines(
int transactionId,
TransactionLineSyncPlan plan,
) async {
if (!plan.hasChanges) {
return;
}
if (plan.updatedLines.isNotEmpty) {
await _lineRepository.updateLines(transactionId, plan.updatedLines);
}
for (final lineId in plan.deletedLineIds) {
await _lineRepository.deleteLine(lineId);
}
if (plan.createdLines.isNotEmpty) {
await _lineRepository.addLines(transactionId, plan.createdLines);
}
}
}

View File

@@ -0,0 +1,119 @@
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart';
/// 입고 화면에서 사용하는 UI 전용 레코드 모델.
class InboundRecord {
InboundRecord({
this.id,
required this.number,
required this.transactionNumber,
required this.transactionType,
this.transactionTypeId,
required this.processedAt,
required this.warehouse,
this.warehouseId,
required this.status,
this.statusId,
required this.writer,
this.writerId,
required this.remark,
required this.items,
this.expectedReturnDate,
this.isActive = true,
this.raw,
});
factory InboundRecord.fromTransaction(StockTransaction transaction) {
return InboundRecord(
id: transaction.id,
number: transaction.transactionNo,
transactionNumber: transaction.transactionNo,
transactionType: transaction.type.name,
transactionTypeId: transaction.type.id,
processedAt: transaction.transactionDate,
warehouse: transaction.warehouse.name,
warehouseId: transaction.warehouse.id,
status: transaction.status.name,
statusId: transaction.status.id,
writer: transaction.createdBy.name,
writerId: transaction.createdBy.id,
remark: transaction.note ?? '-',
items: transaction.lines
.map(InboundLineItem.fromLine)
.toList(growable: false),
expectedReturnDate: transaction.expectedReturnDate,
isActive: transaction.isActive,
raw: transaction,
);
}
final int? id;
final String number;
final String transactionNumber;
final String transactionType;
final int? transactionTypeId;
final DateTime processedAt;
final String warehouse;
final int? warehouseId;
final String status;
final int? statusId;
final String writer;
final int? writerId;
final String remark;
final List<InboundLineItem> items;
final DateTime? expectedReturnDate;
final bool isActive;
final StockTransaction? raw;
int get itemCount => items.length;
int get totalQuantity =>
items.fold<int>(0, (sum, item) => sum + item.quantity);
double get totalAmount =>
items.fold<double>(0, (sum, item) => sum + (item.price * item.quantity));
}
/// 입고 상세 모달에서 사용하는 품목 정보.
class InboundLineItem {
InboundLineItem({
this.id,
int? lineNo,
int? productId,
String? productCode,
required this.product,
required this.manufacturer,
required this.unit,
required this.quantity,
required this.price,
required this.remark,
}) : lineNo = lineNo ?? 0,
productId = productId ?? 0,
productCode = productCode ?? '';
factory InboundLineItem.fromLine(StockTransactionLine line) {
final product = line.product;
return InboundLineItem(
id: line.id,
lineNo: line.lineNo,
productId: product.id,
productCode: product.code,
product: product.name,
manufacturer: product.vendor?.name ?? '-',
unit: product.uom?.name ?? '-',
quantity: line.quantity,
price: line.unitPrice,
remark: line.note ?? '',
);
}
final int? id;
final int lineNo;
final int productId;
final String productCode;
final String product;
final String manufacturer;
final String unit;
final int quantity;
final double price;
final String remark;
}

View File

@@ -0,0 +1,82 @@
import 'package:superport_v2/widgets/components/responsive.dart';
/// 입고 테이블과 필터 구성에 사용되는 정적 스펙 모음.
class InboundTableSpec {
const InboundTableSpec._();
/// 목록 헤더 라벨.
static const List<String> headers = [
'번호',
'처리일자',
'창고',
'트랜잭션번호',
'제품',
'제조사',
'단위',
'수량',
'단가',
'상태',
'작성자',
'품목수',
'총수량',
'비고',
];
/// 기본 정렬에서 허용하는 페이지당 항목 수 옵션.
static const List<int> pageSizeOptions = [10, 20, 50];
/// API include 파라미터 기본값.
static const List<String> defaultIncludeOptions = ['lines'];
/// 백엔드에서 상태 목록을 내려주지 않을 때 사용되는 기본 상태 라벨.
static const List<String> fallbackStatusOptions = ['작성중', '승인대기', '승인완료'];
/// 입고 트랜잭션 타입을 식별하기 위한 키워드.
static const List<String> transactionTypeKeywords = ['입고', 'inbound'];
/// 검색 입력 필드 플레이스홀더.
static const String searchPlaceholder = '트랜잭션번호, 작성자, 제품 검색';
/// 창고 전체 선택 라벨.
static const String allWarehouseLabel = '전체 창고';
/// 상태 전체 선택 라벨.
static const String allStatusLabel = '전체 상태';
/// include 옵션이 선택되지 않았을 때 표시하는 기본 라벨.
static const String includeEmptyLabel = 'Include 없음';
/// 테이블 열 폭 계산을 위한 기준 값.
static const double columnSpanWidth = 140;
/// 테이블 행 높이 계산을 위한 기준 값.
static const double rowSpanHeight = 56;
/// 날짜 필터의 시작 범위.
static final DateTime dateRangeFirstDate = DateTime(2020);
/// 날짜 필터의 종료 범위.
static final DateTime dateRangeLastDate = DateTime(2030);
/// 반응형 브레이크포인트에 따른 가시 열 인덱스.
static List<int> visibleColumns(DeviceBreakpoint breakpoint) {
switch (breakpoint) {
case DeviceBreakpoint.desktop:
return List<int>.generate(headers.length, (index) => index);
case DeviceBreakpoint.tablet:
return const [0, 1, 2, 3, 4, 7, 8, 9, 10, 11, 12];
case DeviceBreakpoint.mobile:
return const [0, 1, 2, 9, 10];
}
}
/// include 파라미터별 사용자 노출 라벨.
static String includeLabel(String value) {
switch (value) {
case 'lines':
return '라인 포함';
default:
return value;
}
}
}

View File

@@ -0,0 +1,54 @@
import '../../../../../core/common/utils/json_utils.dart';
import '../../domain/entities/lookup_item.dart';
/// 룩업 API 응답을 표현하는 DTO.
class LookupItemDto {
LookupItemDto({
required this.id,
required this.name,
this.code,
this.isDefault = false,
this.isActive = true,
this.note,
});
final int id;
final String? code;
final String name;
final bool isDefault;
final bool isActive;
final String? note;
factory LookupItemDto.fromJson(Map<String, dynamic> json) {
return LookupItemDto(
id: json['id'] as int? ?? 0,
code: json['code'] as String? ?? json['status_code'] as String?,
name:
json['name'] as String? ??
json['type_name'] as String? ??
json['status_name'] as String? ??
json['action_name'] as String? ??
'',
isDefault: (json['is_default'] as bool?) ?? false,
isActive: (json['is_active'] as bool?) ?? true,
note: json['note'] as String?,
);
}
LookupItem toEntity() => LookupItem(
id: id,
code: code,
name: name,
isDefault: isDefault,
isActive: isActive,
note: note,
);
static List<LookupItem> parseList(Map<String, dynamic>? json) {
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
return rawItems
.map(LookupItemDto.fromJson)
.map((dto) => dto.toEntity())
.toList(growable: false);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:dio/dio.dart';
import '../../../../../core/network/api_client.dart';
import '../../../../../core/network/api_routes.dart';
import '../../domain/entities/lookup_item.dart';
import '../../domain/repositories/inventory_lookup_repository.dart';
import '../dtos/lookup_item_dto.dart';
/// 인벤토리 공통 룩업 API 호출 구현체.
class InventoryLookupRepositoryRemote implements InventoryLookupRepository {
InventoryLookupRepositoryRemote({required ApiClient apiClient})
: _api = apiClient;
final ApiClient _api;
static const _transactionTypesPath = '${ApiRoutes.apiV1}/transaction-types';
static const _transactionStatusesPath =
'${ApiRoutes.apiV1}/transaction-statuses';
static const _approvalStatusesPath = '${ApiRoutes.apiV1}/approval-statuses';
static const _approvalActionsPath = '${ApiRoutes.apiV1}/approval-actions';
@override
Future<List<LookupItem>> fetchTransactionTypes({bool activeOnly = true}) {
return _fetchList(_transactionTypesPath, activeOnly: activeOnly);
}
@override
Future<List<LookupItem>> fetchTransactionStatuses({bool activeOnly = true}) {
return _fetchList(_transactionStatusesPath, activeOnly: activeOnly);
}
@override
Future<List<LookupItem>> fetchApprovalStatuses({bool activeOnly = true}) {
return _fetchList(_approvalStatusesPath, activeOnly: activeOnly);
}
@override
Future<List<LookupItem>> fetchApprovalActions({bool activeOnly = true}) {
return _fetchList(
_approvalActionsPath,
activeOnly: activeOnly,
// Approval actions는 is_active 필터가 없을 수 있어 조건적으로 전달.
includeIsActive: false,
);
}
Future<List<LookupItem>> _fetchList(
String path, {
required bool activeOnly,
bool includeIsActive = true,
}) async {
final response = await _api.get<Map<String, dynamic>>(
path,
query: {
'page': 1,
'page_size': 200,
if (includeIsActive && activeOnly) 'is_active': true,
},
options: Options(responseType: ResponseType.json),
);
return LookupItemDto.parseList(response.data ?? const {});
}
}

View File

@@ -0,0 +1,18 @@
/// 공통 조회용 룩업 항목 엔티티.
class LookupItem {
LookupItem({
required this.id,
required this.name,
this.code,
this.isDefault = false,
this.isActive = true,
this.note,
});
final int id;
final String? code;
final String name;
final bool isDefault;
final bool isActive;
final String? note;
}

View File

@@ -0,0 +1,16 @@
import '../entities/lookup_item.dart';
/// 인벤토리 공통 룩업(타입/상태/승인 액션) 저장소 인터페이스.
abstract class InventoryLookupRepository {
/// 입출고 트랜잭션 타입 목록을 조회한다.
Future<List<LookupItem>> fetchTransactionTypes({bool activeOnly = true});
/// 입출고 트랜잭션 상태 목록을 조회한다.
Future<List<LookupItem>> fetchTransactionStatuses({bool activeOnly = true});
/// 결재 상태 목록을 조회한다.
Future<List<LookupItem>> fetchApprovalStatuses({bool activeOnly = true});
/// 결재 액션 목록을 조회한다.
Future<List<LookupItem>> fetchApprovalActions({bool activeOnly = true});
}

View File

@@ -0,0 +1,479 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart';
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import 'package:superport_v2/features/inventory/outbound/presentation/models/outbound_record.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
import 'package:superport_v2/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart';
/// 출고 목록을 서버에서 불러오고 상태를 제공하는 컨트롤러.
class OutboundController extends ChangeNotifier {
OutboundController({
required StockTransactionRepository transactionRepository,
required TransactionLineRepository lineRepository,
required TransactionCustomerRepository customerRepository,
required InventoryLookupRepository lookupRepository,
List<String> fallbackStatusOptions = const ['작성중', '출고대기', '출고완료'],
List<String> transactionTypeKeywords = const ['출고', 'outbound'],
}) : _transactionRepository = transactionRepository,
_lineRepository = lineRepository,
_customerRepository = customerRepository,
_lookupRepository = lookupRepository,
_fallbackStatusOptions = List<String>.unmodifiable(
fallbackStatusOptions,
),
_transactionTypeKeywords = List<String>.unmodifiable(
transactionTypeKeywords,
) {
_statusOptions = List<String>.from(_fallbackStatusOptions);
}
final StockTransactionRepository _transactionRepository;
final TransactionLineRepository _lineRepository;
final TransactionCustomerRepository _customerRepository;
final InventoryLookupRepository _lookupRepository;
final List<String> _fallbackStatusOptions;
final List<String> _transactionTypeKeywords;
late List<String> _statusOptions;
final Map<String, LookupItem> _statusLookup = {};
LookupItem? _transactionType;
PaginatedResult<StockTransaction>? _result;
List<OutboundRecord> _records = const [];
bool _isLoading = false;
String? _errorMessage;
StockTransactionListFilter? _lastFilter;
final Set<int> _processingTransactionIds = <int>{};
UnmodifiableListView<String> get statusOptions =>
UnmodifiableListView(_statusOptions);
UnmodifiableMapView<String, LookupItem> get statusLookup =>
UnmodifiableMapView(_statusLookup);
LookupItem? get transactionType => _transactionType;
PaginatedResult<StockTransaction>? get result => _result;
UnmodifiableListView<OutboundRecord> get records =>
UnmodifiableListView(_records);
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
List<String> get fallbackStatusOptions => _fallbackStatusOptions;
StockTransactionListFilter? get lastFilter => _lastFilter;
UnmodifiableSetView<int> get processingTransactionIds =>
UnmodifiableSetView(_processingTransactionIds);
/// 트랜잭션 상태 값을 불러온다.
Future<void> loadStatusOptions() async {
try {
final items = await _lookupRepository.fetchTransactionStatuses();
final normalized = items
.map((item) => item.name.trim())
.where((name) => name.isNotEmpty)
.toSet()
.toList(growable: false);
if (normalized.isEmpty) {
return;
}
_statusOptions = normalized;
_statusLookup
..clear()
..addEntries(
items
.where((item) => item.name.trim().isNotEmpty)
.map((item) => MapEntry(item.name.trim(), item)),
);
notifyListeners();
} catch (_) {
// 실패 시 폴백 값을 유지한다.
}
}
/// 출고 트랜잭션 타입을 조회한다.
Future<bool> resolveTransactionType() async {
if (_transactionType != null) {
return true;
}
try {
final items = await _lookupRepository.fetchTransactionTypes();
final matched = _matchLookup(items, _transactionTypeKeywords);
if (matched == null) {
return false;
}
_transactionType = matched;
notifyListeners();
return true;
} catch (_) {
return false;
}
}
/// 조건에 맞는 출고 트랜잭션 목록을 요청한다.
Future<void> fetchTransactions({
required StockTransactionListFilter filter,
}) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
_lastFilter = filter;
final result = await _transactionRepository.list(filter: filter);
_result = result;
_records = result.items
.map(OutboundRecord.fromTransaction)
.toList(growable: false);
} catch (error) {
_records = const [];
_result = PaginatedResult<StockTransaction>(
items: const [],
page: filter.page,
pageSize: filter.pageSize,
total: 0,
);
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 마지막 필터를 사용해 목록을 새로고침한다.
Future<void> refresh({StockTransactionListFilter? filter}) async {
final target = filter ?? _lastFilter;
if (target == null) {
return;
}
await fetchTransactions(filter: target);
}
/// 출고 트랜잭션을 생성한다.
Future<OutboundRecord> createTransaction(
StockTransactionCreateInput input, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.create(input),
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 출고 트랜잭션을 수정한다.
Future<OutboundRecord> updateTransaction(
int id,
StockTransactionUpdateInput input, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.update(id, input),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 출고 트랜잭션을 삭제한다.
Future<void> deleteTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) async {
await _executeVoidMutation(
() => _transactionRepository.delete(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 삭제된 출고 트랜잭션을 복구한다.
Future<OutboundRecord> restoreTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.restore(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 출고 트랜잭션을 상신한다.
Future<OutboundRecord> submitTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.submit(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 출고 트랜잭션을 완료 처리한다.
Future<OutboundRecord> completeTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.complete(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 출고 트랜잭션을 승인 처리한다.
Future<OutboundRecord> approveTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.approve(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 출고 트랜잭션을 반려 처리한다.
Future<OutboundRecord> rejectTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.reject(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
/// 출고 트랜잭션을 취소 처리한다.
Future<OutboundRecord> cancelTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) {
return _executeMutation(
() => _transactionRepository.cancel(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
);
}
Future<OutboundRecord> _executeMutation(
Future<StockTransaction> Function() mutation, {
int? trackedId,
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) async {
if (trackedId != null) {
_processingTransactionIds.add(trackedId);
notifyListeners();
}
_errorMessage = null;
try {
final transaction = await mutation();
if (refreshAfter) {
await _refreshAfterMutation(refreshFilter);
} else {
_upsertRecord(transaction);
}
return OutboundRecord.fromTransaction(transaction);
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
rethrow;
} finally {
if (trackedId != null) {
_processingTransactionIds.remove(trackedId);
notifyListeners();
}
}
}
Future<void> _executeVoidMutation(
Future<void> Function() mutation, {
required int trackedId,
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
}) async {
_processingTransactionIds.add(trackedId);
notifyListeners();
_errorMessage = null;
try {
await mutation();
if (refreshAfter) {
await _refreshAfterMutation(refreshFilter);
} else {
_removeRecord(trackedId);
}
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
rethrow;
} finally {
_processingTransactionIds.remove(trackedId);
notifyListeners();
}
}
Future<void> _refreshAfterMutation(
StockTransactionListFilter? refreshFilter,
) async {
final filter = refreshFilter ?? _lastFilter;
if (filter == null) {
return;
}
await fetchTransactions(filter: filter);
}
void _upsertRecord(StockTransaction transaction) {
final record = OutboundRecord.fromTransaction(transaction);
final updatedRecords = _records.toList();
final transactionId = transaction.id;
if (transactionId != null) {
final index = updatedRecords.indexWhere(
(item) => item.id == transactionId,
);
if (index >= 0) {
updatedRecords[index] = record;
} else {
updatedRecords.insert(0, record);
}
} else {
updatedRecords.insert(0, record);
}
_records = updatedRecords;
final currentResult = _result;
if (currentResult != null) {
final transactions = currentResult.items.toList();
if (transactionId != null) {
final index = transactions.indexWhere(
(item) => item.id == transactionId,
);
if (index >= 0) {
transactions[index] = transaction;
} else {
transactions.insert(0, transaction);
}
} else {
transactions.insert(0, transaction);
}
_result = currentResult.copyWith(items: transactions);
}
notifyListeners();
}
void _removeRecord(int transactionId) {
final updatedRecords = _records.toList()
..removeWhere((record) => record.id == transactionId);
_records = updatedRecords;
final currentResult = _result;
if (currentResult != null) {
final transactions = currentResult.items
.where((item) => item.id != transactionId)
.toList();
_result = currentResult.copyWith(items: transactions);
}
notifyListeners();
}
LookupItem? _matchLookup(List<LookupItem> items, List<String> keywords) {
if (items.isEmpty) {
return null;
}
final normalizedKeywords = keywords
.map((keyword) => keyword.toLowerCase())
.toList(growable: false);
LookupItem? fallback;
for (final item in items) {
final name = item.name.toLowerCase();
final code = item.code?.toLowerCase();
for (final keyword in normalizedKeywords) {
if (name == keyword || name.contains(keyword)) {
return item;
}
if (code != null && (code == keyword || code.contains(keyword))) {
return item;
}
}
if (fallback == null && item.isDefault) {
fallback = item;
}
}
return fallback ?? items.first;
}
/// 라인 변경 계획을 실제 API 호출로 실행한다.
Future<void> syncTransactionLines(
int transactionId,
TransactionLineSyncPlan plan,
) async {
if (!plan.hasChanges) {
return;
}
if (plan.updatedLines.isNotEmpty) {
await _lineRepository.updateLines(transactionId, plan.updatedLines);
}
for (final lineId in plan.deletedLineIds) {
await _lineRepository.deleteLine(lineId);
}
if (plan.createdLines.isNotEmpty) {
await _lineRepository.addLines(transactionId, plan.createdLines);
}
}
/// 고객 연결 변경 계획을 실제 API 호출로 실행한다.
Future<void> syncTransactionCustomers(
int transactionId,
TransactionCustomerSyncPlan plan,
) async {
if (!plan.hasChanges) {
return;
}
if (plan.updatedCustomers.isNotEmpty) {
await _customerRepository.updateCustomers(
transactionId,
plan.updatedCustomers,
);
}
for (final linkId in plan.deletedCustomerIds) {
await _customerRepository.deleteCustomer(linkId);
}
if (plan.createdCustomers.isNotEmpty) {
await _customerRepository.addCustomers(
transactionId,
plan.createdCustomers,
);
}
}
}

View File

@@ -0,0 +1,148 @@
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart';
/// 출고 화면에서 사용하는 UI용 레코드 모델.
class OutboundRecord {
OutboundRecord({
this.id,
required this.number,
required this.transactionNumber,
required this.transactionType,
this.transactionTypeId,
required this.processedAt,
required this.warehouse,
this.warehouseId,
required this.status,
this.statusId,
required this.writer,
this.writerId,
required this.remark,
required this.items,
required this.customers,
this.raw,
});
factory OutboundRecord.fromTransaction(StockTransaction transaction) {
return OutboundRecord(
id: transaction.id,
number: transaction.transactionNo,
transactionNumber: transaction.transactionNo,
transactionType: transaction.type.name,
transactionTypeId: transaction.type.id,
processedAt: transaction.transactionDate,
warehouse: transaction.warehouse.name,
warehouseId: transaction.warehouse.id,
status: transaction.status.name,
statusId: transaction.status.id,
writer: transaction.createdBy.name,
writerId: transaction.createdBy.id,
remark: transaction.note ?? '-',
items: transaction.lines
.map(OutboundLineItem.fromLine)
.toList(growable: false),
customers: transaction.customers
.map(OutboundCustomer.fromLink)
.toList(growable: false),
raw: transaction,
);
}
final int? id;
final String number;
final String transactionNumber;
final String transactionType;
final int? transactionTypeId;
final DateTime processedAt;
final String warehouse;
final int? warehouseId;
final String status;
final int? statusId;
final String writer;
final int? writerId;
final String remark;
final List<OutboundLineItem> items;
final List<OutboundCustomer> customers;
final StockTransaction? raw;
int get customerCount => customers.length;
int get itemCount => items.length;
int get totalQuantity =>
items.fold<int>(0, (sum, item) => sum + item.quantity);
double get totalAmount =>
items.fold<double>(0, (sum, item) => sum + (item.price * item.quantity));
}
/// 출고 상세 모달에 표시되는 품목 정보.
class OutboundLineItem {
OutboundLineItem({
this.id,
int? lineNo,
int? productId,
String? productCode,
required this.product,
required this.manufacturer,
required this.unit,
required this.quantity,
required this.price,
required this.remark,
}) : lineNo = lineNo ?? 0,
productId = productId ?? 0,
productCode = productCode ?? '';
factory OutboundLineItem.fromLine(StockTransactionLine line) {
final product = line.product;
return OutboundLineItem(
id: line.id,
lineNo: line.lineNo,
productId: product.id,
productCode: product.code,
product: product.name,
manufacturer: product.vendor?.name ?? '-',
unit: product.uom?.name ?? '-',
quantity: line.quantity,
price: line.unitPrice,
remark: line.note ?? '',
);
}
final int? id;
final int lineNo;
final int productId;
final String productCode;
final String product;
final String manufacturer;
final String unit;
final int quantity;
final double price;
final String remark;
}
/// 출고 고객 연결 요약 정보.
class OutboundCustomer {
OutboundCustomer({
this.id,
required this.customerId,
required this.code,
required this.name,
this.note,
});
factory OutboundCustomer.fromLink(StockTransactionCustomer link) {
final target = link.customer;
return OutboundCustomer(
id: link.id,
customerId: target.id,
code: target.code,
name: target.name,
note: link.note,
);
}
final int? id;
final int customerId;
final String code;
final String name;
final String? note;
}

View File

@@ -0,0 +1,71 @@
/// 출고 테이블과 필터 구성을 위한 정적 스펙을 정의한다.
class OutboundTableSpec {
const OutboundTableSpec._();
/// 목록 헤더 라벨.
static const List<String> headers = [
'번호',
'처리일자',
'창고',
'트랜잭션번호',
'제품',
'제조사',
'단위',
'수량',
'단가',
'상태',
'작성자',
'고객수',
'품목수',
'총수량',
'비고',
];
/// 페이지네이션에서 제공하는 항목 수 옵션.
static const List<int> pageSizeOptions = [10, 20, 50];
/// include 파라미터 기본값.
static const List<String> defaultIncludeOptions = ['lines', 'customers'];
/// 백엔드 미응답 시 사용할 기본 상태 라벨.
static const List<String> fallbackStatusOptions = ['작성중', '출고대기', '출고완료'];
/// 출고 트랜잭션 타입 탐색용 키워드.
static const List<String> transactionTypeKeywords = ['출고', 'outbound'];
/// 검색 필드 플레이스홀더.
static const String searchPlaceholder = '트랜잭션번호, 작성자, 제품, 고객사 검색';
/// 창고 전체 라벨.
static const String allWarehouseLabel = '전체 창고';
/// 상태 전체 라벨.
static const String allStatusLabel = '전체 상태';
/// include 선택이 비어 있을 때 보여줄 라벨.
static const String includeEmptyLabel = 'Include 없음';
/// 테이블 열 폭 기준 값.
static const double columnSpanWidth = 140;
/// 테이블 행 높이 기준 값.
static const double rowSpanHeight = 56;
/// 날짜 필터 시작 범위.
static final DateTime dateRangeFirstDate = DateTime(2020);
/// 날짜 필터 종료 범위.
static final DateTime dateRangeLastDate = DateTime(2030);
/// include 파라미터에 대응하는 라벨을 반환한다.
static String includeLabel(String value) {
switch (value) {
case 'lines':
return '라인 포함';
case 'customers':
return '고객 포함';
default:
return value;
}
}
}

View File

@@ -0,0 +1,549 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart';
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import 'package:superport_v2/features/inventory/rental/presentation/models/rental_record.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
import 'package:superport_v2/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart';
/// 대여/반납 목록 데이터를 관리하는 컨트롤러.
class RentalController extends ChangeNotifier {
RentalController({
required StockTransactionRepository transactionRepository,
required TransactionLineRepository lineRepository,
required TransactionCustomerRepository customerRepository,
required InventoryLookupRepository lookupRepository,
List<String> fallbackStatusOptions = const ['대여중', '반납대기', '완료'],
List<String> rentTransactionKeywords = const ['대여', 'rent'],
List<String> returnTransactionKeywords = const ['반납', 'return'],
}) : _transactionRepository = transactionRepository,
_lineRepository = lineRepository,
_customerRepository = customerRepository,
_lookupRepository = lookupRepository,
_fallbackStatusOptions = List<String>.unmodifiable(
fallbackStatusOptions,
),
_rentTransactionKeywords = List<String>.unmodifiable(
rentTransactionKeywords,
),
_returnTransactionKeywords = List<String>.unmodifiable(
returnTransactionKeywords,
) {
_statusOptions = List<String>.from(_fallbackStatusOptions);
}
final StockTransactionRepository _transactionRepository;
final TransactionLineRepository _lineRepository;
final TransactionCustomerRepository _customerRepository;
final InventoryLookupRepository _lookupRepository;
final List<String> _fallbackStatusOptions;
final List<String> _rentTransactionKeywords;
final List<String> _returnTransactionKeywords;
late List<String> _statusOptions;
final Map<String, LookupItem> _statusLookup = {};
LookupItem? _rentTransactionType;
LookupItem? _returnTransactionType;
PaginatedResult<StockTransaction>? _result;
List<RentalRecord> _records = const [];
bool _isLoading = false;
String? _errorMessage;
StockTransactionListFilter? _lastFilter;
final Set<int> _processingTransactionIds = <int>{};
bool _lastFilterByRentalTypes = true;
UnmodifiableListView<String> get statusOptions =>
UnmodifiableListView(_statusOptions);
UnmodifiableMapView<String, LookupItem> get statusLookup =>
UnmodifiableMapView(_statusLookup);
LookupItem? get rentTransactionType => _rentTransactionType;
LookupItem? get returnTransactionType => _returnTransactionType;
PaginatedResult<StockTransaction>? get result => _result;
UnmodifiableListView<RentalRecord> get records =>
UnmodifiableListView(_records);
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
List<String> get fallbackStatusOptions => _fallbackStatusOptions;
StockTransactionListFilter? get lastFilter => _lastFilter;
UnmodifiableSetView<int> get processingTransactionIds =>
UnmodifiableSetView(_processingTransactionIds);
bool get lastFilterByRentalTypes => _lastFilterByRentalTypes;
/// 트랜잭션 상태 목록을 조회한다.
Future<void> loadStatusOptions() async {
try {
final items = await _lookupRepository.fetchTransactionStatuses();
final normalized = items
.map((item) => item.name.trim())
.where((name) => name.isNotEmpty)
.toSet()
.toList(growable: false);
if (normalized.isEmpty) {
return;
}
_statusOptions = normalized;
_statusLookup
..clear()
..addEntries(
items
.where((item) => item.name.trim().isNotEmpty)
.map((item) => MapEntry(item.name.trim(), item)),
);
notifyListeners();
} catch (_) {
// 실패 시 기본 값을 유지한다.
}
}
/// 대여/반납 트랜잭션 타입을 모두 조회한다.
Future<bool> resolveTransactionTypes() async {
if (_rentTransactionType != null || _returnTransactionType != null) {
return true;
}
try {
final items = await _lookupRepository.fetchTransactionTypes();
final rent = _matchLookup(items, _rentTransactionKeywords);
final returns = _matchLookup(items, _returnTransactionKeywords);
if (rent == null && returns == null) {
return false;
}
_rentTransactionType = rent;
_returnTransactionType = returns;
notifyListeners();
return true;
} catch (_) {
return false;
}
}
/// 필터 조건에 맞는 대여/반납 트랜잭션을 조회한다.
Future<void> fetchTransactions({
required StockTransactionListFilter filter,
bool filterByRentalTypes = true,
}) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
_lastFilter = filter;
_lastFilterByRentalTypes = filterByRentalTypes;
final result = await _transactionRepository.list(filter: filter);
final transactions = filterByRentalTypes
? result.items.where(_isRentalTransaction).toList(growable: false)
: result.items;
_records = transactions
.map(RentalRecord.fromTransaction)
.toList(growable: false);
_result = PaginatedResult<StockTransaction>(
items: transactions,
page: filter.page,
pageSize: filter.pageSize,
total: transactions.length,
);
} catch (error) {
_records = const [];
_result = PaginatedResult<StockTransaction>(
items: const [],
page: filter.page,
pageSize: filter.pageSize,
total: 0,
);
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 마지막 필터를 사용해 목록을 새로고침한다.
Future<void> refresh({
StockTransactionListFilter? filter,
bool? filterByRentalTypes,
}) async {
final targetFilter = filter ?? _lastFilter;
if (targetFilter == null) {
return;
}
await fetchTransactions(
filter: targetFilter,
filterByRentalTypes: filterByRentalTypes ?? _lastFilterByRentalTypes,
);
}
/// 대여/반납 트랜잭션을 생성한다.
Future<RentalRecord> createTransaction(
StockTransactionCreateInput input, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
bool? refreshFilterByRentalTypes,
}) {
return _executeMutation(
() => _transactionRepository.create(input),
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
refreshFilterByRentalTypes: refreshFilterByRentalTypes,
);
}
/// 대여/반납 트랜잭션을 수정한다.
Future<RentalRecord> updateTransaction(
int id,
StockTransactionUpdateInput input, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
bool? refreshFilterByRentalTypes,
}) {
return _executeMutation(
() => _transactionRepository.update(id, input),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
refreshFilterByRentalTypes: refreshFilterByRentalTypes,
);
}
/// 대여/반납 트랜잭션을 삭제한다.
Future<void> deleteTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
bool? refreshFilterByRentalTypes,
}) async {
await _executeVoidMutation(
() => _transactionRepository.delete(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
refreshFilterByRentalTypes: refreshFilterByRentalTypes,
);
}
/// 삭제된 대여/반납 트랜잭션을 복구한다.
Future<RentalRecord> restoreTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
bool? refreshFilterByRentalTypes,
}) {
return _executeMutation(
() => _transactionRepository.restore(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
refreshFilterByRentalTypes: refreshFilterByRentalTypes,
);
}
/// 대여/반납 트랜잭션을 상신한다.
Future<RentalRecord> submitTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
bool? refreshFilterByRentalTypes,
}) {
return _executeMutation(
() => _transactionRepository.submit(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
refreshFilterByRentalTypes: refreshFilterByRentalTypes,
);
}
/// 대여/반납 트랜잭션을 완료 처리한다.
Future<RentalRecord> completeTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
bool? refreshFilterByRentalTypes,
}) {
return _executeMutation(
() => _transactionRepository.complete(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
refreshFilterByRentalTypes: refreshFilterByRentalTypes,
);
}
/// 대여/반납 트랜잭션을 승인 처리한다.
Future<RentalRecord> approveTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
bool? refreshFilterByRentalTypes,
}) {
return _executeMutation(
() => _transactionRepository.approve(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
refreshFilterByRentalTypes: refreshFilterByRentalTypes,
);
}
/// 대여/반납 트랜잭션을 반려 처리한다.
Future<RentalRecord> rejectTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
bool? refreshFilterByRentalTypes,
}) {
return _executeMutation(
() => _transactionRepository.reject(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
refreshFilterByRentalTypes: refreshFilterByRentalTypes,
);
}
/// 대여/반납 트랜잭션을 취소 처리한다.
Future<RentalRecord> cancelTransaction(
int id, {
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
bool? refreshFilterByRentalTypes,
}) {
return _executeMutation(
() => _transactionRepository.cancel(id),
trackedId: id,
refreshAfter: refreshAfter,
refreshFilter: refreshFilter,
refreshFilterByRentalTypes: refreshFilterByRentalTypes,
);
}
Future<RentalRecord> _executeMutation(
Future<StockTransaction> Function() mutation, {
int? trackedId,
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
bool? refreshFilterByRentalTypes,
}) async {
if (trackedId != null) {
_processingTransactionIds.add(trackedId);
notifyListeners();
}
_errorMessage = null;
try {
final transaction = await mutation();
if (refreshAfter) {
await _refreshAfterMutation(refreshFilter, refreshFilterByRentalTypes);
} else {
_upsertRecord(transaction);
}
return RentalRecord.fromTransaction(transaction);
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
rethrow;
} finally {
if (trackedId != null) {
_processingTransactionIds.remove(trackedId);
notifyListeners();
}
}
}
Future<void> _executeVoidMutation(
Future<void> Function() mutation, {
required int trackedId,
bool refreshAfter = true,
StockTransactionListFilter? refreshFilter,
bool? refreshFilterByRentalTypes,
}) async {
_processingTransactionIds.add(trackedId);
notifyListeners();
_errorMessage = null;
try {
await mutation();
if (refreshAfter) {
await _refreshAfterMutation(refreshFilter, refreshFilterByRentalTypes);
} else {
_removeRecord(trackedId);
}
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
rethrow;
} finally {
_processingTransactionIds.remove(trackedId);
notifyListeners();
}
}
Future<void> _refreshAfterMutation(
StockTransactionListFilter? refreshFilter,
bool? refreshFilterByRentalTypes,
) async {
final filter = refreshFilter ?? _lastFilter;
if (filter == null) {
return;
}
await fetchTransactions(
filter: filter,
filterByRentalTypes:
refreshFilterByRentalTypes ?? _lastFilterByRentalTypes,
);
}
void _upsertRecord(StockTransaction transaction) {
final record = RentalRecord.fromTransaction(transaction);
final updatedRecords = _records.toList();
final transactionId = transaction.id;
if (transactionId != null) {
final index = updatedRecords.indexWhere(
(item) => item.id == transactionId,
);
if (index >= 0) {
updatedRecords[index] = record;
} else {
updatedRecords.insert(0, record);
}
} else {
updatedRecords.insert(0, record);
}
_records = updatedRecords;
final currentResult = _result;
if (currentResult != null) {
final transactions = currentResult.items.toList();
if (transactionId != null) {
final index = transactions.indexWhere(
(item) => item.id == transactionId,
);
if (index >= 0) {
transactions[index] = transaction;
} else {
transactions.insert(0, transaction);
}
} else {
transactions.insert(0, transaction);
}
_result = currentResult.copyWith(items: transactions);
}
notifyListeners();
}
void _removeRecord(int transactionId) {
final updatedRecords = _records.toList()
..removeWhere((record) => record.id == transactionId);
_records = updatedRecords;
final currentResult = _result;
if (currentResult != null) {
final transactions = currentResult.items
.where((item) => item.id != transactionId)
.toList();
_result = currentResult.copyWith(items: transactions);
}
notifyListeners();
}
bool _isRentalTransaction(StockTransaction transaction) {
final rentId = _rentTransactionType?.id;
final returnId = _returnTransactionType?.id;
if (rentId != null && transaction.type.id == rentId) {
return true;
}
if (returnId != null && transaction.type.id == returnId) {
return true;
}
final name = transaction.type.name.toLowerCase();
return name.contains('대여') ||
name.contains('rent') ||
name.contains('반납') ||
name.contains('return');
}
LookupItem? _matchLookup(List<LookupItem> items, List<String> keywords) {
if (items.isEmpty) {
return null;
}
final normalizedKeywords = keywords
.map((keyword) => keyword.toLowerCase())
.toList(growable: false);
LookupItem? fallback;
for (final item in items) {
final name = item.name.toLowerCase();
final code = item.code?.toLowerCase();
for (final keyword in normalizedKeywords) {
if (name == keyword || name.contains(keyword)) {
return item;
}
if (code != null && (code == keyword || code.contains(keyword))) {
return item;
}
}
if (fallback == null && item.isDefault) {
fallback = item;
}
}
return fallback ?? items.first;
}
/// 라인 변경 계획을 순차적으로 실행한다.
Future<void> syncTransactionLines(
int transactionId,
TransactionLineSyncPlan plan,
) async {
if (!plan.hasChanges) {
return;
}
if (plan.updatedLines.isNotEmpty) {
await _lineRepository.updateLines(transactionId, plan.updatedLines);
}
for (final lineId in plan.deletedLineIds) {
await _lineRepository.deleteLine(lineId);
}
if (plan.createdLines.isNotEmpty) {
await _lineRepository.addLines(transactionId, plan.createdLines);
}
}
/// 고객 연결 변경 계획을 순차적으로 실행한다.
Future<void> syncTransactionCustomers(
int transactionId,
TransactionCustomerSyncPlan plan,
) async {
if (!plan.hasChanges) {
return;
}
if (plan.updatedCustomers.isNotEmpty) {
await _customerRepository.updateCustomers(
transactionId,
plan.updatedCustomers,
);
}
for (final linkId in plan.deletedCustomerIds) {
await _customerRepository.deleteCustomer(linkId);
}
if (plan.createdCustomers.isNotEmpty) {
await _customerRepository.addCustomers(
transactionId,
plan.createdCustomers,
);
}
}
}

View File

@@ -0,0 +1,154 @@
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart';
/// 대여/반납 화면에서 사용하는 UI 전용 레코드 모델.
class RentalRecord {
RentalRecord({
this.id,
required this.number,
required this.transactionNumber,
required this.transactionType,
required this.rentalType,
this.transactionTypeId,
required this.processedAt,
required this.warehouse,
this.warehouseId,
required this.status,
this.statusId,
required this.writer,
this.writerId,
required this.remark,
required this.returnDueDate,
required this.items,
required this.customers,
this.raw,
});
factory RentalRecord.fromTransaction(StockTransaction transaction) {
return RentalRecord(
id: transaction.id,
number: transaction.transactionNo,
transactionNumber: transaction.transactionNo,
transactionType: transaction.type.name,
rentalType: transaction.type.name,
transactionTypeId: transaction.type.id,
processedAt: transaction.transactionDate,
warehouse: transaction.warehouse.name,
warehouseId: transaction.warehouse.id,
status: transaction.status.name,
statusId: transaction.status.id,
writer: transaction.createdBy.name,
writerId: transaction.createdBy.id,
remark: transaction.note ?? '-',
returnDueDate: transaction.expectedReturnDate,
items: transaction.lines
.map(RentalLineItem.fromLine)
.toList(growable: false),
customers: transaction.customers
.map(RentalCustomer.fromLink)
.toList(growable: false),
raw: transaction,
);
}
final int? id;
final String number;
final String transactionNumber;
final String transactionType;
final String rentalType;
final int? transactionTypeId;
final DateTime processedAt;
final String warehouse;
final int? warehouseId;
final String status;
final int? statusId;
final String writer;
final int? writerId;
final String remark;
final DateTime? returnDueDate;
final List<RentalLineItem> items;
final List<RentalCustomer> customers;
final StockTransaction? raw;
int get customerCount => customers.length;
int get itemCount => items.length;
int get totalQuantity =>
items.fold<int>(0, (sum, item) => sum + item.quantity);
double get totalAmount =>
items.fold<double>(0, (sum, item) => sum + (item.price * item.quantity));
}
/// 대여 상세 모달에 표시되는 품목 정보.
class RentalLineItem {
RentalLineItem({
this.id,
int? lineNo,
int? productId,
String? productCode,
required this.product,
required this.manufacturer,
required this.unit,
required this.quantity,
required this.price,
required this.remark,
}) : lineNo = lineNo ?? 0,
productId = productId ?? 0,
productCode = productCode ?? '';
factory RentalLineItem.fromLine(StockTransactionLine line) {
final product = line.product;
return RentalLineItem(
id: line.id,
lineNo: line.lineNo,
productId: product.id,
productCode: product.code,
product: product.name,
manufacturer: product.vendor?.name ?? '-',
unit: product.uom?.name ?? '-',
quantity: line.quantity,
price: line.unitPrice,
remark: line.note ?? '',
);
}
final int? id;
final int lineNo;
final int productId;
final String productCode;
final String product;
final String manufacturer;
final String unit;
final int quantity;
final double price;
final String remark;
}
/// 대여 고객 연결 요약 정보.
class RentalCustomer {
RentalCustomer({
this.id,
required this.customerId,
required this.code,
required this.name,
this.note,
});
factory RentalCustomer.fromLink(StockTransactionCustomer link) {
final summary = link.customer;
return RentalCustomer(
id: link.id,
customerId: summary.id,
code: summary.code,
name: summary.name,
note: link.note,
);
}
final int? id;
final int customerId;
final String code;
final String name;
final String? note;
}

View File

@@ -0,0 +1,76 @@
/// 대여 테이블과 필터 구성에 필요한 정적 스펙을 정의한다.
class RentalTableSpec {
const RentalTableSpec._();
/// 목록 헤더 라벨.
static const List<String> headers = [
'번호',
'처리일자',
'창고',
'대여구분',
'트랜잭션번호',
'상태',
'반납예정일',
'고객수',
'품목수',
'총수량',
'비고',
];
/// 페이지당 항목 수 선택지.
static const List<int> pageSizeOptions = [10, 20, 50];
/// include 파라미터 기본값.
static const List<String> defaultIncludeOptions = ['lines', 'customers'];
/// 백엔드 미응답 시 사용하는 기본 상태라벨.
static const List<String> fallbackStatusOptions = ['대여중', '반납대기', '완료'];
/// 대여 타입 식별 키워드.
static const List<String> rentTransactionKeywords = ['대여', 'rent'];
/// 반납 타입 식별 키워드.
static const List<String> returnTransactionKeywords = ['반납', 'return'];
/// 필터에서 제공하는 대여구분 라벨.
static const List<String> rentalTypes = ['대여', '반납'];
/// 검색 플레이스홀더.
static const String searchPlaceholder = '트랜잭션번호, 작성자, 제품, 고객사 검색';
/// 창고 전체 라벨.
static const String allWarehouseLabel = '전체 창고';
/// 상태 전체 라벨.
static const String allStatusLabel = '전체 상태';
/// 대여구분 전체 라벨.
static const String allRentalTypeLabel = '대여구분 전체';
/// include 선택 없음 라벨.
static const String includeEmptyLabel = 'Include 없음';
/// 테이블 열 폭 기준 값.
static const double columnSpanWidth = 140;
/// 테이블 행 높이 기준 값.
static const double rowSpanHeight = 56;
/// 날짜 필터 시작 범위.
static final DateTime dateRangeFirstDate = DateTime(2020);
/// 날짜 필터 종료 범위.
static final DateTime dateRangeLastDate = DateTime(2030);
/// include 라벨 정의.
static String includeLabel(String value) {
switch (value) {
case 'lines':
return '라인 포함';
case 'customers':
return '고객 포함';
default:
return value;
}
}
}

View File

@@ -1,232 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// 인벤토리 폼에서 공유하는 제품 카탈로그 항목.
class InventoryProductCatalogItem {
const InventoryProductCatalogItem({
required this.code,
required this.name,
required this.manufacturer,
required this.unit,
});
final String code;
final String name;
final String manufacturer;
final String unit;
}
String _normalizeText(String value) {
return value.toLowerCase().replaceAll(RegExp(r'[^a-z0-9가-힣]'), '');
}
/// 제품 카탈로그 유틸리티.
class InventoryProductCatalog {
static final List<InventoryProductCatalogItem> items = List.unmodifiable([
const InventoryProductCatalogItem(
code: 'P-100',
name: 'XR-5000',
manufacturer: '슈퍼벤더',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-101',
name: 'XR-5001',
manufacturer: '슈퍼벤더',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-102',
name: 'Eco-200',
manufacturer: '그린텍',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-201',
name: 'Delta-One',
manufacturer: '델타',
unit: 'SET',
),
const InventoryProductCatalogItem(
code: 'P-210',
name: 'SmartGauge A1',
manufacturer: '슈퍼벤더',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-305',
name: 'PowerPack Mini',
manufacturer: '에이치솔루션',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-320',
name: 'Hydra-Flow 2',
manufacturer: '블루하이드',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-401',
name: 'SolarEdge Pro',
manufacturer: '그린텍',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-430',
name: 'Alpha-Kit 12',
manufacturer: '테크솔루션',
unit: 'SET',
),
const InventoryProductCatalogItem(
code: 'P-501',
name: 'LogiSense 5',
manufacturer: '슈퍼벤더',
unit: 'EA',
),
]);
static final Map<String, InventoryProductCatalogItem> _byKey = {
for (final item in items) _normalizeText(item.name): item,
};
static InventoryProductCatalogItem? match(String value) {
if (value.isEmpty) return null;
return _byKey[_normalizeText(value)];
}
static List<InventoryProductCatalogItem> filter(String query) {
final normalized = _normalizeText(query.trim());
if (normalized.isEmpty) {
return items.take(12).toList();
}
final lower = query.trim().toLowerCase();
return [
for (final item in items)
if (_normalizeText(item.name).contains(normalized) ||
item.code.toLowerCase().contains(lower))
item,
];
}
}
/// 고객 카탈로그 항목.
class InventoryCustomerCatalogItem {
const InventoryCustomerCatalogItem({
required this.code,
required this.name,
required this.industry,
required this.region,
});
final String code;
final String name;
final String industry;
final String region;
}
/// 고객 카탈로그 유틸리티.
class InventoryCustomerCatalog {
static final List<InventoryCustomerCatalogItem> items = List.unmodifiable([
const InventoryCustomerCatalogItem(
code: 'C-1001',
name: '슈퍼포트 파트너',
industry: '물류',
region: '서울',
),
const InventoryCustomerCatalogItem(
code: 'C-1002',
name: '그린에너지',
industry: '에너지',
region: '대전',
),
const InventoryCustomerCatalogItem(
code: 'C-1003',
name: '테크솔루션',
industry: 'IT 서비스',
region: '부산',
),
const InventoryCustomerCatalogItem(
code: 'C-1004',
name: '에이치솔루션',
industry: '제조',
region: '인천',
),
const InventoryCustomerCatalogItem(
code: 'C-1005',
name: '블루하이드',
industry: '해양장비',
region: '울산',
),
const InventoryCustomerCatalogItem(
code: 'C-1010',
name: '넥스트파워',
industry: '발전설비',
region: '광주',
),
const InventoryCustomerCatalogItem(
code: 'C-1011',
name: '씨에스테크',
industry: '반도체',
region: '수원',
),
const InventoryCustomerCatalogItem(
code: 'C-1012',
name: '알파시스템',
industry: '장비임대',
region: '대구',
),
const InventoryCustomerCatalogItem(
code: 'C-1013',
name: '스타트랩',
industry: '연구개발',
region: '세종',
),
const InventoryCustomerCatalogItem(
code: 'C-1014',
name: '메가스틸',
industry: '철강',
region: '포항',
),
]);
static final Map<String, InventoryCustomerCatalogItem> _byName = {
for (final item in items) item.name: item,
};
static InventoryCustomerCatalogItem? byName(String name) => _byName[name];
static List<InventoryCustomerCatalogItem> filter(String query) {
final normalized = _normalizeText(query.trim());
if (normalized.isEmpty) {
return items;
}
final lower = query.trim().toLowerCase();
return [
for (final item in items)
if (_normalizeText(item.name).contains(normalized) ||
item.code.toLowerCase().contains(lower) ||
_normalizeText(item.industry).contains(normalized) ||
_normalizeText(item.region).contains(normalized))
item,
];
}
static String displayLabel(String name) {
final item = byName(name);
if (item == null) {
return name;
}
return '${item.name} (${item.code})';
}
}
/// 검색 결과가 없을 때 노출할 기본 위젯.
Widget buildEmptySearchResult(
ShadTextTheme textTheme, {
String message = '검색 결과가 없습니다.',
}) {
return Padding(
padding: const EdgeInsets.all(12),
child: Text(message, style: textTheme.muted),
);
}

View File

@@ -0,0 +1,265 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/features/masters/customer/domain/entities/customer.dart';
import 'package:superport_v2/features/masters/customer/domain/repositories/customer_repository.dart';
/// 고객 검색 결과 모델.
class InventoryCustomerOption {
InventoryCustomerOption({
required this.id,
required this.code,
required this.name,
required this.industry,
required this.region,
});
factory InventoryCustomerOption.fromCustomer(Customer customer) {
return InventoryCustomerOption(
id: customer.id ?? 0,
code: customer.customerCode,
name: customer.customerName,
industry: customer.note ?? '-',
region: customer.zipcode?.sido ?? '-',
);
}
final int id;
final String code;
final String name;
final String industry;
final String region;
@override
String toString() => '$name ($code)';
}
/// 여러 고객을 원격 검색으로 선택하는 필드.
class InventoryCustomerMultiSelectField extends StatefulWidget {
const InventoryCustomerMultiSelectField({
super.key,
this.initialCustomerIds = const <int>{},
required this.onChanged,
this.enabled = true,
this.placeholder,
});
final Set<int> initialCustomerIds;
final ValueChanged<List<InventoryCustomerOption>> onChanged;
final bool enabled;
final Widget? placeholder;
@override
State<InventoryCustomerMultiSelectField> createState() =>
_InventoryCustomerMultiSelectFieldState();
}
class _InventoryCustomerMultiSelectFieldState
extends State<InventoryCustomerMultiSelectField> {
static const Duration _debounceDuration = Duration(milliseconds: 300);
final ShadSelectController<int> _controller = ShadSelectController<int>(
initialValue: <int>{},
);
final Map<int, InventoryCustomerOption> _selectedOptions = {};
final List<InventoryCustomerOption> _suggestions = [];
Timer? _debounce;
int _requestId = 0;
bool _isSearching = false;
String _searchKeyword = '';
CustomerRepository? get _repository =>
GetIt.I.isRegistered<CustomerRepository>()
? GetIt.I<CustomerRepository>()
: null;
@override
void initState() {
super.initState();
if (widget.initialCustomerIds.isNotEmpty) {
_controller.value = widget.initialCustomerIds.toSet();
_prefetchInitialOptions(widget.initialCustomerIds);
}
}
Future<void> _prefetchInitialOptions(Set<int> ids) async {
final repository = _repository;
if (repository == null) {
return;
}
for (final id in ids) {
try {
final detail = await repository.fetchDetail(id, includeZipcode: false);
_selectedOptions[id] = InventoryCustomerOption.fromCustomer(detail);
} catch (_) {
// 무시하고 다음 ID로 진행한다.
}
}
_notifySelection();
setState(() {});
}
void _scheduleSearch(String keyword) {
_debounce?.cancel();
_debounce = Timer(_debounceDuration, () => _search(keyword));
}
Future<void> _search(String keyword) async {
final repository = _repository;
if (repository == null) {
return;
}
final request = ++_requestId;
setState(() {
_isSearching = true;
});
try {
final result = await repository.list(
page: 1,
pageSize: 20,
query: keyword.isEmpty ? null : keyword,
isActive: true,
);
if (!mounted || request != _requestId) {
return;
}
setState(() {
_suggestions
..clear()
..addAll(result.items.map(InventoryCustomerOption.fromCustomer));
_isSearching = false;
});
} catch (_) {
if (!mounted || request != _requestId) {
return;
}
setState(() {
_suggestions.clear();
_isSearching = false;
});
}
}
void _notifySelection() {
widget.onChanged(_selectedOptions.values.toList(growable: false));
}
@override
void dispose() {
_controller.dispose();
_debounce?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final placeholder = widget.placeholder ?? const Text('고객사 선택');
final selector = ShadSelect<int>.multipleWithSearch(
controller: _controller,
placeholder: placeholder,
searchPlaceholder: const Text('고객사 이름 또는 코드 검색'),
searchInputLeading: const Icon(LucideIcons.search, size: 16),
clearSearchOnClose: false,
closeOnSelect: false,
onChanged: (values) {
final sanitized = values.where((value) => value >= 0).toSet();
if (!setEquals(sanitized, values)) {
_controller.value = sanitized;
}
// 선택된 값이 deselect 되었을 때 map에서 제거한다.
_selectedOptions.removeWhere((key, _) => !sanitized.contains(key));
for (final value in sanitized) {
final existing = _selectedOptions[value];
if (existing != null) {
continue;
}
final match = _suggestions.firstWhere(
(option) => option.id == value,
orElse: () => InventoryCustomerOption(
id: value,
code: 'ID-$value',
name: '고객 $value',
industry: '-',
region: '-',
),
);
_selectedOptions[value] = match;
}
_notifySelection();
setState(() {});
},
onSearchChanged: (keyword) {
setState(() {
_searchKeyword = keyword;
});
_scheduleSearch(keyword);
},
selectedOptionsBuilder: (context, values) {
if (values.isEmpty) {
return const Text('선택된 고객사가 없습니다');
}
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final value in values)
ShadBadge(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Text(_selectedOptions[value]?.name ?? '고객 $value'),
),
),
],
);
},
options: [
if (_isSearching)
const ShadOption(
value: -1,
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('검색 중...'),
],
),
),
for (final option in _suggestions)
ShadOption(
value: option.id,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(option.name),
const SizedBox(height: 2),
Text(
'${option.code} · ${option.industry} · ${option.region}',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
if (!_isSearching && _suggestions.isEmpty && _searchKeyword.isNotEmpty)
const ShadOption(value: -2, child: Text('검색 결과가 없습니다.')),
],
);
if (!widget.enabled) {
return Opacity(
opacity: 0.6,
child: IgnorePointer(ignoring: true, child: selector),
);
}
return selector;
}
}

View File

@@ -0,0 +1,282 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/features/masters/user/domain/entities/user.dart';
import 'package:superport_v2/features/masters/user/domain/repositories/user_repository.dart';
/// 작성자(직원) 검색 결과 모델.
class InventoryEmployeeSuggestion {
InventoryEmployeeSuggestion({
required this.id,
required this.employeeNo,
required this.name,
});
factory InventoryEmployeeSuggestion.fromUser(UserAccount account) {
return InventoryEmployeeSuggestion(
id: account.id ?? 0,
employeeNo: account.employeeNo,
name: account.employeeName,
);
}
final int id;
final String employeeNo;
final String name;
}
/// 작성자를 자동완성으로 선택하는 입력 필드.
class InventoryEmployeeAutocompleteField extends StatefulWidget {
const InventoryEmployeeAutocompleteField({
super.key,
required this.controller,
this.initialSuggestion,
required this.onSuggestionSelected,
this.onChanged,
this.enabled = true,
});
final TextEditingController controller;
final InventoryEmployeeSuggestion? initialSuggestion;
final ValueChanged<InventoryEmployeeSuggestion?> onSuggestionSelected;
final VoidCallback? onChanged;
final bool enabled;
@override
State<InventoryEmployeeAutocompleteField> createState() =>
_InventoryEmployeeAutocompleteFieldState();
}
class _InventoryEmployeeAutocompleteFieldState
extends State<InventoryEmployeeAutocompleteField> {
static const Duration _debounceDuration = Duration(milliseconds: 250);
final List<InventoryEmployeeSuggestion> _suggestions = [];
InventoryEmployeeSuggestion? _selected;
Timer? _debounce;
int _requestId = 0;
bool _isSearching = false;
late final FocusNode _focusNode;
UserRepository? get _repository =>
GetIt.I.isRegistered<UserRepository>() ? GetIt.I<UserRepository>() : null;
@override
void initState() {
super.initState();
_selected = widget.initialSuggestion;
_focusNode = FocusNode();
widget.controller.addListener(_handleChanged);
if (_selected != null && widget.controller.text.isEmpty) {
widget.controller.text = _displayLabel(_selected!);
}
}
@override
void didUpdateWidget(covariant InventoryEmployeeAutocompleteField oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.controller, widget.controller)) {
oldWidget.controller.removeListener(_handleChanged);
widget.controller.addListener(_handleChanged);
}
if (widget.initialSuggestion != oldWidget.initialSuggestion &&
widget.initialSuggestion != null) {
_selected = widget.initialSuggestion;
widget.controller.text = _displayLabel(widget.initialSuggestion!);
}
}
void _handleChanged() {
final text = widget.controller.text.trim();
if (text.isEmpty) {
_clearSelection();
return;
}
_scheduleSearch(text);
widget.onChanged?.call();
}
void _scheduleSearch(String keyword) {
_debounce?.cancel();
_debounce = Timer(_debounceDuration, () => _search(keyword));
}
Future<void> _search(String keyword) async {
final repository = _repository;
if (repository == null) {
return;
}
final request = ++_requestId;
setState(() {
_isSearching = true;
});
try {
final result = await repository.list(
page: 1,
pageSize: 15,
query: keyword,
isActive: true,
);
if (!mounted || request != _requestId) {
return;
}
setState(() {
_suggestions
..clear()
..addAll(result.items.map(InventoryEmployeeSuggestion.fromUser));
_isSearching = false;
});
} catch (_) {
if (!mounted || request != _requestId) {
return;
}
setState(() {
_suggestions.clear();
_isSearching = false;
});
}
}
void _applySuggestion(InventoryEmployeeSuggestion suggestion) {
setState(() {
_selected = suggestion;
});
widget.controller.text = _displayLabel(suggestion);
widget.onSuggestionSelected(suggestion);
widget.onChanged?.call();
}
void _clearSelection() {
if (_selected == null) {
return;
}
setState(() {
_selected = null;
_suggestions.clear();
_isSearching = false;
});
widget.onSuggestionSelected(null);
widget.controller.clear();
}
String _displayLabel(InventoryEmployeeSuggestion suggestion) {
return '${suggestion.name} (${suggestion.employeeNo})';
}
@override
void dispose() {
widget.controller.removeListener(_handleChanged);
_debounce?.cancel();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return RawAutocomplete<InventoryEmployeeSuggestion>(
textEditingController: widget.controller,
focusNode: _focusNode,
optionsBuilder: (textEditingValue) {
if (textEditingValue.text.trim().isEmpty) {
return const Iterable<InventoryEmployeeSuggestion>.empty();
}
return _suggestions;
},
displayStringForOption: _displayLabel,
onSelected: _applySuggestion,
fieldViewBuilder: (context, textController, focusNode, onFieldSubmitted) {
final input = ShadInput(
controller: textController,
focusNode: focusNode,
enabled: widget.enabled,
placeholder: const Text('작성자 이름 또는 사번 검색'),
onChanged: (_) => widget.onChanged?.call(),
onSubmitted: (_) => onFieldSubmitted(),
);
if (!_isSearching) {
return input;
}
return Stack(
alignment: Alignment.centerRight,
children: [
input,
const Padding(
padding: EdgeInsets.only(right: 12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
);
},
optionsViewBuilder: (context, onSelected, options) {
if (options.isEmpty) {
return Align(
alignment: AlignmentDirectional.topStart,
child: Material(
elevation: 6,
color: theme.colorScheme.background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: theme.colorScheme.border),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text('일치하는 직원이 없습니다.', style: theme.textTheme.muted),
),
),
),
);
}
return Align(
alignment: AlignmentDirectional.topStart,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240, maxWidth: 360),
child: Material(
elevation: 6,
color: theme.colorScheme.background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: theme.colorScheme.border),
),
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 6),
itemCount: options.length,
itemBuilder: (context, index) {
final suggestion = options.elementAt(index);
return InkWell(
onTap: () => onSelected(suggestion),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(suggestion.name, style: theme.textTheme.p),
const SizedBox(height: 4),
Text(
'ID ${suggestion.id} · ${suggestion.employeeNo}',
style: theme.textTheme.muted.copyWith(fontSize: 12),
),
],
),
),
);
},
),
),
),
);
},
);
}
}

View File

@@ -1,9 +1,40 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
import '../catalogs.dart'; import 'package:superport_v2/features/masters/product/domain/entities/product.dart';
import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart';
/// 제품명 입력 시 카탈로그 자동완성을 제공하는 필드. /// 제품 검색 결과를 표현하는 제안 모델.
class InventoryProductSuggestion {
InventoryProductSuggestion({
required this.id,
required this.code,
required this.name,
required this.vendorName,
required this.unitName,
});
factory InventoryProductSuggestion.fromProduct(Product product) {
return InventoryProductSuggestion(
id: product.id ?? 0,
code: product.productCode,
name: product.productName,
vendorName: product.vendor?.vendorName ?? '-',
unitName: product.uom?.uomName ?? '-',
);
}
final int id;
final String code;
final String name;
final String vendorName;
final String unitName;
}
/// 제품명 자동완성을 제공하는 입력 필드.
class InventoryProductAutocompleteField extends StatefulWidget { class InventoryProductAutocompleteField extends StatefulWidget {
const InventoryProductAutocompleteField({ const InventoryProductAutocompleteField({
super.key, super.key,
@@ -11,7 +42,8 @@ class InventoryProductAutocompleteField extends StatefulWidget {
required this.productFocusNode, required this.productFocusNode,
required this.manufacturerController, required this.manufacturerController,
required this.unitController, required this.unitController,
required this.onCatalogMatched, required this.onSuggestionSelected,
this.initialSuggestion,
this.onChanged, this.onChanged,
}); });
@@ -19,7 +51,8 @@ class InventoryProductAutocompleteField extends StatefulWidget {
final FocusNode productFocusNode; final FocusNode productFocusNode;
final TextEditingController manufacturerController; final TextEditingController manufacturerController;
final TextEditingController unitController; final TextEditingController unitController;
final ValueChanged<InventoryProductCatalogItem?> onCatalogMatched; final ValueChanged<InventoryProductSuggestion?> onSuggestionSelected;
final InventoryProductSuggestion? initialSuggestion;
final VoidCallback? onChanged; final VoidCallback? onChanged;
@override @override
@@ -27,21 +60,35 @@ class InventoryProductAutocompleteField extends StatefulWidget {
_InventoryProductAutocompleteFieldState(); _InventoryProductAutocompleteFieldState();
} }
/// 자동완성 로직을 구현한 상태 클래스. /// 제품 자동완성 위젯의 내부 상태를 관리한다.
class _InventoryProductAutocompleteFieldState class _InventoryProductAutocompleteFieldState
extends State<InventoryProductAutocompleteField> { extends State<InventoryProductAutocompleteField> {
InventoryProductCatalogItem? _catalogMatch; static const Duration _debounceDuration = Duration(milliseconds: 280);
final List<InventoryProductSuggestion> _suggestions =
<InventoryProductSuggestion>[];
InventoryProductSuggestion? _selected;
Timer? _debounce;
int _requestCounter = 0;
bool _isSearching = false;
ProductRepository? get _repository =>
GetIt.I.isRegistered<ProductRepository>()
? GetIt.I<ProductRepository>()
: null;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_catalogMatch = InventoryProductCatalog.match( _selected = widget.initialSuggestion;
widget.productController.text.trim(), if (_selected != null) {
); _applySuggestion(_selected!, updateProductField: false);
if (_catalogMatch != null) {
_applyCatalog(_catalogMatch!, updateProduct: false);
} }
widget.productController.addListener(_handleTextChanged); widget.productController.addListener(_handleTextChanged);
if (widget.productController.text.trim().isNotEmpty &&
widget.initialSuggestion == null) {
_scheduleSearch(widget.productController.text.trim());
}
} }
@override @override
@@ -50,70 +97,119 @@ class _InventoryProductAutocompleteFieldState
if (!identical(oldWidget.productController, widget.productController)) { if (!identical(oldWidget.productController, widget.productController)) {
oldWidget.productController.removeListener(_handleTextChanged); oldWidget.productController.removeListener(_handleTextChanged);
widget.productController.addListener(_handleTextChanged); widget.productController.addListener(_handleTextChanged);
_catalogMatch = InventoryProductCatalog.match( _scheduleSearch(widget.productController.text.trim());
widget.productController.text.trim(),
);
if (_catalogMatch != null) {
_applyCatalog(_catalogMatch!, updateProduct: false);
} }
if (widget.initialSuggestion != oldWidget.initialSuggestion &&
widget.initialSuggestion != null) {
_selected = widget.initialSuggestion;
_applySuggestion(widget.initialSuggestion!, updateProductField: false);
} }
} }
/// 텍스트 입력 변화를 감지해 자동완성 결과를 적용한다. /// 입력 변화에 따라 제안 검색을 예약한다.
void _handleTextChanged() { void _handleTextChanged() {
final text = widget.productController.text.trim(); final text = widget.productController.text.trim();
final match = InventoryProductCatalog.match(text); if (text.isEmpty) {
if (match != null) { _clearSelection();
_applyCatalog(match);
return; return;
} }
if (_catalogMatch != null) { _scheduleSearch(text);
setState(() {
_catalogMatch = null;
});
widget.onCatalogMatched(null);
if (widget.manufacturerController.text.isNotEmpty) {
widget.manufacturerController.clear();
}
if (widget.unitController.text.isNotEmpty) {
widget.unitController.clear();
}
}
widget.onChanged?.call(); widget.onChanged?.call();
} }
/// 선택된 카탈로그 정보를 관련 필드에 적용한다. void _scheduleSearch(String keyword) {
void _applyCatalog( _debounce?.cancel();
InventoryProductCatalogItem match, { if (keyword.isEmpty) {
bool updateProduct = true, setState(() {
_isSearching = false;
_suggestions.clear();
});
return;
}
_debounce = Timer(_debounceDuration, () => _fetchSuggestions(keyword));
}
Future<void> _fetchSuggestions(String keyword) async {
final repository = _repository;
if (repository == null) {
return;
}
final currentRequest = ++_requestCounter;
setState(() {
_isSearching = true;
});
try {
final result = await repository.list(
page: 1,
pageSize: 12,
query: keyword,
isActive: true,
);
if (!mounted || currentRequest != _requestCounter) {
return;
}
setState(() {
_suggestions
..clear()
..addAll(result.items.map(InventoryProductSuggestion.fromProduct));
_isSearching = false;
});
} catch (_) {
if (!mounted || currentRequest != _requestCounter) {
return;
}
setState(() {
_suggestions.clear();
_isSearching = false;
});
}
}
/// 선택된 제안 정보를 외부 필드에 반영한다.
void _applySuggestion(
InventoryProductSuggestion suggestion, {
bool updateProductField = true,
}) { }) {
setState(() { setState(() {
_catalogMatch = match; _selected = suggestion;
}); });
widget.onCatalogMatched(match); widget.onSuggestionSelected(suggestion);
if (updateProduct && widget.productController.text != match.name) { if (updateProductField) {
widget.productController.text = match.name; widget.productController.text = suggestion.name;
widget.productController.selection = TextSelection.collapsed( widget.productController.selection = TextSelection.collapsed(
offset: widget.productController.text.length, offset: widget.productController.text.length,
); );
} }
if (widget.manufacturerController.text != match.manufacturer) { if (widget.manufacturerController.text != suggestion.vendorName) {
widget.manufacturerController.text = match.manufacturer; widget.manufacturerController.text = suggestion.vendorName;
} }
if (widget.unitController.text != match.unit) { if (widget.unitController.text != suggestion.unitName) {
widget.unitController.text = match.unit; widget.unitController.text = suggestion.unitName;
} }
widget.onChanged?.call(); widget.onChanged?.call();
} }
/// 주어진 검색어에 매칭되는 제품 목록을 반환한다. void _clearSelection() {
Iterable<InventoryProductCatalogItem> _options(String query) { if (_selected == null &&
return InventoryProductCatalog.filter(query); widget.manufacturerController.text.isEmpty &&
widget.unitController.text.isEmpty) {
return;
}
setState(() {
_selected = null;
_suggestions.clear();
_isSearching = false;
});
widget.onSuggestionSelected(null);
widget.manufacturerController.clear();
widget.unitController.clear();
widget.onChanged?.call();
} }
@override @override
void dispose() { void dispose() {
widget.productController.removeListener(_handleTextChanged); widget.productController.removeListener(_handleTextChanged);
_debounce?.cancel();
super.dispose(); super.dispose();
} }
@@ -122,25 +218,45 @@ class _InventoryProductAutocompleteFieldState
final theme = ShadTheme.of(context); final theme = ShadTheme.of(context);
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
return RawAutocomplete<InventoryProductCatalogItem>( return RawAutocomplete<InventoryProductSuggestion>(
textEditingController: widget.productController, textEditingController: widget.productController,
focusNode: widget.productFocusNode, focusNode: widget.productFocusNode,
optionsBuilder: (textEditingValue) { optionsBuilder: (textEditingValue) {
return _options(textEditingValue.text); if (textEditingValue.text.trim().isEmpty) {
return const Iterable<InventoryProductSuggestion>.empty();
}
return _suggestions;
}, },
displayStringForOption: (option) => option.name, displayStringForOption: (option) => option.name,
onSelected: (option) { onSelected: (option) {
_applyCatalog(option); _applySuggestion(option);
}, },
fieldViewBuilder: fieldViewBuilder:
(context, textEditingController, focusNode, onFieldSubmitted) { (context, textController, focusNode, onFieldSubmitted) {
return ShadInput( final input = ShadInput(
controller: textEditingController, controller: textController,
focusNode: focusNode, focusNode: focusNode,
placeholder: const Text('제품명'), placeholder: const Text('제품명 검색'),
onChanged: (_) => widget.onChanged?.call(), onChanged: (_) => widget.onChanged?.call(),
onSubmitted: (_) => onFieldSubmitted(), onSubmitted: (_) => onFieldSubmitted(),
); );
if (!_isSearching) {
return input;
}
return Stack(
alignment: Alignment.centerRight,
children: [
input,
const Padding(
padding: EdgeInsets.only(right: 12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
);
}, },
optionsViewBuilder: (context, onSelected, options) { optionsViewBuilder: (context, onSelected, options) {
if (options.isEmpty) { if (options.isEmpty) {
@@ -149,7 +265,7 @@ class _InventoryProductAutocompleteFieldState
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: constraints.maxWidth, maxWidth: constraints.maxWidth,
maxHeight: 240, maxHeight: 220,
), ),
child: Material( child: Material(
elevation: 6, elevation: 6,
@@ -158,7 +274,15 @@ class _InventoryProductAutocompleteFieldState
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
side: BorderSide(color: theme.colorScheme.border), side: BorderSide(color: theme.colorScheme.border),
), ),
child: buildEmptySearchResult(theme.textTheme), child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Text(
'검색 결과가 없습니다.',
style: theme.textTheme.muted,
),
),
),
), ),
), ),
); );
@@ -195,7 +319,7 @@ class _InventoryProductAutocompleteFieldState
Text(option.name, style: theme.textTheme.p), Text(option.name, style: theme.textTheme.p),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'${option.code} · ${option.manufacturer} · ${option.unit}', '${option.code} · ${option.vendorName} · ${option.unitName}',
style: theme.textTheme.muted.copyWith( style: theme.textTheme.muted.copyWith(
fontSize: 12, fontSize: 12,
), ),

View File

@@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
/// 창고 선택 옵션 모델.
class InventoryWarehouseOption {
InventoryWarehouseOption({
required this.id,
required this.code,
required this.name,
});
factory InventoryWarehouseOption.fromWarehouse(Warehouse warehouse) {
return InventoryWarehouseOption(
id: warehouse.id ?? 0,
code: warehouse.warehouseCode,
name: warehouse.warehouseName,
);
}
final int id;
final String code;
final String name;
@override
String toString() => '$name ($code)';
}
/// 창고 목록을 불러와 `ShadSelect`로 노출하는 위젯.
///
/// - 서버에서 활성화된 창고를 조회해 옵션으로 구성한다.
/// - `includeAllOption`을 사용하면 '전체' 선택지를 함께 제공한다.
class InventoryWarehouseSelectField extends StatefulWidget {
const InventoryWarehouseSelectField({
super.key,
this.initialWarehouseId,
required this.onChanged,
this.enabled = true,
this.placeholder,
this.includeAllOption = false,
this.allLabel = '전체 창고',
});
final int? initialWarehouseId;
final ValueChanged<InventoryWarehouseOption?> onChanged;
final bool enabled;
final Widget? placeholder;
final bool includeAllOption;
final String allLabel;
@override
State<InventoryWarehouseSelectField> createState() =>
_InventoryWarehouseSelectFieldState();
}
class _InventoryWarehouseSelectFieldState
extends State<InventoryWarehouseSelectField> {
WarehouseRepository? get _repository =>
GetIt.I.isRegistered<WarehouseRepository>()
? GetIt.I<WarehouseRepository>()
: null;
List<InventoryWarehouseOption> _options = const [];
InventoryWarehouseOption? _selected;
bool _isLoading = false;
String? _error;
@override
void initState() {
super.initState();
_loadWarehouses();
}
@override
void didUpdateWidget(covariant InventoryWarehouseSelectField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.initialWarehouseId != oldWidget.initialWarehouseId) {
_syncSelection(widget.initialWarehouseId);
}
}
Future<void> _loadWarehouses() async {
final repository = _repository;
if (repository == null) {
setState(() {
_error = '창고 데이터 소스를 찾을 수 없습니다.';
});
return;
}
setState(() {
_isLoading = true;
_error = null;
});
try {
final result = await repository.list(
page: 1,
pageSize: 100,
isActive: true,
includeZipcode: false,
);
final options = result.items
.map(InventoryWarehouseOption.fromWarehouse)
.toList(growable: false);
final selected = _findOptionById(options, widget.initialWarehouseId);
setState(() {
_options = options;
_selected = selected;
_isLoading = false;
});
if (selected != null) {
widget.onChanged(selected);
}
} catch (error) {
setState(() {
final failure = Failure.from(error);
_error = failure.describe();
_isLoading = false;
});
}
}
void _syncSelection(int? warehouseId) {
if (_options.isEmpty) {
if (warehouseId == null && _selected != null) {
setState(() {
_selected = null;
});
widget.onChanged(null);
}
return;
}
final next = _findOptionById(_options, warehouseId);
if (warehouseId == null && _selected != null) {
setState(() {
_selected = null;
});
widget.onChanged(null);
return;
}
if (!identical(next, _selected)) {
setState(() {
_selected = next;
});
widget.onChanged(next);
}
}
InventoryWarehouseOption? _findOptionById(
List<InventoryWarehouseOption> options,
int? id,
) {
if (id == null) {
return null;
}
for (final option in options) {
if (option.id == id) {
return option;
}
}
return null;
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Stack(
alignment: Alignment.centerLeft,
children: const [
ShadInput(readOnly: true),
Padding(
padding: EdgeInsets.only(left: 12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
);
}
if (_error != null) {
return ShadInput(
readOnly: true,
enabled: false,
placeholder: Text(_error!),
);
}
if (_options.isEmpty) {
return ShadInput(
readOnly: true,
enabled: false,
placeholder: const Text('선택 가능한 창고가 없습니다'),
);
}
return ShadSelect<int?>(
enabled: widget.enabled,
initialValue: _selected?.id,
placeholder: widget.placeholder ?? const Text('창고 선택'),
selectedOptionBuilder: (context, value) {
final option = value == null
? _selected
: _options.firstWhere(
(item) => item.id == value,
orElse: () => _selected ?? _options.first,
);
if (option == null) {
return widget.placeholder ?? const Text('창고 선택');
}
return Text('${option.name} (${option.code})');
},
onChanged: (value) {
if (value == null) {
if (_selected != null) {
setState(() {
_selected = null;
});
widget.onChanged(null);
}
return;
}
final option = _options.firstWhere(
(item) => item.id == value,
orElse: () => _options.first,
);
setState(() {
_selected = option;
});
widget.onChanged(option);
},
options: [
if (widget.includeAllOption)
ShadOption<int?>(value: null, child: Text(widget.allLabel)),
for (final option in _options)
ShadOption<int?>(
value: option.id,
child: Text('${option.name} · ${option.code}'),
),
],
);
}
}

View File

@@ -0,0 +1,148 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/stock_transaction.dart';
import '../../domain/entities/stock_transaction_input.dart';
import '../../domain/repositories/stock_transaction_repository.dart';
import '../dtos/stock_transaction_dto.dart';
/// 재고 트랜잭션 API를 호출하는 원격 저장소 구현체.
class StockTransactionRepositoryRemote implements StockTransactionRepository {
StockTransactionRepositoryRemote({required ApiClient apiClient})
: _api = apiClient;
final ApiClient _api;
static const _basePath = '${ApiRoutes.apiV1}/stock-transactions';
@override
Future<PaginatedResult<StockTransaction>> list({
StockTransactionListFilter? filter,
}) async {
final effectiveFilter = filter ?? StockTransactionListFilter();
final response = await _api.get<Map<String, dynamic>>(
_basePath,
query: effectiveFilter.toQuery(),
options: Options(responseType: ResponseType.json),
);
return StockTransactionDto.parsePaginated(response.data ?? const {});
}
@override
Future<StockTransaction> fetchDetail(
int id, {
List<String> include = const ['lines', 'customers', 'approval'],
}) async {
final response = await _api.get<Map<String, dynamic>>(
'$_basePath/$id',
query: {if (include.isNotEmpty) 'include': include.join(',')},
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
}
@override
Future<StockTransaction> create(StockTransactionCreateInput input) async {
final response = await _api.post<Map<String, dynamic>>(
_basePath,
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
}
@override
Future<StockTransaction> update(
int id,
StockTransactionUpdateInput input,
) async {
final response = await _api.patch<Map<String, dynamic>>(
'$_basePath/$id',
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
}
@override
Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id');
}
@override
Future<StockTransaction> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/restore',
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
}
@override
Future<StockTransaction> submit(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/submit',
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
}
@override
Future<StockTransaction> complete(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/complete',
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
}
@override
Future<StockTransaction> approve(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/approve',
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
}
@override
Future<StockTransaction> reject(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/reject',
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
}
@override
Future<StockTransaction> cancel(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/cancel',
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
}
StockTransaction _parseSingle(Map<String, dynamic>? body) {
final data = _extractData(body);
return StockTransactionDto.fromJson(data).toEntity();
}
Map<String, dynamic> _extractData(Map<String, dynamic>? body) {
if (body == null) {
return <String, dynamic>{};
}
if (body['data'] is Map<String, dynamic>) {
return body['data'] as Map<String, dynamic>;
}
if (body.containsKey('transaction')) {
final transaction = body['transaction'];
if (transaction is Map<String, dynamic>) {
return transaction;
}
}
return body;
}
}

View File

@@ -0,0 +1,86 @@
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/stock_transaction.dart';
import '../../domain/entities/stock_transaction_input.dart';
import '../../domain/repositories/stock_transaction_repository.dart';
import '../dtos/stock_transaction_dto.dart';
/// 재고 트랜잭션 고객 연결 API를 호출하는 원격 저장소 구현체.
class TransactionCustomerRepositoryRemote
implements TransactionCustomerRepository {
TransactionCustomerRepositoryRemote({required ApiClient apiClient})
: _api = apiClient;
final ApiClient _api;
static const _basePath = '${ApiRoutes.apiV1}/stock-transactions';
static const _customerPath = '${ApiRoutes.apiV1}/transaction-customers';
@override
Future<List<StockTransactionCustomer>> addCustomers(
int transactionId,
List<TransactionCustomerCreateInput> customers,
) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$transactionId/customers',
data: {
'id': transactionId,
'customers': customers
.map((customer) => customer.toJson())
.toList(growable: false),
},
options: Options(responseType: ResponseType.json),
);
return _parseCustomers(response.data);
}
@override
Future<List<StockTransactionCustomer>> updateCustomers(
int transactionId,
List<TransactionCustomerUpdateInput> customers,
) async {
final response = await _api.patch<Map<String, dynamic>>(
'$_basePath/$transactionId/customers',
data: {
'id': transactionId,
'customers': customers
.map((customer) => customer.toJson())
.toList(growable: false),
},
options: Options(responseType: ResponseType.json),
);
return _parseCustomers(response.data);
}
@override
Future<void> deleteCustomer(int customerLinkId) async {
await _api.delete<void>('$_customerPath/$customerLinkId');
}
List<StockTransactionCustomer> _parseCustomers(Map<String, dynamic>? body) {
final data = _extractData(body);
if (data['customers'] is List) {
final dto = StockTransactionDto.fromJson(data);
return dto.customers;
}
if (data.containsKey('id')) {
final dto = StockTransactionDto.fromJson({
'customers': [data],
});
return dto.customers;
}
return const [];
}
Map<String, dynamic> _extractData(Map<String, dynamic>? body) {
if (body == null) {
return <String, dynamic>{};
}
if (body['data'] is Map<String, dynamic>) {
return body['data'] as Map<String, dynamic>;
}
return body;
}
}

View File

@@ -0,0 +1,94 @@
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/stock_transaction.dart';
import '../../domain/entities/stock_transaction_input.dart';
import '../../domain/repositories/stock_transaction_repository.dart';
import '../dtos/stock_transaction_dto.dart';
/// 재고 트랜잭션 라인 API를 호출하는 원격 저장소 구현체.
class TransactionLineRepositoryRemote implements TransactionLineRepository {
TransactionLineRepositoryRemote({required ApiClient apiClient})
: _api = apiClient;
final ApiClient _api;
static const _basePath = '${ApiRoutes.apiV1}/stock-transactions';
static const _linePath = '${ApiRoutes.apiV1}/transaction-lines';
@override
Future<List<StockTransactionLine>> addLines(
int transactionId,
List<TransactionLineCreateInput> lines,
) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$transactionId/lines',
data: {
'id': transactionId,
'lines': lines.map((line) => line.toJson()).toList(growable: false),
},
options: Options(responseType: ResponseType.json),
);
return _parseLines(response.data);
}
@override
Future<List<StockTransactionLine>> updateLines(
int transactionId,
List<TransactionLineUpdateInput> lines,
) async {
final response = await _api.patch<Map<String, dynamic>>(
'$_basePath/$transactionId/lines',
data: {
'id': transactionId,
'lines': lines.map((line) => line.toJson()).toList(growable: false),
},
options: Options(responseType: ResponseType.json),
);
return _parseLines(response.data);
}
@override
Future<void> deleteLine(int lineId) async {
await _api.delete<void>('$_linePath/$lineId');
}
@override
Future<StockTransactionLine> restoreLine(int lineId) async {
final response = await _api.post<Map<String, dynamic>>(
'$_linePath/$lineId/restore',
options: Options(responseType: ResponseType.json),
);
final lines = _parseLines(response.data);
if (lines.isEmpty) {
throw StateError('복구된 라인 정보를 찾을 수 없습니다.');
}
return lines.first;
}
List<StockTransactionLine> _parseLines(Map<String, dynamic>? body) {
final data = _extractData(body);
if (data['lines'] is List) {
final dto = StockTransactionDto.fromJson(data);
return dto.lines;
}
if (data.containsKey('id')) {
final dto = StockTransactionDto.fromJson({
'lines': [data],
});
return dto.lines;
}
return const [];
}
Map<String, dynamic> _extractData(Map<String, dynamic>? body) {
if (body == null) {
return <String, dynamic>{};
}
if (body['data'] is Map<String, dynamic>) {
return body['data'] as Map<String, dynamic>;
}
return body;
}
}

View File

@@ -0,0 +1,202 @@
/// 재고 트랜잭션 생성 입력 모델.
class StockTransactionCreateInput {
StockTransactionCreateInput({
this.transactionNo,
required this.transactionTypeId,
required this.transactionStatusId,
required this.warehouseId,
required this.transactionDate,
required this.createdById,
this.note,
this.expectedReturnDate,
this.lines = const [],
this.customers = const [],
});
final String? transactionNo;
final int transactionTypeId;
final int transactionStatusId;
final int warehouseId;
final DateTime transactionDate;
final int createdById;
final String? note;
final DateTime? expectedReturnDate;
final List<TransactionLineCreateInput> lines;
final List<TransactionCustomerCreateInput> customers;
Map<String, dynamic> toPayload() {
return {
if (transactionNo != null && transactionNo!.trim().isNotEmpty)
'transaction_no': transactionNo,
'transaction_type_id': transactionTypeId,
'transaction_status_id': transactionStatusId,
'warehouse_id': warehouseId,
'transaction_date': transactionDate.toIso8601String(),
'created_by_id': createdById,
if (note != null && note!.trim().isNotEmpty) 'note': note,
if (expectedReturnDate != null)
'expected_return_date': expectedReturnDate!.toIso8601String(),
if (lines.isNotEmpty)
'lines': lines.map((line) => line.toJson()).toList(growable: false),
if (customers.isNotEmpty)
'customers': customers
.map((customer) => customer.toJson())
.toList(growable: false),
};
}
}
/// 재고 트랜잭션 수정 입력 모델.
class StockTransactionUpdateInput {
StockTransactionUpdateInput({
required this.transactionStatusId,
this.note,
this.expectedReturnDate,
});
final int transactionStatusId;
final String? note;
final DateTime? expectedReturnDate;
Map<String, dynamic> toPayload() {
return {
'transaction_status_id': transactionStatusId,
if (note != null && note!.trim().isNotEmpty) 'note': note,
if (expectedReturnDate != null)
'expected_return_date': expectedReturnDate!.toIso8601String(),
};
}
}
/// 재고 트랜잭션 라인 추가 입력 모델.
class TransactionLineCreateInput {
TransactionLineCreateInput({
required this.lineNo,
required this.productId,
required this.quantity,
required this.unitPrice,
this.note,
});
final int lineNo;
final int productId;
final int quantity;
final double unitPrice;
final String? note;
Map<String, dynamic> toJson() {
return {
'line_no': lineNo,
'product_id': productId,
'quantity': quantity,
'unit_price': unitPrice,
if (note != null && note!.trim().isNotEmpty) 'note': note,
};
}
}
/// 재고 트랜잭션 라인 수정 입력 모델.
class TransactionLineUpdateInput {
TransactionLineUpdateInput({
required this.id,
this.lineNo,
this.quantity,
this.unitPrice,
this.note,
});
final int id;
final int? lineNo;
final int? quantity;
final double? unitPrice;
final String? note;
Map<String, dynamic> toJson() {
return {
'id': id,
if (lineNo != null) 'line_no': lineNo,
if (quantity != null) 'quantity': quantity,
if (unitPrice != null) 'unit_price': unitPrice,
if (note != null && note!.trim().isNotEmpty) 'note': note,
};
}
}
/// 재고 트랜잭션 고객 연결 추가 입력 모델.
class TransactionCustomerCreateInput {
TransactionCustomerCreateInput({required this.customerId, this.note});
final int customerId;
final String? note;
Map<String, dynamic> toJson() {
return {
'customer_id': customerId,
if (note != null && note!.trim().isNotEmpty) 'note': note,
};
}
}
/// 재고 트랜잭션 고객 연결 수정 입력 모델.
class TransactionCustomerUpdateInput {
TransactionCustomerUpdateInput({required this.id, this.note});
final int id;
final String? note;
Map<String, dynamic> toJson() {
return {
'id': id,
if (note != null && note!.trim().isNotEmpty) 'note': note,
};
}
}
/// 재고 트랜잭션 목록 조회 필터 모델.
class StockTransactionListFilter {
StockTransactionListFilter({
this.page = 1,
this.pageSize = 20,
this.query,
this.transactionTypeId,
this.transactionStatusId,
this.warehouseId,
this.customerId,
this.from,
this.to,
this.sort,
this.order,
this.include = const ['lines', 'customers', 'approval'],
});
final int page;
final int pageSize;
final String? query;
final int? transactionTypeId;
final int? transactionStatusId;
final int? warehouseId;
final int? customerId;
final DateTime? from;
final DateTime? to;
final String? sort;
final String? order;
final List<String> include;
Map<String, dynamic> toQuery() {
return {
'page': page,
'page_size': pageSize,
if (query != null && query!.trim().isNotEmpty) 'q': query,
if (transactionTypeId != null) 'transaction_type_id': transactionTypeId,
if (transactionStatusId != null)
'transaction_status_id': transactionStatusId,
if (warehouseId != null) 'warehouse_id': warehouseId,
if (customerId != null) 'customer_id': customerId,
if (from != null) 'from': from!.toIso8601String(),
if (to != null) 'to': to!.toIso8601String(),
if (sort != null && sort!.trim().isNotEmpty) 'sort': sort,
if (order != null && order!.trim().isNotEmpty) 'order': order,
if (include.isNotEmpty) 'include': include.join(','),
};
}
}

View File

@@ -0,0 +1,84 @@
import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/stock_transaction.dart';
import '../entities/stock_transaction_input.dart';
/// 재고 트랜잭션 저장소 인터페이스.
abstract class StockTransactionRepository {
/// 재고 트랜잭션 목록을 조회한다.
Future<PaginatedResult<StockTransaction>> list({
StockTransactionListFilter? filter,
});
/// 재고 트랜잭션 단건을 조회한다.
Future<StockTransaction> fetchDetail(
int id, {
List<String> include = const ['lines', 'customers', 'approval'],
});
/// 재고 트랜잭션을 생성한다.
Future<StockTransaction> create(StockTransactionCreateInput input);
/// 재고 트랜잭션을 수정한다.
Future<StockTransaction> update(int id, StockTransactionUpdateInput input);
/// 재고 트랜잭션을 삭제한다.
Future<void> delete(int id);
/// 삭제된 재고 트랜잭션을 복구한다.
Future<StockTransaction> restore(int id);
/// 재고 트랜잭션을 상신(submit)한다.
Future<StockTransaction> submit(int id);
/// 재고 트랜잭션을 완료 처리한다.
Future<StockTransaction> complete(int id);
/// 재고 트랜잭션을 승인 처리한다.
Future<StockTransaction> approve(int id);
/// 재고 트랜잭션을 반려 처리한다.
Future<StockTransaction> reject(int id);
/// 재고 트랜잭션을 취소 처리한다.
Future<StockTransaction> cancel(int id);
}
/// 재고 트랜잭션 라인 저장소 인터페이스.
abstract class TransactionLineRepository {
/// 라인을 추가한다.
Future<List<StockTransactionLine>> addLines(
int transactionId,
List<TransactionLineCreateInput> lines,
);
/// 라인 정보를 일괄 수정한다.
Future<List<StockTransactionLine>> updateLines(
int transactionId,
List<TransactionLineUpdateInput> lines,
);
/// 단일 라인을 삭제한다.
Future<void> deleteLine(int lineId);
/// 삭제된 라인을 복구한다.
Future<StockTransactionLine> restoreLine(int lineId);
}
/// 재고 트랜잭션 고객 연결 저장소 인터페이스.
abstract class TransactionCustomerRepository {
/// 고객 연결을 추가한다.
Future<List<StockTransactionCustomer>> addCustomers(
int transactionId,
List<TransactionCustomerCreateInput> customers,
);
/// 고객 연결 정보를 수정한다.
Future<List<StockTransactionCustomer>> updateCustomers(
int transactionId,
List<TransactionCustomerUpdateInput> customers,
);
/// 고객 연결을 삭제한다.
Future<void> deleteCustomer(int customerLinkId);
}

View File

@@ -0,0 +1,295 @@
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
/// 라인 편집 폼에서 수집한 입력 값을 정규화한 모델.
class TransactionLineDraft {
TransactionLineDraft({
this.id,
required this.lineNo,
required this.productId,
required this.quantity,
required this.unitPrice,
this.note,
});
/// 기존 라인의 식별자. 신규 라인은 `null`이다.
final int? id;
/// 서버에 전달할 라인 순번.
final int lineNo;
/// 선택된 제품의 식별자.
final int productId;
/// 수량.
final int quantity;
/// 단가.
final double unitPrice;
/// 비고. 공백일 경우 `null`로 정규화한다.
final String? note;
}
/// 라인 변경 사항을 서버 호출 단위로 정리한 결과.
class TransactionLineSyncPlan {
TransactionLineSyncPlan({
this.createdLines = const [],
this.updatedLines = const [],
this.deletedLineIds = const [],
});
/// 새로 추가해야 할 라인 목록.
final List<TransactionLineCreateInput> createdLines;
/// 수정이 필요한 기존 라인 목록.
final List<TransactionLineUpdateInput> updatedLines;
/// 삭제해야 할 라인 ID 목록.
final List<int> deletedLineIds;
/// 변경 사항이 있는지 여부.
bool get hasChanges =>
createdLines.isNotEmpty ||
updatedLines.isNotEmpty ||
deletedLineIds.isNotEmpty;
}
/// 고객 연결 입력 값을 정규화한 모델.
class TransactionCustomerDraft {
TransactionCustomerDraft({this.id, required this.customerId, this.note});
/// 기존 고객 연결 ID. 신규 연결은 `null`이다.
final int? id;
/// 선택된 고객 ID.
final int customerId;
/// 비고. 공백일 경우 `null`.
final String? note;
}
/// 고객 연결 변경 사항을 서버 호출 단위로 정리한 결과.
class TransactionCustomerSyncPlan {
TransactionCustomerSyncPlan({
this.createdCustomers = const [],
this.updatedCustomers = const [],
this.deletedCustomerIds = const [],
});
/// 새로 추가할 고객 연결 입력.
final List<TransactionCustomerCreateInput> createdCustomers;
/// 비고 등 속성이 변경된 고객 연결 입력.
final List<TransactionCustomerUpdateInput> updatedCustomers;
/// 삭제 대상 고객 연결 ID.
final List<int> deletedCustomerIds;
/// 변경 사항이 있는지 여부.
bool get hasChanges =>
createdCustomers.isNotEmpty ||
updatedCustomers.isNotEmpty ||
deletedCustomerIds.isNotEmpty;
}
/// 재고 트랜잭션 라인·고객 입력을 기존 데이터와 비교해 동기화 계획을 산출하는 유틸리티.
class TransactionDetailSyncService {
const TransactionDetailSyncService();
/// 현재 서버 상태와 폼 입력을 비교해 라인 변경 계획을 생성한다.
TransactionLineSyncPlan buildLinePlan({
required List<TransactionLineDraft> drafts,
required List<StockTransactionLine> currentLines,
}) {
final createdLines = <TransactionLineCreateInput>[];
final updatedLines = <TransactionLineUpdateInput>[];
final deletedLineIds = <int>{};
final currentById = <int, StockTransactionLine>{};
for (final line in currentLines) {
final id = line.id;
if (id != null) {
currentById[id] = line;
}
}
final remainingIds = currentById.keys.toSet();
for (final draft in drafts) {
final draftId = draft.id;
final normalizedNote = _normalizeOptionalText(draft.note);
if (draftId == null) {
createdLines.add(
TransactionLineCreateInput(
lineNo: draft.lineNo,
productId: draft.productId,
quantity: draft.quantity,
unitPrice: draft.unitPrice,
note: normalizedNote,
),
);
continue;
}
final current = currentById[draftId];
if (current == null) {
createdLines.add(
TransactionLineCreateInput(
lineNo: draft.lineNo,
productId: draft.productId,
quantity: draft.quantity,
unitPrice: draft.unitPrice,
note: normalizedNote,
),
);
continue;
}
remainingIds.remove(draftId);
if (current.product.id != draft.productId) {
deletedLineIds.add(draftId);
createdLines.add(
TransactionLineCreateInput(
lineNo: draft.lineNo,
productId: draft.productId,
quantity: draft.quantity,
unitPrice: draft.unitPrice,
note: normalizedNote,
),
);
continue;
}
int? nextLineNo;
if (current.lineNo != draft.lineNo) {
nextLineNo = draft.lineNo;
}
int? nextQuantity;
if (current.quantity != draft.quantity) {
nextQuantity = draft.quantity;
}
double? nextUnitPrice;
if ((current.unitPrice - draft.unitPrice).abs() > 0.0001) {
nextUnitPrice = draft.unitPrice;
}
final currentNote = _normalizeOptionalText(current.note);
String? nextNote;
if (currentNote != normalizedNote) {
nextNote = normalizedNote;
}
if (nextLineNo != null ||
nextQuantity != null ||
nextUnitPrice != null ||
nextNote != null) {
updatedLines.add(
TransactionLineUpdateInput(
id: draftId,
lineNo: nextLineNo,
quantity: nextQuantity,
unitPrice: nextUnitPrice,
note: nextNote,
),
);
}
}
for (final id in remainingIds) {
deletedLineIds.add(id);
}
return TransactionLineSyncPlan(
createdLines: createdLines,
updatedLines: updatedLines,
deletedLineIds: deletedLineIds.toList(),
);
}
/// 현재 서버 상태와 폼 입력을 비교해 고객 연결 변경 계획을 생성한다.
TransactionCustomerSyncPlan buildCustomerPlan({
required List<TransactionCustomerDraft> drafts,
required List<StockTransactionCustomer> currentCustomers,
}) {
final createdCustomers = <TransactionCustomerCreateInput>[];
final updatedCustomers = <TransactionCustomerUpdateInput>[];
final deletedCustomerIds = <int>{};
final currentById = <int, StockTransactionCustomer>{};
final currentByCustomerId = <int, StockTransactionCustomer>{};
for (final customer in currentCustomers) {
final id = customer.id;
if (id != null) {
currentById[id] = customer;
}
currentByCustomerId[customer.customer.id] = customer;
}
final remainingIds = currentById.keys.toSet();
for (final draft in drafts) {
final draftId = draft.id;
final normalizedNote = _normalizeOptionalText(draft.note);
if (draftId != null) {
final currentByLink = currentById[draftId];
if (currentByLink != null) {
remainingIds.remove(draftId);
final currentNote = _normalizeOptionalText(currentByLink.note);
if (currentNote != normalizedNote) {
updatedCustomers.add(
TransactionCustomerUpdateInput(id: draftId, note: normalizedNote),
);
}
continue;
}
}
final fallback = currentByCustomerId[draft.customerId];
if (fallback != null && fallback.id != null) {
final fallbackId = fallback.id!;
remainingIds.remove(fallbackId);
final currentNote = _normalizeOptionalText(fallback.note);
if (currentNote != normalizedNote) {
updatedCustomers.add(
TransactionCustomerUpdateInput(
id: fallbackId,
note: normalizedNote,
),
);
}
continue;
}
createdCustomers.add(
TransactionCustomerCreateInput(
customerId: draft.customerId,
note: normalizedNote,
),
);
}
for (final id in remainingIds) {
deletedCustomerIds.add(id);
}
return TransactionCustomerSyncPlan(
createdCustomers: createdCustomers,
updatedCustomers: updatedCustomers,
deletedCustomerIds: deletedCustomerIds.toList(),
);
}
}
String? _normalizeOptionalText(String? value) {
if (value == null) {
return null;
}
final trimmed = value.trim();
if (trimmed.isEmpty) {
return null;
}
return trimmed;
}

View File

@@ -0,0 +1,85 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/features/inventory/lookups/data/repositories/inventory_lookup_repository_remote.dart';
class _MockApiClient extends Mock implements ApiClient {}
void main() {
late ApiClient apiClient;
late InventoryLookupRepositoryRemote repository;
setUpAll(() {
registerFallbackValue(Options());
registerFallbackValue(CancelToken());
});
setUp(() {
apiClient = _MockApiClient();
repository = InventoryLookupRepositoryRemote(apiClient: apiClient);
});
Future<void> stubGet(String path) async {
when(
() => apiClient.get<Map<String, dynamic>>(
path,
query: any(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: {
'items': [
{'id': 1, 'name': '샘플'},
],
},
requestOptions: RequestOptions(path: path),
statusCode: 200,
),
);
}
test('fetchTransactionStatuses는 /transaction-statuses를 호출한다', () async {
const path = '/api/v1/transaction-statuses';
await stubGet(path);
final items = await repository.fetchTransactionStatuses();
expect(items, isNotEmpty);
final captured = verify(
() => apiClient.get<Map<String, dynamic>>(
captureAny(),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured;
expect(captured[0], equals(path));
final query = captured[1] as Map<String, dynamic>;
expect(query['page'], 1);
expect(query['page_size'], 200);
expect(query['is_active'], true);
});
test('fetchApprovalActions는 is_active 파라미터 없이 호출한다', () async {
const path = '/api/v1/approval-actions';
await stubGet(path);
await repository.fetchApprovalActions();
final query =
verify(
() => apiClient.get<Map<String, dynamic>>(
captureAny(),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured[1]
as Map<String, dynamic>;
expect(query.containsKey('is_active'), isFalse);
});
}

View File

@@ -0,0 +1,178 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/network/api_error.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/features/inventory/outbound/presentation/controllers/outbound_controller.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
class _MockStockTransactionRepository extends Mock
implements StockTransactionRepository {}
class _MockInventoryLookupRepository extends Mock
implements InventoryLookupRepository {}
class _MockTransactionLineRepository extends Mock
implements TransactionLineRepository {}
class _MockTransactionCustomerRepository extends Mock
implements TransactionCustomerRepository {}
class _FakeStockTransactionCreateInput extends Fake
implements StockTransactionCreateInput {}
class _FakeStockTransactionUpdateInput extends Fake
implements StockTransactionUpdateInput {}
class _FakeStockTransactionListFilter extends Fake
implements StockTransactionListFilter {}
void main() {
group('OutboundController', () {
late StockTransactionRepository transactionRepository;
late InventoryLookupRepository lookupRepository;
late TransactionLineRepository lineRepository;
late TransactionCustomerRepository customerRepository;
late OutboundController controller;
setUpAll(() {
registerFallbackValue(_FakeStockTransactionCreateInput());
registerFallbackValue(_FakeStockTransactionUpdateInput());
registerFallbackValue(_FakeStockTransactionListFilter());
});
setUp(() {
transactionRepository = _MockStockTransactionRepository();
lookupRepository = _MockInventoryLookupRepository();
lineRepository = _MockTransactionLineRepository();
customerRepository = _MockTransactionCustomerRepository();
controller = OutboundController(
transactionRepository: transactionRepository,
lineRepository: lineRepository,
customerRepository: customerRepository,
lookupRepository: lookupRepository,
);
});
test('createTransaction은 레코드를 추가한다', () async {
final transaction = _buildTransaction();
when(
() => transactionRepository.create(any()),
).thenAnswer((_) async => transaction);
final record = await controller.createTransaction(
StockTransactionCreateInput(
transactionTypeId: 1,
transactionStatusId: 2,
warehouseId: 3,
transactionDate: DateTime(2024, 4, 1),
createdById: 7,
),
refreshAfter: false,
);
expect(record.id, equals(transaction.id));
expect(controller.records.first.id, equals(transaction.id));
});
test('completeTransaction은 레코드를 갱신하고 처리 상태를 추적한다', () async {
final original = _buildTransaction();
final completed = _buildTransaction(statusName: '출고완료');
when(
() => transactionRepository.create(any()),
).thenAnswer((_) async => original);
when(
() => transactionRepository.complete(any()),
).thenAnswer((_) async => completed);
await controller.createTransaction(
StockTransactionCreateInput(
transactionTypeId: 1,
transactionStatusId: 2,
warehouseId: 3,
transactionDate: DateTime(2024, 4, 1),
createdById: 7,
),
refreshAfter: false,
);
final future = controller.completeTransaction(
original.id!,
refreshAfter: false,
);
expect(controller.processingTransactionIds.contains(original.id), isTrue);
final record = await future;
expect(record.status, equals('출고완료'));
expect(controller.records.first.status, equals('출고완료'));
expect(
controller.processingTransactionIds.contains(original.id),
isFalse,
);
});
test('fetchTransactions 실패 시 Failure 메시지를 기록한다', () async {
final exception = ApiException(
code: ApiErrorCode.badRequest,
message: '출고 데이터를 가져오지 못했습니다.',
details: {
'errors': {
'warehouse_id': ['창고를 선택하세요.'],
},
},
);
when(
() => transactionRepository.list(filter: any(named: 'filter')),
).thenThrow(exception);
await controller.fetchTransactions(
filter: StockTransactionListFilter(transactionTypeId: 1),
);
final failure = Failure.from(exception);
expect(controller.errorMessage, equals(failure.describe()));
expect(controller.records, isEmpty);
});
});
}
StockTransaction _buildTransaction({int id = 200, String statusName = '출고대기'}) {
return StockTransaction(
id: id,
transactionNo: 'OUT-$id',
transactionDate: DateTime(2024, 4, 1),
type: StockTransactionType(id: 20, name: '출고'),
status: StockTransactionStatus(id: 21, name: statusName),
warehouse: StockTransactionWarehouse(id: 2, code: 'WH-2', name: '부산 센터'),
createdBy: StockTransactionEmployee(
id: 2,
employeeNo: 'EMP-2',
name: '출고 담당',
),
lines: [
StockTransactionLine(
id: 10,
lineNo: 1,
product: StockTransactionProduct(id: 10, code: 'P-10', name: '출고 품목'),
quantity: 3,
unitPrice: 2500.0,
),
],
customers: [
StockTransactionCustomer(
id: 1,
customer: StockTransactionCustomerSummary(
id: 100,
code: 'C-1',
name: '슈퍼포트 고객',
),
),
],
);
}

View File

@@ -0,0 +1,254 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_error.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/features/inventory/rental/presentation/controllers/rental_controller.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
class _MockStockTransactionRepository extends Mock
implements StockTransactionRepository {}
class _MockInventoryLookupRepository extends Mock
implements InventoryLookupRepository {}
class _MockTransactionLineRepository extends Mock
implements TransactionLineRepository {}
class _MockTransactionCustomerRepository extends Mock
implements TransactionCustomerRepository {}
class _FakeStockTransactionCreateInput extends Fake
implements StockTransactionCreateInput {}
class _FakeStockTransactionUpdateInput extends Fake
implements StockTransactionUpdateInput {}
class _FakeStockTransactionListFilter extends Fake
implements StockTransactionListFilter {}
void main() {
group('RentalController', () {
late StockTransactionRepository transactionRepository;
late InventoryLookupRepository lookupRepository;
late TransactionLineRepository lineRepository;
late TransactionCustomerRepository customerRepository;
late RentalController controller;
setUpAll(() {
registerFallbackValue(_FakeStockTransactionCreateInput());
registerFallbackValue(_FakeStockTransactionUpdateInput());
registerFallbackValue(_FakeStockTransactionListFilter());
});
setUp(() {
transactionRepository = _MockStockTransactionRepository();
lookupRepository = _MockInventoryLookupRepository();
lineRepository = _MockTransactionLineRepository();
customerRepository = _MockTransactionCustomerRepository();
controller = RentalController(
transactionRepository: transactionRepository,
lineRepository: lineRepository,
customerRepository: customerRepository,
lookupRepository: lookupRepository,
);
});
test('createTransaction은 레코드를 추가한다', () async {
final transaction = _buildRentalTransaction();
when(
() => transactionRepository.create(any()),
).thenAnswer((_) async => transaction);
final record = await controller.createTransaction(
StockTransactionCreateInput(
transactionTypeId: 1,
transactionStatusId: 2,
warehouseId: 4,
transactionDate: DateTime(2024, 5, 1),
createdById: 5,
),
refreshAfter: false,
);
expect(record.id, equals(transaction.id));
expect(controller.records.first.id, equals(transaction.id));
});
test('deleteTransaction은 레코드를 제거하고 처리 상태를 초기화한다', () async {
final transaction = _buildRentalTransaction();
when(
() => transactionRepository.create(any()),
).thenAnswer((_) async => transaction);
when(
() => transactionRepository.delete(any()),
).thenAnswer((_) async => Future<void>.value());
await controller.createTransaction(
StockTransactionCreateInput(
transactionTypeId: 1,
transactionStatusId: 2,
warehouseId: 4,
transactionDate: DateTime(2024, 5, 1),
createdById: 5,
),
refreshAfter: false,
);
final future = controller.deleteTransaction(
transaction.id!,
refreshAfter: false,
);
expect(
controller.processingTransactionIds.contains(transaction.id),
isTrue,
);
await future;
expect(controller.records, isEmpty);
expect(
controller.processingTransactionIds.contains(transaction.id),
isFalse,
);
});
test('completeTransaction은 마지막 필터 설정을 유지하며 새로고침한다', () async {
final rental = _buildRentalTransaction();
final other = _buildNonRentalTransaction();
when(
() => transactionRepository.list(filter: any(named: 'filter')),
).thenAnswer(
(_) async => PaginatedResult<StockTransaction>(
items: [rental, other],
page: 1,
pageSize: 20,
total: 2,
),
);
await controller.fetchTransactions(
filter: StockTransactionListFilter(transactionTypeId: 1),
filterByRentalTypes: false,
);
final updated = rental.copyWith(
status: StockTransactionStatus(id: 2, name: '완료'),
);
when(
() => transactionRepository.complete(any()),
).thenAnswer((_) async => updated);
when(
() => transactionRepository.list(filter: any(named: 'filter')),
).thenAnswer(
(_) async => PaginatedResult<StockTransaction>(
items: [updated, other],
page: 1,
pageSize: 20,
total: 2,
),
);
final result = await controller.completeTransaction(rental.id!);
expect(result.status, equals('완료'));
expect(controller.records.length, equals(2));
});
test('fetchTransactions 실패 시 Failure 메시지를 저장한다', () async {
final exception = ApiException(
code: ApiErrorCode.conflict,
message: '대여 목록을 가져오는 데 실패했습니다.',
details: {
'errors': {
'status': ['상태 필터가 올바르지 않습니다.'],
},
},
);
when(
() => transactionRepository.list(filter: any(named: 'filter')),
).thenThrow(exception);
await controller.fetchTransactions(
filter: StockTransactionListFilter(transactionTypeId: 1),
);
final failure = Failure.from(exception);
expect(controller.errorMessage, equals(failure.describe()));
expect(controller.records, isEmpty);
});
});
}
StockTransaction _buildRentalTransaction({
int id = 300,
String statusName = '대여중',
}) {
return StockTransaction(
id: id,
transactionNo: 'RENT-$id',
transactionDate: DateTime(2024, 5, 1),
type: StockTransactionType(id: 30, name: '대여'),
status: StockTransactionStatus(id: 31, name: statusName),
warehouse: StockTransactionWarehouse(id: 5, code: 'WH-5', name: '대여 창고'),
createdBy: StockTransactionEmployee(
id: 5,
employeeNo: 'EMP-5',
name: '대여 담당',
),
note: '렌탈',
lines: [
StockTransactionLine(
id: 20,
lineNo: 1,
product: StockTransactionProduct(id: 20, code: 'P-20', name: '렌탈 품목'),
quantity: 2,
unitPrice: 15000.0,
),
],
customers: [
StockTransactionCustomer(
id: 2,
customer: StockTransactionCustomerSummary(
id: 200,
code: 'C-200',
name: '렌탈 고객',
),
),
],
expectedReturnDate: DateTime(2024, 5, 20),
);
}
StockTransaction _buildNonRentalTransaction() {
return StockTransaction(
id: 301,
transactionNo: 'SRV-301',
transactionDate: DateTime(2024, 5, 2),
type: StockTransactionType(id: 40, name: '수리'),
status: StockTransactionStatus(id: 32, name: '진행중'),
warehouse: StockTransactionWarehouse(id: 6, code: 'WH-6', name: '서비스 센터'),
createdBy: StockTransactionEmployee(
id: 6,
employeeNo: 'EMP-6',
name: '서비스 담당',
),
lines: [
StockTransactionLine(
id: 21,
lineNo: 1,
product: StockTransactionProduct(id: 21, code: 'P-21', name: '서비스 품목'),
quantity: 1,
unitPrice: 5000.0,
),
],
customers: const [],
);
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/network/api_error.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/features/inventory/shared/widgets/warehouse_select_field.dart';
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
class _MockWarehouseRepository extends Mock implements WarehouseRepository {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final getIt = GetIt.instance;
setUp(() async {
await getIt.reset();
});
tearDown(() async {
await getIt.reset();
});
testWidgets('창고 로드 실패 시 Failure 메시지를 표시한다', (tester) async {
final repository = _MockWarehouseRepository();
getIt.registerSingleton<WarehouseRepository>(repository);
final exception = ApiException(
code: ApiErrorCode.unknown,
message: '창고 목록을 불러오지 못했습니다.',
details: {
'errors': {
'warehouse': ['창고 데이터를 조회할 수 없습니다.'],
},
},
);
when(
() => repository.list(
page: 1,
pageSize: 100,
isActive: true,
includeZipcode: false,
),
).thenThrow(exception);
await tester.pumpWidget(
MaterialApp(
home: ShadTheme(
data: ShadThemeData(
colorScheme: const ShadSlateColorScheme.light(),
brightness: Brightness.light,
),
child: Scaffold(
body: Center(
child: InventoryWarehouseSelectField(onChanged: (_) {}),
),
),
),
),
);
await tester.pumpAndSettle();
final failure = Failure.from(exception);
expect(find.text(failure.describe()), findsOneWidget);
});
}

View File

@@ -0,0 +1,334 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
class _MockApiClient extends Mock implements ApiClient {}
void main() {
late ApiClient apiClient;
late StockTransactionRepositoryRemote repository;
setUpAll(() {
registerFallbackValue(Options());
registerFallbackValue(CancelToken());
});
setUp(() {
apiClient = _MockApiClient();
repository = StockTransactionRepositoryRemote(apiClient: apiClient);
});
Future<Response<Map<String, dynamic>>> emptyListResponse(String path) async {
return Response<Map<String, dynamic>>(
data: {'items': const [], 'page': 1, 'page_size': 20, 'total': 0},
requestOptions: RequestOptions(path: path),
statusCode: 200,
);
}
Map<String, dynamic> detailBody() {
return {
'data': {
'id': 10,
'transaction_no': 'TX-1',
'transaction_type': {'id': 1, 'type_name': '입고'},
'transaction_status': {'id': 1, 'status_name': '작성중'},
'warehouse': {'id': 3, 'warehouse_code': 'W-1', 'warehouse_name': '서울'},
'created_by': {'id': 5, 'employee_no': 'EMP-1', 'employee_name': '홍길동'},
'lines': const [],
'customers': const [],
},
};
}
test('list 호출 시 필터 파라미터를 전달한다', () async {
const path = '/api/v1/stock-transactions';
when(
() => apiClient.get<Map<String, dynamic>>(
path,
query: any(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer((_) => emptyListResponse(path));
final filter = StockTransactionListFilter(
page: 2,
pageSize: 50,
query: '품번',
transactionTypeId: 11,
transactionStatusId: 7,
warehouseId: 3,
customerId: 99,
from: DateTime(2024, 1, 1),
to: DateTime(2024, 1, 31),
sort: 'transaction_date',
order: 'desc',
include: const ['lines', 'approval'],
);
await repository.list(filter: filter);
final captured = verify(
() => apiClient.get<Map<String, dynamic>>(
captureAny(),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured;
final query = captured[1] as Map<String, dynamic>;
expect(captured.first, equals(path));
expect(query['page'], 2);
expect(query['page_size'], 50);
expect(query['q'], '품번');
expect(query['transaction_type_id'], 11);
expect(query['transaction_status_id'], 7);
expect(query['warehouse_id'], 3);
expect(query['customer_id'], 99);
expect(query['from'], '2024-01-01T00:00:00.000');
expect(query['to'], '2024-01-31T00:00:00.000');
expect(query['sort'], 'transaction_date');
expect(query['order'], 'desc');
expect(query['include'], 'lines,approval');
});
test('fetchDetail은 include 파라미터를 조인해 전달한다', () async {
const path = '/api/v1/stock-transactions/10';
when(
() => apiClient.get<Map<String, dynamic>>(
path,
query: any(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: detailBody(),
requestOptions: RequestOptions(path: path),
statusCode: 200,
),
);
await repository.fetchDetail(10, include: const ['lines', 'customers']);
final query =
verify(
() => apiClient.get<Map<String, dynamic>>(
captureAny(),
query: captureAny(named: 'query'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured[1]
as Map<String, dynamic>;
expect(query['include'], 'lines,customers');
});
test('create는 입력 payload를 body로 전달한다', () async {
const path = '/api/v1/stock-transactions';
when(
() => apiClient.post<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: detailBody(),
requestOptions: RequestOptions(path: path),
statusCode: 201,
),
);
final input = StockTransactionCreateInput(
transactionTypeId: 1,
transactionStatusId: 2,
warehouseId: 3,
transactionDate: DateTime(2024, 2, 1),
createdById: 9,
note: '테스트',
lines: [
TransactionLineCreateInput(
lineNo: 1,
productId: 11,
quantity: 2,
unitPrice: 1000,
),
],
customers: [TransactionCustomerCreateInput(customerId: 7)],
);
await repository.create(input);
final payload =
verify(
() => apiClient.post<Map<String, dynamic>>(
captureAny(),
data: captureAny(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured[1]
as Map<String, dynamic>;
expect(payload['transaction_type_id'], 1);
expect(payload['transaction_status_id'], 2);
expect(payload['warehouse_id'], 3);
expect(payload['created_by_id'], 9);
expect(payload['lines'], isA<List>());
expect(payload['customers'], isA<List>());
});
test('submit은 /submit 엔드포인트를 호출한다', () async {
const path = '/api/v1/stock-transactions/10/submit';
when(
() => apiClient.post<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: detailBody(),
requestOptions: RequestOptions(path: path),
statusCode: 200,
),
);
await repository.submit(10);
verify(
() => apiClient.post<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).called(1);
});
test('complete는 /complete 엔드포인트를 호출한다', () async {
const path = '/api/v1/stock-transactions/10/complete';
when(
() => apiClient.post<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: detailBody(),
requestOptions: RequestOptions(path: path),
statusCode: 200,
),
);
await repository.complete(10);
verify(
() => apiClient.post<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).called(1);
});
test('approve는 /approve 엔드포인트를 호출한다', () async {
const path = '/api/v1/stock-transactions/11/approve';
when(
() => apiClient.post<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: detailBody(),
requestOptions: RequestOptions(path: path),
statusCode: 200,
),
);
await repository.approve(11);
verify(
() => apiClient.post<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).called(1);
});
test('reject는 /reject 엔드포인트를 호출한다', () async {
const path = '/api/v1/stock-transactions/12/reject';
when(
() => apiClient.post<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: detailBody(),
requestOptions: RequestOptions(path: path),
statusCode: 200,
),
);
await repository.reject(12);
verify(
() => apiClient.post<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).called(1);
});
test('cancel은 /cancel 엔드포인트를 호출한다', () async {
const path = '/api/v1/stock-transactions/13/cancel';
when(
() => apiClient.post<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: detailBody(),
requestOptions: RequestOptions(path: path),
statusCode: 200,
),
);
await repository.cancel(13);
verify(
() => apiClient.post<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).called(1);
});
}

View File

@@ -0,0 +1,137 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/features/inventory/transactions/data/repositories/transaction_customer_repository_remote.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
class _MockApiClient extends Mock implements ApiClient {}
void main() {
late ApiClient apiClient;
late TransactionCustomerRepositoryRemote repository;
setUpAll(() {
registerFallbackValue(Options());
registerFallbackValue(CancelToken());
});
setUp(() {
apiClient = _MockApiClient();
repository = TransactionCustomerRepositoryRemote(apiClient: apiClient);
});
Map<String, dynamic> customerResponse() {
return {
'data': {
'customers': [
{
'id': 301,
'customer': {
'id': 700,
'customer_code': 'C-1',
'customer_name': '슈퍼포트',
},
'note': '테스트',
},
],
},
};
}
test('addCustomers는 거래 ID를 포함해 POST 요청을 보낸다', () async {
const path = '/api/v1/stock-transactions/77/customers';
when(
() => apiClient.post<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: customerResponse(),
requestOptions: RequestOptions(path: path),
statusCode: 200,
),
);
await repository.addCustomers(77, [
TransactionCustomerCreateInput(customerId: 700, note: '비고'),
]);
final payload =
verify(
() => apiClient.post<Map<String, dynamic>>(
captureAny(),
data: captureAny(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured[1]
as Map<String, dynamic>;
expect(payload['id'], 77);
expect(payload['customers'], isA<List>());
});
test('updateCustomers는 PATCH 요청을 보낸다', () async {
const path = '/api/v1/stock-transactions/77/customers';
when(
() => apiClient.patch<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: customerResponse(),
requestOptions: RequestOptions(path: path),
statusCode: 200,
),
);
await repository.updateCustomers(77, [
TransactionCustomerUpdateInput(id: 301, note: '수정'),
]);
verify(
() => apiClient.patch<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).called(1);
});
test('deleteCustomer는 /transaction-customers/{id} 엔드포인트를 호출한다', () async {
const path = '/api/v1/transaction-customers/301';
when(
() => apiClient.delete<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<void>(
requestOptions: RequestOptions(path: path),
statusCode: 204,
),
);
await repository.deleteCustomer(301);
verify(
() => apiClient.delete<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).called(1);
});
}

View File

@@ -0,0 +1,171 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
class _MockApiClient extends Mock implements ApiClient {}
void main() {
late ApiClient apiClient;
late TransactionLineRepositoryRemote repository;
setUpAll(() {
registerFallbackValue(Options());
registerFallbackValue(CancelToken());
});
setUp(() {
apiClient = _MockApiClient();
repository = TransactionLineRepositoryRemote(apiClient: apiClient);
});
Map<String, dynamic> lineResponse() {
return {
'data': {
'lines': [
{
'id': 101,
'line_no': 1,
'product': {'id': 11, 'product_code': 'P-1', 'product_name': '품목'},
'quantity': 3,
'unit_price': 1000,
},
],
},
};
}
test('addLines는 거래 ID를 포함한 POST 요청을 보낸다', () async {
const path = '/api/v1/stock-transactions/50/lines';
when(
() => apiClient.post<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: lineResponse(),
requestOptions: RequestOptions(path: path),
statusCode: 200,
),
);
await repository.addLines(50, [
TransactionLineCreateInput(
lineNo: 2,
productId: 44,
quantity: 5,
unitPrice: 1500,
),
]);
final payload =
verify(
() => apiClient.post<Map<String, dynamic>>(
captureAny(),
data: captureAny(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).captured[1]
as Map<String, dynamic>;
expect(payload['id'], 50);
expect(payload['lines'], isA<List>());
});
test('updateLines는 PATCH 요청을 사용한다', () async {
const path = '/api/v1/stock-transactions/50/lines';
when(
() => apiClient.patch<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: lineResponse(),
requestOptions: RequestOptions(path: path),
statusCode: 200,
),
);
await repository.updateLines(50, [
TransactionLineUpdateInput(id: 101, quantity: 10),
]);
verify(
() => apiClient.patch<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).called(1);
});
test('deleteLine은 /transaction-lines/{id}를 호출한다', () async {
const path = '/api/v1/transaction-lines/101';
when(
() => apiClient.delete<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<void>(
requestOptions: RequestOptions(path: path),
statusCode: 204,
),
);
await repository.deleteLine(101);
verify(
() => apiClient.delete<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).called(1);
});
test('restoreLine은 복구 엔드포인트를 호출한다', () async {
const path = '/api/v1/transaction-lines/101/restore';
when(
() => apiClient.post<Map<String, dynamic>>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: {
'data': {
'id': 101,
'line_no': 1,
'product': {'id': 11, 'product_code': 'P-1', 'product_name': '품목'},
'quantity': 3,
'unit_price': 1000,
},
},
requestOptions: RequestOptions(path: path),
statusCode: 200,
),
);
final line = await repository.restoreLine(101);
expect(line.id, 101);
expect(line.lineNo, 1);
});
}

View File

@@ -0,0 +1,153 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart';
import 'package:superport_v2/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart';
StockTransactionLine _buildLine({
int? id,
int productId = 1,
int lineNo = 1,
int quantity = 1,
double unitPrice = 1000,
String? note,
}) {
return StockTransactionLine(
id: id,
lineNo: lineNo,
product: StockTransactionProduct(
id: productId,
code: 'P$productId',
name: '제품$productId',
),
quantity: quantity,
unitPrice: unitPrice,
note: note,
);
}
StockTransactionCustomer _buildCustomer({
int? id,
int customerId = 1,
String code = 'C1',
String name = '고객1',
String? note,
}) {
return StockTransactionCustomer(
id: id,
customer: StockTransactionCustomerSummary(
id: customerId,
code: code,
name: name,
),
note: note,
);
}
void main() {
const service = TransactionDetailSyncService();
group('TransactionDetailSyncService', () {
test('buildLinePlan는 생성/수정/삭제 계획을 정확히 산출한다', () {
final currentLines = [
_buildLine(
id: 10,
productId: 1,
lineNo: 1,
quantity: 2,
unitPrice: 1000,
),
_buildLine(
id: 11,
productId: 2,
lineNo: 2,
quantity: 4,
unitPrice: 2000,
),
_buildLine(
id: 12,
productId: 3,
lineNo: 3,
quantity: 6,
unitPrice: 3000,
),
];
final drafts = [
TransactionLineDraft(
id: 10,
lineNo: 1,
productId: 1,
quantity: 3,
unitPrice: 1000,
note: null,
),
TransactionLineDraft(
id: 11,
lineNo: 2,
productId: 4,
quantity: 4,
unitPrice: 2200,
note: '교체',
),
TransactionLineDraft(
lineNo: 3,
productId: 5,
quantity: 1,
unitPrice: 500,
note: null,
),
];
final plan = service.buildLinePlan(
drafts: drafts,
currentLines: currentLines,
);
expect(plan.createdLines.length, 2);
final createdProductIds = plan.createdLines.map((line) => line.productId);
expect(createdProductIds, containsAll(<int>[4, 5]));
expect(plan.updatedLines.length, 1);
final update = plan.updatedLines.first;
expect(update.id, 10);
expect(update.quantity, 3);
expect(update.unitPrice, isNull);
expect(update.note, isNull);
expect(plan.deletedLineIds.length, 2);
expect(plan.deletedLineIds, containsAll(<int>[11, 12]));
});
test('buildCustomerPlan은 고객 추가/수정/삭제를 구분한다', () {
final currentCustomers = [
_buildCustomer(
id: 20,
customerId: 100,
code: 'C100',
name: '고객 100',
note: '메모',
),
_buildCustomer(id: 21, customerId: 200, code: 'C200', name: '고객 200'),
];
final drafts = [
TransactionCustomerDraft(id: 20, customerId: 100, note: '메모 수정'),
TransactionCustomerDraft(customerId: 300, note: null),
];
final plan = service.buildCustomerPlan(
drafts: drafts,
currentCustomers: currentCustomers,
);
expect(plan.createdCustomers.length, 1);
expect(plan.createdCustomers.first.customerId, 300);
expect(plan.updatedCustomers.length, 1);
expect(plan.updatedCustomers.first.id, 20);
expect(plan.updatedCustomers.first.note, '메모 수정');
expect(plan.deletedCustomerIds.length, 1);
expect(plan.deletedCustomerIds.first, 21);
});
});
}