diff --git a/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart b/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart new file mode 100644 index 0000000..5a69cc2 --- /dev/null +++ b/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart @@ -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 fallbackStatusOptions = const ['작성중', '승인대기', '승인완료'], + List transactionTypeKeywords = const ['입고', 'inbound'], + }) : _transactionRepository = transactionRepository, + _lineRepository = lineRepository, + _lookupRepository = lookupRepository, + _fallbackStatusOptions = List.unmodifiable( + fallbackStatusOptions, + ), + _transactionTypeKeywords = List.unmodifiable( + transactionTypeKeywords, + ) { + _statusOptions = List.from(_fallbackStatusOptions); + } + + final StockTransactionRepository _transactionRepository; + final TransactionLineRepository _lineRepository; + final InventoryLookupRepository _lookupRepository; + final List _fallbackStatusOptions; + final List _transactionTypeKeywords; + + late List _statusOptions; + final Map _statusLookup = {}; + LookupItem? _transactionType; + PaginatedResult? _result; + List _records = const []; + bool _isLoading = false; + String? _errorMessage; + StockTransactionListFilter? _lastFilter; + final Set _processingTransactionIds = {}; + + UnmodifiableListView get statusOptions => + UnmodifiableListView(_statusOptions); + + UnmodifiableMapView get statusLookup => + UnmodifiableMapView(_statusLookup); + + LookupItem? get transactionType => _transactionType; + + PaginatedResult? get result => _result; + + UnmodifiableListView get records => + UnmodifiableListView(_records); + + bool get isLoading => _isLoading; + + String? get errorMessage => _errorMessage; + + List get fallbackStatusOptions => _fallbackStatusOptions; + + StockTransactionListFilter? get lastFilter => _lastFilter; + + UnmodifiableSetView get processingTransactionIds => + UnmodifiableSetView(_processingTransactionIds); + + /// 트랜잭션 상태 목록을 서버에서 읽어온다. + Future 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 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 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( + items: const [], + page: filter.page, + pageSize: filter.pageSize, + total: 0, + ); + final failure = Failure.from(error); + _errorMessage = failure.describe(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 마지막으로 사용한 필터를 기준으로 목록을 새로고침한다. + Future refresh({StockTransactionListFilter? filter}) async { + final target = filter ?? _lastFilter; + if (target == null) { + return; + } + await fetchTransactions(filter: target); + } + + /// 재고 트랜잭션을 생성하고 필요 시 목록을 갱신한다. + Future createTransaction( + StockTransactionCreateInput input, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.create(input), + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 재고 트랜잭션을 수정한다. + Future updateTransaction( + int id, + StockTransactionUpdateInput input, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.update(id, input), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 재고 트랜잭션을 삭제한다. + Future deleteTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) async { + await _executeVoidMutation( + () => _transactionRepository.delete(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 삭제된 재고 트랜잭션을 복구한다. + Future restoreTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.restore(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 재고 트랜잭션을 상신(submit)한다. + Future submitTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.submit(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 재고 트랜잭션을 완료 처리한다. + Future completeTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.complete(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 재고 트랜잭션을 승인 처리한다. + Future approveTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.approve(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 재고 트랜잭션을 반려 처리한다. + Future rejectTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.reject(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 재고 트랜잭션을 취소 처리한다. + Future cancelTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.cancel(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + Future _executeMutation( + Future 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 _executeVoidMutation( + Future 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 _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 items, List 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 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); + } + } +} diff --git a/lib/features/inventory/inbound/presentation/models/inbound_record.dart b/lib/features/inventory/inbound/presentation/models/inbound_record.dart new file mode 100644 index 0000000..b76c15b --- /dev/null +++ b/lib/features/inventory/inbound/presentation/models/inbound_record.dart @@ -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 items; + final DateTime? expectedReturnDate; + final bool isActive; + final StockTransaction? raw; + + int get itemCount => items.length; + + int get totalQuantity => + items.fold(0, (sum, item) => sum + item.quantity); + + double get totalAmount => + items.fold(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; +} diff --git a/lib/features/inventory/inbound/presentation/specs/inbound_table_spec.dart b/lib/features/inventory/inbound/presentation/specs/inbound_table_spec.dart new file mode 100644 index 0000000..6e2ca0c --- /dev/null +++ b/lib/features/inventory/inbound/presentation/specs/inbound_table_spec.dart @@ -0,0 +1,82 @@ +import 'package:superport_v2/widgets/components/responsive.dart'; + +/// 입고 테이블과 필터 구성에 사용되는 정적 스펙 모음. +class InboundTableSpec { + const InboundTableSpec._(); + + /// 목록 헤더 라벨. + static const List headers = [ + '번호', + '처리일자', + '창고', + '트랜잭션번호', + '제품', + '제조사', + '단위', + '수량', + '단가', + '상태', + '작성자', + '품목수', + '총수량', + '비고', + ]; + + /// 기본 정렬에서 허용하는 페이지당 항목 수 옵션. + static const List pageSizeOptions = [10, 20, 50]; + + /// API include 파라미터 기본값. + static const List defaultIncludeOptions = ['lines']; + + /// 백엔드에서 상태 목록을 내려주지 않을 때 사용되는 기본 상태 라벨. + static const List fallbackStatusOptions = ['작성중', '승인대기', '승인완료']; + + /// 입고 트랜잭션 타입을 식별하기 위한 키워드. + static const List 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 visibleColumns(DeviceBreakpoint breakpoint) { + switch (breakpoint) { + case DeviceBreakpoint.desktop: + return List.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; + } + } +} diff --git a/lib/features/inventory/lookups/data/dtos/lookup_item_dto.dart b/lib/features/inventory/lookups/data/dtos/lookup_item_dto.dart new file mode 100644 index 0000000..7b0bcd2 --- /dev/null +++ b/lib/features/inventory/lookups/data/dtos/lookup_item_dto.dart @@ -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 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 parseList(Map? json) { + final rawItems = JsonUtils.extractList(json, keys: const ['items']); + return rawItems + .map(LookupItemDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(growable: false); + } +} diff --git a/lib/features/inventory/lookups/data/repositories/inventory_lookup_repository_remote.dart b/lib/features/inventory/lookups/data/repositories/inventory_lookup_repository_remote.dart new file mode 100644 index 0000000..bef6bff --- /dev/null +++ b/lib/features/inventory/lookups/data/repositories/inventory_lookup_repository_remote.dart @@ -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> fetchTransactionTypes({bool activeOnly = true}) { + return _fetchList(_transactionTypesPath, activeOnly: activeOnly); + } + + @override + Future> fetchTransactionStatuses({bool activeOnly = true}) { + return _fetchList(_transactionStatusesPath, activeOnly: activeOnly); + } + + @override + Future> fetchApprovalStatuses({bool activeOnly = true}) { + return _fetchList(_approvalStatusesPath, activeOnly: activeOnly); + } + + @override + Future> fetchApprovalActions({bool activeOnly = true}) { + return _fetchList( + _approvalActionsPath, + activeOnly: activeOnly, + // Approval actions는 is_active 필터가 없을 수 있어 조건적으로 전달. + includeIsActive: false, + ); + } + + Future> _fetchList( + String path, { + required bool activeOnly, + bool includeIsActive = true, + }) async { + final response = await _api.get>( + path, + query: { + 'page': 1, + 'page_size': 200, + if (includeIsActive && activeOnly) 'is_active': true, + }, + options: Options(responseType: ResponseType.json), + ); + return LookupItemDto.parseList(response.data ?? const {}); + } +} diff --git a/lib/features/inventory/lookups/domain/entities/lookup_item.dart b/lib/features/inventory/lookups/domain/entities/lookup_item.dart new file mode 100644 index 0000000..8e4388f --- /dev/null +++ b/lib/features/inventory/lookups/domain/entities/lookup_item.dart @@ -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; +} diff --git a/lib/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart b/lib/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart new file mode 100644 index 0000000..3349469 --- /dev/null +++ b/lib/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart @@ -0,0 +1,16 @@ +import '../entities/lookup_item.dart'; + +/// 인벤토리 공통 룩업(타입/상태/승인 액션) 저장소 인터페이스. +abstract class InventoryLookupRepository { + /// 입출고 트랜잭션 타입 목록을 조회한다. + Future> fetchTransactionTypes({bool activeOnly = true}); + + /// 입출고 트랜잭션 상태 목록을 조회한다. + Future> fetchTransactionStatuses({bool activeOnly = true}); + + /// 결재 상태 목록을 조회한다. + Future> fetchApprovalStatuses({bool activeOnly = true}); + + /// 결재 액션 목록을 조회한다. + Future> fetchApprovalActions({bool activeOnly = true}); +} diff --git a/lib/features/inventory/outbound/presentation/controllers/outbound_controller.dart b/lib/features/inventory/outbound/presentation/controllers/outbound_controller.dart new file mode 100644 index 0000000..ac20287 --- /dev/null +++ b/lib/features/inventory/outbound/presentation/controllers/outbound_controller.dart @@ -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 fallbackStatusOptions = const ['작성중', '출고대기', '출고완료'], + List transactionTypeKeywords = const ['출고', 'outbound'], + }) : _transactionRepository = transactionRepository, + _lineRepository = lineRepository, + _customerRepository = customerRepository, + _lookupRepository = lookupRepository, + _fallbackStatusOptions = List.unmodifiable( + fallbackStatusOptions, + ), + _transactionTypeKeywords = List.unmodifiable( + transactionTypeKeywords, + ) { + _statusOptions = List.from(_fallbackStatusOptions); + } + + final StockTransactionRepository _transactionRepository; + final TransactionLineRepository _lineRepository; + final TransactionCustomerRepository _customerRepository; + final InventoryLookupRepository _lookupRepository; + final List _fallbackStatusOptions; + final List _transactionTypeKeywords; + + late List _statusOptions; + final Map _statusLookup = {}; + LookupItem? _transactionType; + PaginatedResult? _result; + List _records = const []; + bool _isLoading = false; + String? _errorMessage; + StockTransactionListFilter? _lastFilter; + final Set _processingTransactionIds = {}; + + UnmodifiableListView get statusOptions => + UnmodifiableListView(_statusOptions); + + UnmodifiableMapView get statusLookup => + UnmodifiableMapView(_statusLookup); + + LookupItem? get transactionType => _transactionType; + + PaginatedResult? get result => _result; + + UnmodifiableListView get records => + UnmodifiableListView(_records); + + bool get isLoading => _isLoading; + + String? get errorMessage => _errorMessage; + + List get fallbackStatusOptions => _fallbackStatusOptions; + + StockTransactionListFilter? get lastFilter => _lastFilter; + + UnmodifiableSetView get processingTransactionIds => + UnmodifiableSetView(_processingTransactionIds); + + /// 트랜잭션 상태 값을 불러온다. + Future 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 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 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( + items: const [], + page: filter.page, + pageSize: filter.pageSize, + total: 0, + ); + final failure = Failure.from(error); + _errorMessage = failure.describe(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 마지막 필터를 사용해 목록을 새로고침한다. + Future refresh({StockTransactionListFilter? filter}) async { + final target = filter ?? _lastFilter; + if (target == null) { + return; + } + await fetchTransactions(filter: target); + } + + /// 출고 트랜잭션을 생성한다. + Future createTransaction( + StockTransactionCreateInput input, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.create(input), + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 출고 트랜잭션을 수정한다. + Future updateTransaction( + int id, + StockTransactionUpdateInput input, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.update(id, input), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 출고 트랜잭션을 삭제한다. + Future deleteTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) async { + await _executeVoidMutation( + () => _transactionRepository.delete(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 삭제된 출고 트랜잭션을 복구한다. + Future restoreTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.restore(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 출고 트랜잭션을 상신한다. + Future submitTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.submit(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 출고 트랜잭션을 완료 처리한다. + Future completeTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.complete(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 출고 트랜잭션을 승인 처리한다. + Future approveTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.approve(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 출고 트랜잭션을 반려 처리한다. + Future rejectTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.reject(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + /// 출고 트랜잭션을 취소 처리한다. + Future cancelTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + }) { + return _executeMutation( + () => _transactionRepository.cancel(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + ); + } + + Future _executeMutation( + Future 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 _executeVoidMutation( + Future 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 _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 items, List 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 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 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, + ); + } + } +} diff --git a/lib/features/inventory/outbound/presentation/models/outbound_record.dart b/lib/features/inventory/outbound/presentation/models/outbound_record.dart new file mode 100644 index 0000000..922d336 --- /dev/null +++ b/lib/features/inventory/outbound/presentation/models/outbound_record.dart @@ -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 items; + final List customers; + final StockTransaction? raw; + + int get customerCount => customers.length; + + int get itemCount => items.length; + + int get totalQuantity => + items.fold(0, (sum, item) => sum + item.quantity); + + double get totalAmount => + items.fold(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; +} diff --git a/lib/features/inventory/outbound/presentation/specs/outbound_table_spec.dart b/lib/features/inventory/outbound/presentation/specs/outbound_table_spec.dart new file mode 100644 index 0000000..943be93 --- /dev/null +++ b/lib/features/inventory/outbound/presentation/specs/outbound_table_spec.dart @@ -0,0 +1,71 @@ +/// 출고 테이블과 필터 구성을 위한 정적 스펙을 정의한다. +class OutboundTableSpec { + const OutboundTableSpec._(); + + /// 목록 헤더 라벨. + static const List headers = [ + '번호', + '처리일자', + '창고', + '트랜잭션번호', + '제품', + '제조사', + '단위', + '수량', + '단가', + '상태', + '작성자', + '고객수', + '품목수', + '총수량', + '비고', + ]; + + /// 페이지네이션에서 제공하는 항목 수 옵션. + static const List pageSizeOptions = [10, 20, 50]; + + /// include 파라미터 기본값. + static const List defaultIncludeOptions = ['lines', 'customers']; + + /// 백엔드 미응답 시 사용할 기본 상태 라벨. + static const List fallbackStatusOptions = ['작성중', '출고대기', '출고완료']; + + /// 출고 트랜잭션 타입 탐색용 키워드. + static const List 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; + } + } +} diff --git a/lib/features/inventory/rental/presentation/controllers/rental_controller.dart b/lib/features/inventory/rental/presentation/controllers/rental_controller.dart new file mode 100644 index 0000000..02aa295 --- /dev/null +++ b/lib/features/inventory/rental/presentation/controllers/rental_controller.dart @@ -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 fallbackStatusOptions = const ['대여중', '반납대기', '완료'], + List rentTransactionKeywords = const ['대여', 'rent'], + List returnTransactionKeywords = const ['반납', 'return'], + }) : _transactionRepository = transactionRepository, + _lineRepository = lineRepository, + _customerRepository = customerRepository, + _lookupRepository = lookupRepository, + _fallbackStatusOptions = List.unmodifiable( + fallbackStatusOptions, + ), + _rentTransactionKeywords = List.unmodifiable( + rentTransactionKeywords, + ), + _returnTransactionKeywords = List.unmodifiable( + returnTransactionKeywords, + ) { + _statusOptions = List.from(_fallbackStatusOptions); + } + + final StockTransactionRepository _transactionRepository; + final TransactionLineRepository _lineRepository; + final TransactionCustomerRepository _customerRepository; + final InventoryLookupRepository _lookupRepository; + final List _fallbackStatusOptions; + final List _rentTransactionKeywords; + final List _returnTransactionKeywords; + + late List _statusOptions; + final Map _statusLookup = {}; + LookupItem? _rentTransactionType; + LookupItem? _returnTransactionType; + PaginatedResult? _result; + List _records = const []; + bool _isLoading = false; + String? _errorMessage; + StockTransactionListFilter? _lastFilter; + final Set _processingTransactionIds = {}; + bool _lastFilterByRentalTypes = true; + + UnmodifiableListView get statusOptions => + UnmodifiableListView(_statusOptions); + + UnmodifiableMapView get statusLookup => + UnmodifiableMapView(_statusLookup); + + LookupItem? get rentTransactionType => _rentTransactionType; + + LookupItem? get returnTransactionType => _returnTransactionType; + + PaginatedResult? get result => _result; + + UnmodifiableListView get records => + UnmodifiableListView(_records); + + bool get isLoading => _isLoading; + + String? get errorMessage => _errorMessage; + + List get fallbackStatusOptions => _fallbackStatusOptions; + + StockTransactionListFilter? get lastFilter => _lastFilter; + + UnmodifiableSetView get processingTransactionIds => + UnmodifiableSetView(_processingTransactionIds); + + bool get lastFilterByRentalTypes => _lastFilterByRentalTypes; + + /// 트랜잭션 상태 목록을 조회한다. + Future 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 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 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( + items: transactions, + page: filter.page, + pageSize: filter.pageSize, + total: transactions.length, + ); + } catch (error) { + _records = const []; + _result = PaginatedResult( + items: const [], + page: filter.page, + pageSize: filter.pageSize, + total: 0, + ); + final failure = Failure.from(error); + _errorMessage = failure.describe(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// 마지막 필터를 사용해 목록을 새로고침한다. + Future refresh({ + StockTransactionListFilter? filter, + bool? filterByRentalTypes, + }) async { + final targetFilter = filter ?? _lastFilter; + if (targetFilter == null) { + return; + } + await fetchTransactions( + filter: targetFilter, + filterByRentalTypes: filterByRentalTypes ?? _lastFilterByRentalTypes, + ); + } + + /// 대여/반납 트랜잭션을 생성한다. + Future createTransaction( + StockTransactionCreateInput input, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + bool? refreshFilterByRentalTypes, + }) { + return _executeMutation( + () => _transactionRepository.create(input), + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + refreshFilterByRentalTypes: refreshFilterByRentalTypes, + ); + } + + /// 대여/반납 트랜잭션을 수정한다. + Future 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 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 restoreTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + bool? refreshFilterByRentalTypes, + }) { + return _executeMutation( + () => _transactionRepository.restore(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + refreshFilterByRentalTypes: refreshFilterByRentalTypes, + ); + } + + /// 대여/반납 트랜잭션을 상신한다. + Future submitTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + bool? refreshFilterByRentalTypes, + }) { + return _executeMutation( + () => _transactionRepository.submit(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + refreshFilterByRentalTypes: refreshFilterByRentalTypes, + ); + } + + /// 대여/반납 트랜잭션을 완료 처리한다. + Future completeTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + bool? refreshFilterByRentalTypes, + }) { + return _executeMutation( + () => _transactionRepository.complete(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + refreshFilterByRentalTypes: refreshFilterByRentalTypes, + ); + } + + /// 대여/반납 트랜잭션을 승인 처리한다. + Future approveTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + bool? refreshFilterByRentalTypes, + }) { + return _executeMutation( + () => _transactionRepository.approve(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + refreshFilterByRentalTypes: refreshFilterByRentalTypes, + ); + } + + /// 대여/반납 트랜잭션을 반려 처리한다. + Future rejectTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + bool? refreshFilterByRentalTypes, + }) { + return _executeMutation( + () => _transactionRepository.reject(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + refreshFilterByRentalTypes: refreshFilterByRentalTypes, + ); + } + + /// 대여/반납 트랜잭션을 취소 처리한다. + Future cancelTransaction( + int id, { + bool refreshAfter = true, + StockTransactionListFilter? refreshFilter, + bool? refreshFilterByRentalTypes, + }) { + return _executeMutation( + () => _transactionRepository.cancel(id), + trackedId: id, + refreshAfter: refreshAfter, + refreshFilter: refreshFilter, + refreshFilterByRentalTypes: refreshFilterByRentalTypes, + ); + } + + Future _executeMutation( + Future 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 _executeVoidMutation( + Future 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 _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 items, List 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 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 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, + ); + } + } +} diff --git a/lib/features/inventory/rental/presentation/models/rental_record.dart b/lib/features/inventory/rental/presentation/models/rental_record.dart new file mode 100644 index 0000000..36f6bb5 --- /dev/null +++ b/lib/features/inventory/rental/presentation/models/rental_record.dart @@ -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 items; + final List customers; + final StockTransaction? raw; + + int get customerCount => customers.length; + + int get itemCount => items.length; + + int get totalQuantity => + items.fold(0, (sum, item) => sum + item.quantity); + + double get totalAmount => + items.fold(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; +} diff --git a/lib/features/inventory/rental/presentation/specs/rental_table_spec.dart b/lib/features/inventory/rental/presentation/specs/rental_table_spec.dart new file mode 100644 index 0000000..0f1fd40 --- /dev/null +++ b/lib/features/inventory/rental/presentation/specs/rental_table_spec.dart @@ -0,0 +1,76 @@ +/// 대여 테이블과 필터 구성에 필요한 정적 스펙을 정의한다. +class RentalTableSpec { + const RentalTableSpec._(); + + /// 목록 헤더 라벨. + static const List headers = [ + '번호', + '처리일자', + '창고', + '대여구분', + '트랜잭션번호', + '상태', + '반납예정일', + '고객수', + '품목수', + '총수량', + '비고', + ]; + + /// 페이지당 항목 수 선택지. + static const List pageSizeOptions = [10, 20, 50]; + + /// include 파라미터 기본값. + static const List defaultIncludeOptions = ['lines', 'customers']; + + /// 백엔드 미응답 시 사용하는 기본 상태라벨. + static const List fallbackStatusOptions = ['대여중', '반납대기', '완료']; + + /// 대여 타입 식별 키워드. + static const List rentTransactionKeywords = ['대여', 'rent']; + + /// 반납 타입 식별 키워드. + static const List returnTransactionKeywords = ['반납', 'return']; + + /// 필터에서 제공하는 대여구분 라벨. + static const List 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; + } + } +} diff --git a/lib/features/inventory/shared/catalogs.dart b/lib/features/inventory/shared/catalogs.dart deleted file mode 100644 index 676d0b4..0000000 --- a/lib/features/inventory/shared/catalogs.dart +++ /dev/null @@ -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 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 _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 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 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 _byName = { - for (final item in items) item.name: item, - }; - - static InventoryCustomerCatalogItem? byName(String name) => _byName[name]; - - static List 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), - ); -} diff --git a/lib/features/inventory/shared/widgets/customer_multi_select_field.dart b/lib/features/inventory/shared/widgets/customer_multi_select_field.dart new file mode 100644 index 0000000..124a25a --- /dev/null +++ b/lib/features/inventory/shared/widgets/customer_multi_select_field.dart @@ -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 {}, + required this.onChanged, + this.enabled = true, + this.placeholder, + }); + + final Set initialCustomerIds; + final ValueChanged> onChanged; + final bool enabled; + final Widget? placeholder; + + @override + State createState() => + _InventoryCustomerMultiSelectFieldState(); +} + +class _InventoryCustomerMultiSelectFieldState + extends State { + static const Duration _debounceDuration = Duration(milliseconds: 300); + + final ShadSelectController _controller = ShadSelectController( + initialValue: {}, + ); + final Map _selectedOptions = {}; + final List _suggestions = []; + Timer? _debounce; + int _requestId = 0; + bool _isSearching = false; + String _searchKeyword = ''; + + CustomerRepository? get _repository => + GetIt.I.isRegistered() + ? GetIt.I() + : null; + + @override + void initState() { + super.initState(); + if (widget.initialCustomerIds.isNotEmpty) { + _controller.value = widget.initialCustomerIds.toSet(); + _prefetchInitialOptions(widget.initialCustomerIds); + } + } + + Future _prefetchInitialOptions(Set 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 _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.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; + } +} diff --git a/lib/features/inventory/shared/widgets/employee_autocomplete_field.dart b/lib/features/inventory/shared/widgets/employee_autocomplete_field.dart new file mode 100644 index 0000000..496643a --- /dev/null +++ b/lib/features/inventory/shared/widgets/employee_autocomplete_field.dart @@ -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 onSuggestionSelected; + final VoidCallback? onChanged; + final bool enabled; + + @override + State createState() => + _InventoryEmployeeAutocompleteFieldState(); +} + +class _InventoryEmployeeAutocompleteFieldState + extends State { + static const Duration _debounceDuration = Duration(milliseconds: 250); + + final List _suggestions = []; + InventoryEmployeeSuggestion? _selected; + Timer? _debounce; + int _requestId = 0; + bool _isSearching = false; + late final FocusNode _focusNode; + + UserRepository? get _repository => + GetIt.I.isRegistered() ? GetIt.I() : 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 _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( + textEditingController: widget.controller, + focusNode: _focusNode, + optionsBuilder: (textEditingValue) { + if (textEditingValue.text.trim().isEmpty) { + return const Iterable.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), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/inventory/shared/widgets/product_autocomplete_field.dart b/lib/features/inventory/shared/widgets/product_autocomplete_field.dart index 57512ac..8b2577e 100644 --- a/lib/features/inventory/shared/widgets/product_autocomplete_field.dart +++ b/lib/features/inventory/shared/widgets/product_autocomplete_field.dart @@ -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 onCatalogMatched; + final ValueChanged onSuggestionSelected; + final InventoryProductSuggestion? initialSuggestion; final VoidCallback? onChanged; @override @@ -27,21 +60,35 @@ class InventoryProductAutocompleteField extends StatefulWidget { _InventoryProductAutocompleteFieldState(); } -/// 자동완성 로직을 구현한 상태 클래스. +/// 제품 자동완성 위젯의 내부 상태를 관리한다. class _InventoryProductAutocompleteFieldState extends State { - InventoryProductCatalogItem? _catalogMatch; + static const Duration _debounceDuration = Duration(milliseconds: 280); + + final List _suggestions = + []; + InventoryProductSuggestion? _selected; + Timer? _debounce; + int _requestCounter = 0; + bool _isSearching = false; + + ProductRepository? get _repository => + GetIt.I.isRegistered() + ? GetIt.I() + : 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 _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 _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( + return RawAutocomplete( textEditingController: widget.productController, focusNode: widget.productFocusNode, optionsBuilder: (textEditingValue) { - return _options(textEditingValue.text); + if (textEditingValue.text.trim().isEmpty) { + return const Iterable.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, ), diff --git a/lib/features/inventory/shared/widgets/warehouse_select_field.dart b/lib/features/inventory/shared/widgets/warehouse_select_field.dart new file mode 100644 index 0000000..7ae1e86 --- /dev/null +++ b/lib/features/inventory/shared/widgets/warehouse_select_field.dart @@ -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 onChanged; + final bool enabled; + final Widget? placeholder; + final bool includeAllOption; + final String allLabel; + + @override + State createState() => + _InventoryWarehouseSelectFieldState(); +} + +class _InventoryWarehouseSelectFieldState + extends State { + WarehouseRepository? get _repository => + GetIt.I.isRegistered() + ? GetIt.I() + : null; + + List _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 _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 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( + 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(value: null, child: Text(widget.allLabel)), + for (final option in _options) + ShadOption( + value: option.id, + child: Text('${option.name} · ${option.code}'), + ), + ], + ); + } +} diff --git a/lib/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart b/lib/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart new file mode 100644 index 0000000..2558418 --- /dev/null +++ b/lib/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart @@ -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> list({ + StockTransactionListFilter? filter, + }) async { + final effectiveFilter = filter ?? StockTransactionListFilter(); + final response = await _api.get>( + _basePath, + query: effectiveFilter.toQuery(), + options: Options(responseType: ResponseType.json), + ); + return StockTransactionDto.parsePaginated(response.data ?? const {}); + } + + @override + Future fetchDetail( + int id, { + List include = const ['lines', 'customers', 'approval'], + }) async { + final response = await _api.get>( + '$_basePath/$id', + query: {if (include.isNotEmpty) 'include': include.join(',')}, + options: Options(responseType: ResponseType.json), + ); + return _parseSingle(response.data); + } + + @override + Future create(StockTransactionCreateInput input) async { + final response = await _api.post>( + _basePath, + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + return _parseSingle(response.data); + } + + @override + Future update( + int id, + StockTransactionUpdateInput input, + ) async { + final response = await _api.patch>( + '$_basePath/$id', + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + return _parseSingle(response.data); + } + + @override + Future delete(int id) async { + await _api.delete('$_basePath/$id'); + } + + @override + Future restore(int id) async { + final response = await _api.post>( + '$_basePath/$id/restore', + options: Options(responseType: ResponseType.json), + ); + return _parseSingle(response.data); + } + + @override + Future submit(int id) async { + final response = await _api.post>( + '$_basePath/$id/submit', + options: Options(responseType: ResponseType.json), + ); + return _parseSingle(response.data); + } + + @override + Future complete(int id) async { + final response = await _api.post>( + '$_basePath/$id/complete', + options: Options(responseType: ResponseType.json), + ); + return _parseSingle(response.data); + } + + @override + Future approve(int id) async { + final response = await _api.post>( + '$_basePath/$id/approve', + options: Options(responseType: ResponseType.json), + ); + return _parseSingle(response.data); + } + + @override + Future reject(int id) async { + final response = await _api.post>( + '$_basePath/$id/reject', + options: Options(responseType: ResponseType.json), + ); + return _parseSingle(response.data); + } + + @override + Future cancel(int id) async { + final response = await _api.post>( + '$_basePath/$id/cancel', + options: Options(responseType: ResponseType.json), + ); + return _parseSingle(response.data); + } + + StockTransaction _parseSingle(Map? body) { + final data = _extractData(body); + return StockTransactionDto.fromJson(data).toEntity(); + } + + Map _extractData(Map? body) { + if (body == null) { + return {}; + } + if (body['data'] is Map) { + return body['data'] as Map; + } + if (body.containsKey('transaction')) { + final transaction = body['transaction']; + if (transaction is Map) { + return transaction; + } + } + return body; + } +} diff --git a/lib/features/inventory/transactions/data/repositories/transaction_customer_repository_remote.dart b/lib/features/inventory/transactions/data/repositories/transaction_customer_repository_remote.dart new file mode 100644 index 0000000..3fb7902 --- /dev/null +++ b/lib/features/inventory/transactions/data/repositories/transaction_customer_repository_remote.dart @@ -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> addCustomers( + int transactionId, + List customers, + ) async { + final response = await _api.post>( + '$_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> updateCustomers( + int transactionId, + List customers, + ) async { + final response = await _api.patch>( + '$_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 deleteCustomer(int customerLinkId) async { + await _api.delete('$_customerPath/$customerLinkId'); + } + + List _parseCustomers(Map? 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 _extractData(Map? body) { + if (body == null) { + return {}; + } + if (body['data'] is Map) { + return body['data'] as Map; + } + return body; + } +} diff --git a/lib/features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart b/lib/features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart new file mode 100644 index 0000000..17fb9a3 --- /dev/null +++ b/lib/features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart @@ -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> addLines( + int transactionId, + List lines, + ) async { + final response = await _api.post>( + '$_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> updateLines( + int transactionId, + List lines, + ) async { + final response = await _api.patch>( + '$_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 deleteLine(int lineId) async { + await _api.delete('$_linePath/$lineId'); + } + + @override + Future restoreLine(int lineId) async { + final response = await _api.post>( + '$_linePath/$lineId/restore', + options: Options(responseType: ResponseType.json), + ); + final lines = _parseLines(response.data); + if (lines.isEmpty) { + throw StateError('복구된 라인 정보를 찾을 수 없습니다.'); + } + return lines.first; + } + + List _parseLines(Map? 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 _extractData(Map? body) { + if (body == null) { + return {}; + } + if (body['data'] is Map) { + return body['data'] as Map; + } + return body; + } +} diff --git a/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart new file mode 100644 index 0000000..eadc2f3 --- /dev/null +++ b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart @@ -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 lines; + final List customers; + + Map 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 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 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 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 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 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 include; + + Map 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(','), + }; + } +} diff --git a/lib/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart b/lib/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart new file mode 100644 index 0000000..66a757d --- /dev/null +++ b/lib/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart @@ -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> list({ + StockTransactionListFilter? filter, + }); + + /// 재고 트랜잭션 단건을 조회한다. + Future fetchDetail( + int id, { + List include = const ['lines', 'customers', 'approval'], + }); + + /// 재고 트랜잭션을 생성한다. + Future create(StockTransactionCreateInput input); + + /// 재고 트랜잭션을 수정한다. + Future update(int id, StockTransactionUpdateInput input); + + /// 재고 트랜잭션을 삭제한다. + Future delete(int id); + + /// 삭제된 재고 트랜잭션을 복구한다. + Future restore(int id); + + /// 재고 트랜잭션을 상신(submit)한다. + Future submit(int id); + + /// 재고 트랜잭션을 완료 처리한다. + Future complete(int id); + + /// 재고 트랜잭션을 승인 처리한다. + Future approve(int id); + + /// 재고 트랜잭션을 반려 처리한다. + Future reject(int id); + + /// 재고 트랜잭션을 취소 처리한다. + Future cancel(int id); +} + +/// 재고 트랜잭션 라인 저장소 인터페이스. +abstract class TransactionLineRepository { + /// 라인을 추가한다. + Future> addLines( + int transactionId, + List lines, + ); + + /// 라인 정보를 일괄 수정한다. + Future> updateLines( + int transactionId, + List lines, + ); + + /// 단일 라인을 삭제한다. + Future deleteLine(int lineId); + + /// 삭제된 라인을 복구한다. + Future restoreLine(int lineId); +} + +/// 재고 트랜잭션 고객 연결 저장소 인터페이스. +abstract class TransactionCustomerRepository { + /// 고객 연결을 추가한다. + Future> addCustomers( + int transactionId, + List customers, + ); + + /// 고객 연결 정보를 수정한다. + Future> updateCustomers( + int transactionId, + List customers, + ); + + /// 고객 연결을 삭제한다. + Future deleteCustomer(int customerLinkId); +} diff --git a/lib/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart b/lib/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart new file mode 100644 index 0000000..4b57e1a --- /dev/null +++ b/lib/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart @@ -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 createdLines; + + /// 수정이 필요한 기존 라인 목록. + final List updatedLines; + + /// 삭제해야 할 라인 ID 목록. + final List 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 createdCustomers; + + /// 비고 등 속성이 변경된 고객 연결 입력. + final List updatedCustomers; + + /// 삭제 대상 고객 연결 ID. + final List deletedCustomerIds; + + /// 변경 사항이 있는지 여부. + bool get hasChanges => + createdCustomers.isNotEmpty || + updatedCustomers.isNotEmpty || + deletedCustomerIds.isNotEmpty; +} + +/// 재고 트랜잭션 라인·고객 입력을 기존 데이터와 비교해 동기화 계획을 산출하는 유틸리티. +class TransactionDetailSyncService { + const TransactionDetailSyncService(); + + /// 현재 서버 상태와 폼 입력을 비교해 라인 변경 계획을 생성한다. + TransactionLineSyncPlan buildLinePlan({ + required List drafts, + required List currentLines, + }) { + final createdLines = []; + final updatedLines = []; + final deletedLineIds = {}; + + final currentById = {}; + 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 drafts, + required List currentCustomers, + }) { + final createdCustomers = []; + final updatedCustomers = []; + final deletedCustomerIds = {}; + + final currentById = {}; + final currentByCustomerId = {}; + + 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; +} diff --git a/test/features/inventory/lookups/data/inventory_lookup_repository_remote_test.dart b/test/features/inventory/lookups/data/inventory_lookup_repository_remote_test.dart new file mode 100644 index 0000000..c33a24c --- /dev/null +++ b/test/features/inventory/lookups/data/inventory_lookup_repository_remote_test.dart @@ -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 stubGet(String path) async { + when( + () => apiClient.get>( + path, + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + 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>( + 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; + 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>( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured[1] + as Map; + expect(query.containsKey('is_active'), isFalse); + }); +} diff --git a/test/features/inventory/outbound/presentation/controllers/outbound_controller_test.dart b/test/features/inventory/outbound/presentation/controllers/outbound_controller_test.dart new file mode 100644 index 0000000..927dd06 --- /dev/null +++ b/test/features/inventory/outbound/presentation/controllers/outbound_controller_test.dart @@ -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: '슈퍼포트 고객', + ), + ), + ], + ); +} diff --git a/test/features/inventory/rental/presentation/controllers/rental_controller_test.dart b/test/features/inventory/rental/presentation/controllers/rental_controller_test.dart new file mode 100644 index 0000000..22f9572 --- /dev/null +++ b/test/features/inventory/rental/presentation/controllers/rental_controller_test.dart @@ -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.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( + 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( + 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 [], + ); +} diff --git a/test/features/inventory/shared/widgets/warehouse_select_field_test.dart b/test/features/inventory/shared/widgets/warehouse_select_field_test.dart new file mode 100644 index 0000000..159fb54 --- /dev/null +++ b/test/features/inventory/shared/widgets/warehouse_select_field_test.dart @@ -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(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); + }); +} diff --git a/test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart b/test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart new file mode 100644 index 0000000..b789522 --- /dev/null +++ b/test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart @@ -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>> emptyListResponse(String path) async { + return Response>( + data: {'items': const [], 'page': 1, 'page_size': 20, 'total': 0}, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + } + + Map 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>( + 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>( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + + final query = captured[1] as Map; + 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>( + path, + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: detailBody(), + requestOptions: RequestOptions(path: path), + statusCode: 200, + ), + ); + + await repository.fetchDetail(10, include: const ['lines', 'customers']); + + final query = + verify( + () => apiClient.get>( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured[1] + as Map; + + expect(query['include'], 'lines,customers'); + }); + + test('create는 입력 payload를 body로 전달한다', () async { + const path = '/api/v1/stock-transactions'; + when( + () => apiClient.post>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + 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>( + captureAny(), + data: captureAny(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured[1] + as Map; + + 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()); + expect(payload['customers'], isA()); + }); + + test('submit은 /submit 엔드포인트를 호출한다', () async { + const path = '/api/v1/stock-transactions/10/submit'; + when( + () => apiClient.post>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: detailBody(), + requestOptions: RequestOptions(path: path), + statusCode: 200, + ), + ); + + await repository.submit(10); + + verify( + () => apiClient.post>( + 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>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: detailBody(), + requestOptions: RequestOptions(path: path), + statusCode: 200, + ), + ); + + await repository.complete(10); + + verify( + () => apiClient.post>( + 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>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: detailBody(), + requestOptions: RequestOptions(path: path), + statusCode: 200, + ), + ); + + await repository.approve(11); + + verify( + () => apiClient.post>( + 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>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: detailBody(), + requestOptions: RequestOptions(path: path), + statusCode: 200, + ), + ); + + await repository.reject(12); + + verify( + () => apiClient.post>( + 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>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: detailBody(), + requestOptions: RequestOptions(path: path), + statusCode: 200, + ), + ); + + await repository.cancel(13); + + verify( + () => apiClient.post>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).called(1); + }); +} diff --git a/test/features/inventory/transactions/data/transaction_customer_repository_remote_test.dart b/test/features/inventory/transactions/data/transaction_customer_repository_remote_test.dart new file mode 100644 index 0000000..17f8b78 --- /dev/null +++ b/test/features/inventory/transactions/data/transaction_customer_repository_remote_test.dart @@ -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 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>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: customerResponse(), + requestOptions: RequestOptions(path: path), + statusCode: 200, + ), + ); + + await repository.addCustomers(77, [ + TransactionCustomerCreateInput(customerId: 700, note: '비고'), + ]); + + final payload = + verify( + () => apiClient.post>( + captureAny(), + data: captureAny(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured[1] + as Map; + + expect(payload['id'], 77); + expect(payload['customers'], isA()); + }); + + test('updateCustomers는 PATCH 요청을 보낸다', () async { + const path = '/api/v1/stock-transactions/77/customers'; + when( + () => apiClient.patch>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: customerResponse(), + requestOptions: RequestOptions(path: path), + statusCode: 200, + ), + ); + + await repository.updateCustomers(77, [ + TransactionCustomerUpdateInput(id: 301, note: '수정'), + ]); + + verify( + () => apiClient.patch>( + 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( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: path), + statusCode: 204, + ), + ); + + await repository.deleteCustomer(301); + + verify( + () => apiClient.delete( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).called(1); + }); +} diff --git a/test/features/inventory/transactions/data/transaction_line_repository_remote_test.dart b/test/features/inventory/transactions/data/transaction_line_repository_remote_test.dart new file mode 100644 index 0000000..7a2cbc3 --- /dev/null +++ b/test/features/inventory/transactions/data/transaction_line_repository_remote_test.dart @@ -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 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>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + 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>( + captureAny(), + data: captureAny(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured[1] + as Map; + + expect(payload['id'], 50); + expect(payload['lines'], isA()); + }); + + test('updateLines는 PATCH 요청을 사용한다', () async { + const path = '/api/v1/stock-transactions/50/lines'; + when( + () => apiClient.patch>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: lineResponse(), + requestOptions: RequestOptions(path: path), + statusCode: 200, + ), + ); + + await repository.updateLines(50, [ + TransactionLineUpdateInput(id: 101, quantity: 10), + ]); + + verify( + () => apiClient.patch>( + 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( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: path), + statusCode: 204, + ), + ); + + await repository.deleteLine(101); + + verify( + () => apiClient.delete( + 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>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + 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); + }); +} diff --git a/test/features/inventory/transactions/presentation/transaction_detail_sync_service_test.dart b/test/features/inventory/transactions/presentation/transaction_detail_sync_service_test.dart new file mode 100644 index 0000000..03d22cf --- /dev/null +++ b/test/features/inventory/transactions/presentation/transaction_detail_sync_service_test.dart @@ -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([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([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); + }); + }); +}