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

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

View File

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

View File

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

View File

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