- 환경/라우터 모듈에 approval_flow_v2 토글을 추가하고 FeatureFlags 초기화를 연결 (.env*, lib/core/**) - ApiClient 빌더·ApiRoutes 확장과 ApprovalRepositoryRemote 리팩터링으로 include·액션 시그니처를 정합화 - ApprovalFlow·ApprovalDraft 엔티티/레포/유즈케이스를 도입해 서버 초안과 단계 액션(승인·회수·재상신)을 지원 - Approval 컨트롤러·히스토리·템플릿 페이지와 공유 위젯을 재작성해 감사 로그·회수 UX·템플릿 CRUD를 반영 - Inbound/Outbound/Rental 컨트롤러·페이지에 결재 섹션을 삽입하고 대시보드 pending 카드 요약을 갱신 - SuperportDialog·FormField 등 공통 위젯을 보강하고 승인 위젯 가이드를 추가해 UI 가이드를 정리 - 결재/재고 테스트 픽스처와 단위·위젯·통합 테스트를 확장하고 flutter_test_config로 스테이징 호스트를 허용 - Approval Flow 레포트/플랜 문서를 업데이트하고 ApprovalFlow_System_Integration_and_ChangePlan.md를 추가 - 실행: flutter analyze, flutter test
348 lines
11 KiB
Dart
348 lines
11 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:integration_test/integration_test.dart';
|
|
|
|
import 'package:superport_v2/core/common/models/paginated_result.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.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';
|
|
|
|
void main() {
|
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
const baseUrl = String.fromEnvironment('STAGING_API_BASE_URL');
|
|
const token = String.fromEnvironment('STAGING_API_TOKEN');
|
|
const runFlow = bool.fromEnvironment('STAGING_RUN_TRANSACTION_FLOW');
|
|
const useFakeFlow = bool.fromEnvironment('STAGING_USE_FAKE_FLOW');
|
|
|
|
const transactionTypeId = int.fromEnvironment(
|
|
'STAGING_TRANSACTION_TYPE_ID',
|
|
defaultValue: 0,
|
|
);
|
|
const transactionStatusId = int.fromEnvironment(
|
|
'STAGING_TRANSACTION_STATUS_ID',
|
|
defaultValue: 0,
|
|
);
|
|
const warehouseId = int.fromEnvironment(
|
|
'STAGING_WAREHOUSE_ID',
|
|
defaultValue: 0,
|
|
);
|
|
const employeeId = int.fromEnvironment(
|
|
'STAGING_EMPLOYEE_ID',
|
|
defaultValue: 0,
|
|
);
|
|
const productId = int.fromEnvironment('STAGING_PRODUCT_ID', defaultValue: 0);
|
|
const customerId = int.fromEnvironment(
|
|
'STAGING_CUSTOMER_ID',
|
|
defaultValue: 0,
|
|
);
|
|
|
|
final missingConfigs = <String>[];
|
|
if (!runFlow) {
|
|
missingConfigs.add('STAGING_RUN_TRANSACTION_FLOW=true');
|
|
}
|
|
|
|
if (!useFakeFlow) {
|
|
if (baseUrl.isEmpty) {
|
|
missingConfigs.add('STAGING_API_BASE_URL');
|
|
}
|
|
if (token.isEmpty) {
|
|
missingConfigs.add('STAGING_API_TOKEN');
|
|
}
|
|
if (transactionTypeId == 0) {
|
|
missingConfigs.add('STAGING_TRANSACTION_TYPE_ID');
|
|
}
|
|
if (transactionStatusId == 0) {
|
|
missingConfigs.add('STAGING_TRANSACTION_STATUS_ID');
|
|
}
|
|
if (warehouseId == 0) {
|
|
missingConfigs.add('STAGING_WAREHOUSE_ID');
|
|
}
|
|
if (employeeId == 0) {
|
|
missingConfigs.add('STAGING_EMPLOYEE_ID');
|
|
}
|
|
if (productId == 0) {
|
|
missingConfigs.add('STAGING_PRODUCT_ID');
|
|
}
|
|
if (customerId == 0) {
|
|
missingConfigs.add('STAGING_CUSTOMER_ID');
|
|
}
|
|
}
|
|
|
|
if (missingConfigs.isNotEmpty) {
|
|
final reason = '환경 변수를 설정하세요: ${missingConfigs.join(', ')}';
|
|
testWidgets(
|
|
'staging transaction flow (환경 변수 설정 필요: STAGING_RUN_TRANSACTION_FLOW 및 API 식별자)',
|
|
(tester) async {
|
|
tester.printToConsole('통합 테스트를 실행하려면 다음 값을 설정하세요.\n$reason');
|
|
},
|
|
skip: true,
|
|
);
|
|
return;
|
|
}
|
|
|
|
testWidgets('stock transaction end-to-end flow succeeds', (tester) async {
|
|
final resolvedTransactionTypeId = transactionTypeId == 0
|
|
? 100
|
|
: transactionTypeId;
|
|
final resolvedTransactionStatusId = transactionStatusId == 0
|
|
? 10
|
|
: transactionStatusId;
|
|
final resolvedWarehouseId = warehouseId == 0 ? 1 : warehouseId;
|
|
final resolvedEmployeeId = employeeId == 0 ? 1 : employeeId;
|
|
final resolvedProductId = productId == 0 ? 1 : productId;
|
|
final resolvedCustomerId = customerId == 0 ? 1 : customerId;
|
|
|
|
late final StockTransactionRepository repository;
|
|
if (useFakeFlow) {
|
|
repository = _FakeStockTransactionRepository(
|
|
transactionTypeId: resolvedTransactionTypeId,
|
|
initialStatusId: resolvedTransactionStatusId,
|
|
warehouseId: resolvedWarehouseId,
|
|
employeeId: resolvedEmployeeId,
|
|
productId: resolvedProductId,
|
|
customerId: resolvedCustomerId,
|
|
);
|
|
} else {
|
|
final dio = Dio(
|
|
BaseOptions(
|
|
baseUrl: baseUrl,
|
|
headers: {
|
|
'Authorization': 'Bearer $token',
|
|
'Accept': 'application/json',
|
|
},
|
|
),
|
|
);
|
|
final apiClient = ApiClient(dio: dio);
|
|
repository = StockTransactionRepositoryRemote(apiClient: apiClient);
|
|
}
|
|
|
|
final now = DateTime.now();
|
|
|
|
final createInput = StockTransactionCreateInput(
|
|
transactionTypeId: resolvedTransactionTypeId,
|
|
transactionStatusId: resolvedTransactionStatusId,
|
|
warehouseId: resolvedWarehouseId,
|
|
transactionDate: now,
|
|
createdById: resolvedEmployeeId,
|
|
note: 'integration-test ${now.toIso8601String()}',
|
|
lines: [
|
|
TransactionLineCreateInput(
|
|
lineNo: 1,
|
|
productId: resolvedProductId,
|
|
quantity: 1,
|
|
unitPrice: 1,
|
|
),
|
|
],
|
|
customers: [
|
|
TransactionCustomerCreateInput(customerId: resolvedCustomerId),
|
|
],
|
|
approval: StockTransactionApprovalInput(
|
|
requestedById: resolvedEmployeeId,
|
|
),
|
|
);
|
|
|
|
final created = await repository.create(createInput);
|
|
expect(created.id, isNotNull);
|
|
tester.printToConsole('created transaction: ${created.id}');
|
|
|
|
// 상태 전이: submit -> cancel 순으로 흐름 검증 (승인 루프는 환경에 따라 조정 필요).
|
|
final submitted = await repository.submit(created.id!);
|
|
expect(submitted.id, equals(created.id));
|
|
tester.printToConsole('submitted transaction: ${submitted.id}');
|
|
|
|
final cancelled = await repository.cancel(created.id!);
|
|
expect(cancelled.id, equals(created.id));
|
|
tester.printToConsole('cancelled transaction: ${cancelled.id}');
|
|
|
|
// 테스트 데이터 정리.
|
|
await repository.delete(created.id!);
|
|
tester.printToConsole('deleted transaction: ${created.id}');
|
|
});
|
|
}
|
|
|
|
class _FakeStockTransactionRepository implements StockTransactionRepository {
|
|
_FakeStockTransactionRepository({
|
|
required this.transactionTypeId,
|
|
required this.initialStatusId,
|
|
required this.warehouseId,
|
|
required this.employeeId,
|
|
required this.productId,
|
|
required this.customerId,
|
|
});
|
|
|
|
final int transactionTypeId;
|
|
final int initialStatusId;
|
|
final int warehouseId;
|
|
final int employeeId;
|
|
final int productId;
|
|
final int customerId;
|
|
|
|
int _sequence = 1;
|
|
final Map<int, StockTransaction> _transactions = {};
|
|
|
|
@override
|
|
Future<StockTransaction> create(StockTransactionCreateInput input) async {
|
|
final id = _sequence++;
|
|
final generatedNo = 'FAKE-${id.toString().padLeft(6, '0')}';
|
|
final transaction = StockTransaction(
|
|
id: id,
|
|
transactionNo: generatedNo,
|
|
transactionDate: input.transactionDate,
|
|
type: StockTransactionType(id: input.transactionTypeId, name: '테스트 트랜잭션'),
|
|
status: StockTransactionStatus(id: initialStatusId, name: '작성중'),
|
|
warehouse: StockTransactionWarehouse(
|
|
id: warehouseId,
|
|
code: 'WH-$warehouseId',
|
|
name: '테스트 창고',
|
|
),
|
|
createdBy: StockTransactionEmployee(
|
|
id: employeeId,
|
|
employeeNo: 'EMP-$employeeId',
|
|
name: '통합 테스트 사용자',
|
|
),
|
|
note: input.note,
|
|
isActive: true,
|
|
lines: input.lines
|
|
.map(
|
|
(line) => StockTransactionLine(
|
|
id: line.lineNo,
|
|
lineNo: line.lineNo,
|
|
product: StockTransactionProduct(
|
|
id: line.productId,
|
|
code: 'P-${line.productId}',
|
|
name: '테스트 상품',
|
|
),
|
|
quantity: line.quantity,
|
|
unitPrice: line.unitPrice,
|
|
note: line.note,
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
customers: input.customers
|
|
.map(
|
|
(customer) => StockTransactionCustomer(
|
|
id: customer.customerId,
|
|
customer: StockTransactionCustomerSummary(
|
|
id: customer.customerId,
|
|
code: 'C-${customer.customerId}',
|
|
name: '테스트 고객',
|
|
),
|
|
note: customer.note,
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
expectedReturnDate: input.expectedReturnDate,
|
|
);
|
|
_transactions[id] = transaction;
|
|
return transaction;
|
|
}
|
|
|
|
@override
|
|
Future<StockTransaction> submit(int id) async {
|
|
return _updateStatus(
|
|
id,
|
|
StockTransactionStatus(id: initialStatusId + 1, name: '승인대기'),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<StockTransaction> complete(int id) async {
|
|
return _updateStatus(
|
|
id,
|
|
StockTransactionStatus(id: initialStatusId + 2, name: '완료'),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<StockTransaction> approve(int id) async {
|
|
return _updateStatus(
|
|
id,
|
|
StockTransactionStatus(id: initialStatusId + 3, name: '승인완료'),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<StockTransaction> reject(int id) async {
|
|
return _updateStatus(
|
|
id,
|
|
StockTransactionStatus(id: initialStatusId + 4, name: '반려'),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<StockTransaction> cancel(int id) async {
|
|
return _updateStatus(
|
|
id,
|
|
StockTransactionStatus(id: initialStatusId + 5, name: '취소'),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> delete(int id) async {
|
|
_transactions.remove(id);
|
|
}
|
|
|
|
@override
|
|
Future<StockTransaction> restore(int id) async {
|
|
return _require(id);
|
|
}
|
|
|
|
@override
|
|
Future<StockTransaction> update(
|
|
int id,
|
|
StockTransactionUpdateInput input,
|
|
) async {
|
|
final current = _require(id);
|
|
final updated = current.copyWith(
|
|
status: StockTransactionStatus(
|
|
id: input.transactionStatusId,
|
|
name: '상태${input.transactionStatusId}',
|
|
),
|
|
note: input.note ?? current.note,
|
|
expectedReturnDate:
|
|
input.expectedReturnDate ?? current.expectedReturnDate,
|
|
);
|
|
_transactions[id] = updated;
|
|
return updated;
|
|
}
|
|
|
|
@override
|
|
Future<StockTransaction> fetchDetail(
|
|
int id, {
|
|
List<String> include = const ['lines', 'customers', 'approval'],
|
|
}) async {
|
|
return _require(id);
|
|
}
|
|
|
|
@override
|
|
Future<PaginatedResult<StockTransaction>> list({
|
|
StockTransactionListFilter? filter,
|
|
}) async {
|
|
final items = _transactions.values.toList(growable: false);
|
|
return PaginatedResult<StockTransaction>(
|
|
items: items,
|
|
page: filter?.page ?? 1,
|
|
pageSize: filter?.pageSize ?? items.length,
|
|
total: items.length,
|
|
);
|
|
}
|
|
|
|
StockTransaction _updateStatus(int id, StockTransactionStatus status) {
|
|
final current = _require(id);
|
|
final updated = current.copyWith(status: status);
|
|
_transactions[id] = updated;
|
|
return updated;
|
|
}
|
|
|
|
StockTransaction _require(int id) {
|
|
final transaction = _transactions[id];
|
|
if (transaction == null) {
|
|
throw StateError('Transaction $id not found');
|
|
}
|
|
return transaction;
|
|
}
|
|
}
|