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

View File

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

View File

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

View File

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