refactor: 인벤토리 테이블 스펙과 도메인 계층 정비
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,40 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.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 {
|
||||
const InventoryProductAutocompleteField({
|
||||
super.key,
|
||||
@@ -11,7 +42,8 @@ class InventoryProductAutocompleteField extends StatefulWidget {
|
||||
required this.productFocusNode,
|
||||
required this.manufacturerController,
|
||||
required this.unitController,
|
||||
required this.onCatalogMatched,
|
||||
required this.onSuggestionSelected,
|
||||
this.initialSuggestion,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
@@ -19,7 +51,8 @@ class InventoryProductAutocompleteField extends StatefulWidget {
|
||||
final FocusNode productFocusNode;
|
||||
final TextEditingController manufacturerController;
|
||||
final TextEditingController unitController;
|
||||
final ValueChanged<InventoryProductCatalogItem?> onCatalogMatched;
|
||||
final ValueChanged<InventoryProductSuggestion?> onSuggestionSelected;
|
||||
final InventoryProductSuggestion? initialSuggestion;
|
||||
final VoidCallback? onChanged;
|
||||
|
||||
@override
|
||||
@@ -27,21 +60,35 @@ class InventoryProductAutocompleteField extends StatefulWidget {
|
||||
_InventoryProductAutocompleteFieldState();
|
||||
}
|
||||
|
||||
/// 자동완성 로직을 구현한 상태 클래스.
|
||||
/// 제품 자동완성 위젯의 내부 상태를 관리한다.
|
||||
class _InventoryProductAutocompleteFieldState
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
_catalogMatch = InventoryProductCatalog.match(
|
||||
widget.productController.text.trim(),
|
||||
);
|
||||
if (_catalogMatch != null) {
|
||||
_applyCatalog(_catalogMatch!, updateProduct: false);
|
||||
_selected = widget.initialSuggestion;
|
||||
if (_selected != null) {
|
||||
_applySuggestion(_selected!, updateProductField: false);
|
||||
}
|
||||
widget.productController.addListener(_handleTextChanged);
|
||||
if (widget.productController.text.trim().isNotEmpty &&
|
||||
widget.initialSuggestion == null) {
|
||||
_scheduleSearch(widget.productController.text.trim());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -50,70 +97,119 @@ class _InventoryProductAutocompleteFieldState
|
||||
if (!identical(oldWidget.productController, widget.productController)) {
|
||||
oldWidget.productController.removeListener(_handleTextChanged);
|
||||
widget.productController.addListener(_handleTextChanged);
|
||||
_catalogMatch = InventoryProductCatalog.match(
|
||||
widget.productController.text.trim(),
|
||||
);
|
||||
if (_catalogMatch != null) {
|
||||
_applyCatalog(_catalogMatch!, updateProduct: false);
|
||||
}
|
||||
_scheduleSearch(widget.productController.text.trim());
|
||||
}
|
||||
if (widget.initialSuggestion != oldWidget.initialSuggestion &&
|
||||
widget.initialSuggestion != null) {
|
||||
_selected = widget.initialSuggestion;
|
||||
_applySuggestion(widget.initialSuggestion!, updateProductField: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 텍스트 입력 변화를 감지해 자동완성 결과를 적용한다.
|
||||
/// 입력 변화에 따라 제안 검색을 예약한다.
|
||||
void _handleTextChanged() {
|
||||
final text = widget.productController.text.trim();
|
||||
final match = InventoryProductCatalog.match(text);
|
||||
if (match != null) {
|
||||
_applyCatalog(match);
|
||||
if (text.isEmpty) {
|
||||
_clearSelection();
|
||||
return;
|
||||
}
|
||||
if (_catalogMatch != null) {
|
||||
setState(() {
|
||||
_catalogMatch = null;
|
||||
});
|
||||
widget.onCatalogMatched(null);
|
||||
if (widget.manufacturerController.text.isNotEmpty) {
|
||||
widget.manufacturerController.clear();
|
||||
}
|
||||
if (widget.unitController.text.isNotEmpty) {
|
||||
widget.unitController.clear();
|
||||
}
|
||||
}
|
||||
_scheduleSearch(text);
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
|
||||
/// 선택된 카탈로그 정보를 관련 필드에 적용한다.
|
||||
void _applyCatalog(
|
||||
InventoryProductCatalogItem match, {
|
||||
bool updateProduct = true,
|
||||
void _scheduleSearch(String keyword) {
|
||||
_debounce?.cancel();
|
||||
if (keyword.isEmpty) {
|
||||
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(() {
|
||||
_catalogMatch = match;
|
||||
_selected = suggestion;
|
||||
});
|
||||
widget.onCatalogMatched(match);
|
||||
if (updateProduct && widget.productController.text != match.name) {
|
||||
widget.productController.text = match.name;
|
||||
widget.onSuggestionSelected(suggestion);
|
||||
if (updateProductField) {
|
||||
widget.productController.text = suggestion.name;
|
||||
widget.productController.selection = TextSelection.collapsed(
|
||||
offset: widget.productController.text.length,
|
||||
);
|
||||
}
|
||||
if (widget.manufacturerController.text != match.manufacturer) {
|
||||
widget.manufacturerController.text = match.manufacturer;
|
||||
if (widget.manufacturerController.text != suggestion.vendorName) {
|
||||
widget.manufacturerController.text = suggestion.vendorName;
|
||||
}
|
||||
if (widget.unitController.text != match.unit) {
|
||||
widget.unitController.text = match.unit;
|
||||
if (widget.unitController.text != suggestion.unitName) {
|
||||
widget.unitController.text = suggestion.unitName;
|
||||
}
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
|
||||
/// 주어진 검색어에 매칭되는 제품 목록을 반환한다.
|
||||
Iterable<InventoryProductCatalogItem> _options(String query) {
|
||||
return InventoryProductCatalog.filter(query);
|
||||
void _clearSelection() {
|
||||
if (_selected == null &&
|
||||
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
|
||||
void dispose() {
|
||||
widget.productController.removeListener(_handleTextChanged);
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -122,25 +218,45 @@ class _InventoryProductAutocompleteFieldState
|
||||
final theme = ShadTheme.of(context);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return RawAutocomplete<InventoryProductCatalogItem>(
|
||||
return RawAutocomplete<InventoryProductSuggestion>(
|
||||
textEditingController: widget.productController,
|
||||
focusNode: widget.productFocusNode,
|
||||
optionsBuilder: (textEditingValue) {
|
||||
return _options(textEditingValue.text);
|
||||
if (textEditingValue.text.trim().isEmpty) {
|
||||
return const Iterable<InventoryProductSuggestion>.empty();
|
||||
}
|
||||
return _suggestions;
|
||||
},
|
||||
displayStringForOption: (option) => option.name,
|
||||
onSelected: (option) {
|
||||
_applyCatalog(option);
|
||||
_applySuggestion(option);
|
||||
},
|
||||
fieldViewBuilder:
|
||||
(context, textEditingController, focusNode, onFieldSubmitted) {
|
||||
return ShadInput(
|
||||
controller: textEditingController,
|
||||
(context, textController, focusNode, onFieldSubmitted) {
|
||||
final input = ShadInput(
|
||||
controller: textController,
|
||||
focusNode: focusNode,
|
||||
placeholder: const Text('제품명'),
|
||||
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) {
|
||||
@@ -149,7 +265,7 @@ class _InventoryProductAutocompleteFieldState
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: 240,
|
||||
maxHeight: 220,
|
||||
),
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
@@ -158,7 +274,15 @@ class _InventoryProductAutocompleteFieldState
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
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),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${option.code} · ${option.manufacturer} · ${option.unit}',
|
||||
'${option.code} · ${option.vendorName} · ${option.unitName}',
|
||||
style: theme.textTheme.muted.copyWith(
|
||||
fontSize: 12,
|
||||
),
|
||||
|
||||
@@ -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}'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(','),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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: '슈퍼포트 고객',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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 [],
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user