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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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