diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a67fa39 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# 변경 기록 + +## 2025-10-20 +- 재고 입·출·대여 컨트롤러가 `Failure.describe()` 기반으로 오류를 노출해 승인/취소 흐름에서 서버 메시지가 그대로 전달됩니다. +- 우편번호 검색 다이얼로그와 창고 선택 위젯이 API 예외를 상세히 표기하며, 관련 위젯 테스트를 추가했습니다. +- 승인/재고 플로우 주요 컨트롤러 단위 테스트에 실패 메시지 검증을 포함해 회귀를 방지합니다. + +## 2025-10-18 +- 재고 트랜잭션 목록/상세/상태 전이를 실 API(`StockTransactionRepositoryRemote`)에 연결하고 보고서 다운로드 UX를 개선했습니다. +- 결재 도메인에서 `canProceed` API 검증과 기능 플래그 기본값 조정으로 사용성 이슈를 해소했습니다. diff --git a/doc/qa/staging_transaction_flow.md b/doc/qa/staging_transaction_flow.md new file mode 100644 index 0000000..c711a24 --- /dev/null +++ b/doc/qa/staging_transaction_flow.md @@ -0,0 +1,50 @@ +# 스테이징 재고 플로우 검증 시나리오 + +## 전제 조건 +- 스테이징 API 서버가 기동되어 있고, 테스트용 토큰이 발급되어 있어야 합니다. +- 아래 환경 변수에 대응하는 실 ID가 존재해야 합니다. + - `STAGING_API_BASE_URL` + - `STAGING_API_TOKEN` + - `STAGING_TRANSACTION_TYPE_ID` + - `STAGING_TRANSACTION_STATUS_ID` + - `STAGING_WAREHOUSE_ID` + - `STAGING_EMPLOYEE_ID` + - `STAGING_PRODUCT_ID` + - `STAGING_CUSTOMER_ID` +- 임시 데이터 생성/삭제가 허용된 스테이징 계정을 사용하세요. + +## 테스트 데이터 구성 +- 스테이징 콘솔 또는 API를 사용해 전용 QA 데이터를 사전에 확보합니다. + 1. `/api/v1/warehouses`, `/api/v1/employees`, `/api/v1/products`, `/api/v1/customers` 목록을 조회해 테스트에 사용할 고정 ID를 선택하거나 새 레코드를 생성합니다. + 2. `/api/v1/transaction-types`와 `/api/v1/transaction-statuses`에서 submit/approve/complete 플로우를 지원하는 타입·상태 조합을 확인합니다. + 3. `/api/v1/stock-transactions`에 임시 트랜잭션을 생성해 라인/고객/승인 데이터가 정상 저장되는지 점검한 뒤, 성공한 요청 본문을 재사용 가능한 fixture로 저장합니다. + 4. 테스트 종료 후 `/api/v1/stock-transactions/{id}` DELETE 요청으로 데이터를 정리해 다른 시나리오에 영향을 주지 않도록 합니다. +- 각 ID는 `.env.staging` 또는 CI 시크릿에 명시해 실행 시점에 자동으로 주입합니다. + +## 자동화 테스트 실행 방법 +1. 루트에서 다음 명령으로 환경 변수를 전달합니다. + ```bash + flutter test integration_test/stock_transaction_state_flow_test.dart \ + --dart-define=STAGING_RUN_TRANSACTION_FLOW=true \ + --dart-define=STAGING_API_BASE_URL=https://staging.superport.example \ + --dart-define=STAGING_API_TOKEN= \ + --dart-define=STAGING_TRANSACTION_TYPE_ID=1 \ + --dart-define=STAGING_TRANSACTION_STATUS_ID=1 \ + --dart-define=STAGING_WAREHOUSE_ID=3 \ + --dart-define=STAGING_EMPLOYEE_ID=5 \ + --dart-define=STAGING_PRODUCT_ID=11 \ + --dart-define=STAGING_CUSTOMER_ID=7 + ``` +2. 테스트는 다음 흐름을 검증합니다. + - 재고 트랜잭션 생성 → 상신(submit) → 취소(cancel) → 삭제(delete) + - 실패 시 HTTP 응답을 출력하므로, 로그를 토대로 API 상태를 점검합니다. + +## 수동 검증 체크리스트 +1. **생성**: 입고/출고/대여 중 하나를 생성하고, 작성자·창고·라인·고객 정보가 정확히 저장되는지 확인합니다. +2. **상태 전이**: 작성중 → 상신 → 승인/완료 → 취소 순으로 버튼이 활성화되는지와 API 응답 코드를 확인합니다. +3. **보고서 다운로드**: 동일한 조건으로 보고서를 다운로드하여 URL/파일이 정상 동작하는지 브라우저에서 재확인합니다. +4. **정리**: 테스트용 데이터는 취소 후 삭제하여 스테이징 목록을 정리합니다. + +## 장애 대응 메모 +- 4xx 발생 시 API 로그와 `feature_flag` 값을 우선 확인합니다. +- 5xx 또는 타임아웃은 백엔드 담당자에게 API 경로와 요청 페이로드를 공유합니다. diff --git a/integration_test/stock_transaction_state_flow_test.dart b/integration_test/stock_transaction_state_flow_test.dart new file mode 100644 index 0000000..d616069 --- /dev/null +++ b/integration_test/stock_transaction_state_flow_test.dart @@ -0,0 +1,344 @@ +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 = []; + 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), + ], + ); + + 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 _transactions = {}; + + @override + Future create(StockTransactionCreateInput input) async { + final id = _sequence++; + final transaction = StockTransaction( + id: id, + transactionNo: + input.transactionNo ?? 'FAKE-${id.toString().padLeft(6, '0')}', + 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 submit(int id) async { + return _updateStatus( + id, + StockTransactionStatus(id: initialStatusId + 1, name: '승인대기'), + ); + } + + @override + Future complete(int id) async { + return _updateStatus( + id, + StockTransactionStatus(id: initialStatusId + 2, name: '완료'), + ); + } + + @override + Future approve(int id) async { + return _updateStatus( + id, + StockTransactionStatus(id: initialStatusId + 3, name: '승인완료'), + ); + } + + @override + Future reject(int id) async { + return _updateStatus( + id, + StockTransactionStatus(id: initialStatusId + 4, name: '반려'), + ); + } + + @override + Future cancel(int id) async { + return _updateStatus( + id, + StockTransactionStatus(id: initialStatusId + 5, name: '취소'), + ); + } + + @override + Future delete(int id) async { + _transactions.remove(id); + } + + @override + Future restore(int id) async { + return _require(id); + } + + @override + Future 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 fetchDetail( + int id, { + List include = const ['lines', 'customers', 'approval'], + }) async { + return _require(id); + } + + @override + Future> list({ + StockTransactionListFilter? filter, + }) async { + final items = _transactions.values.toList(growable: false); + return PaginatedResult( + 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; + } +} diff --git a/lib/core/network/api_routes.dart b/lib/core/network/api_routes.dart new file mode 100644 index 0000000..9779760 --- /dev/null +++ b/lib/core/network/api_routes.dart @@ -0,0 +1,8 @@ +/// API 경로 상수 모음 +/// - 버전 prefix 등을 중앙에서 관리해 중복을 방지한다. +class ApiRoutes { + const ApiRoutes._(); + + /// API v1 prefix + static const apiV1 = '/api/v1'; +} diff --git a/lib/core/permissions/permission_resources.dart b/lib/core/permissions/permission_resources.dart new file mode 100644 index 0000000..7f4cdbb --- /dev/null +++ b/lib/core/permissions/permission_resources.dart @@ -0,0 +1,116 @@ +/// 권한 검사에 사용하는 리소스 경로 상수를 정의하고 정규화 유틸을 제공한다. +/// +/// - UI 라우트 경로(`/inventory/inbound` 등)를 서버 표준 경로(`/stock-transactions`) +/// 로 변환해 [PermissionManager]와 환경 설정이 동일한 키를 사용하도록 맞춘다. +class PermissionResources { + const PermissionResources._(); + + static const String dashboard = '/dashboard'; + static const String stockTransactions = '/stock-transactions'; + static const String approvals = '/approvals'; + static const String approvalSteps = '/approval-steps'; + static const String approvalHistories = '/approval-histories'; + static const String approvalTemplates = '/approval-templates'; + static const String groupMenuPermissions = '/group-menu-permissions'; + static const String vendors = '/vendors'; + static const String products = '/products'; + static const String warehouses = '/warehouses'; + static const String customers = '/customers'; + static const String users = '/users'; + static const String groups = '/groups'; + static const String menus = '/menus'; + static const String postalSearch = '/zipcodes'; + static const String reports = '/reports'; + static const String reportsTransactions = '/reports/transactions'; + static const String reportsApprovals = '/reports/approvals'; + + /// 라우트/엔드포인트 별칭을 표준 경로로 매핑한 테이블. + static const Map _aliases = { + '/dashboard': dashboard, + '/inventory': stockTransactions, + '/inventory/inbound': stockTransactions, + '/inventory/outbound': stockTransactions, + '/inventory/rental': stockTransactions, + '/approvals/requests': approvals, + '/approvals': approvals, + '/approvals/steps': approvalSteps, + '/approval-steps': approvalSteps, + '/approvals/history': approvalHistories, + '/approvals/histories': approvalHistories, + '/approval-histories': approvalHistories, + '/approvals/templates': approvalTemplates, + '/approval-templates': approvalTemplates, + '/masters/group-permissions': groupMenuPermissions, + '/group-menu-permissions': groupMenuPermissions, + '/masters/vendors': vendors, + '/vendors': vendors, + '/masters/products': products, + '/products': products, + '/masters/warehouses': warehouses, + '/warehouses': warehouses, + '/masters/customers': customers, + '/customers': customers, + '/masters/users': users, + '/users': users, + '/masters/groups': groups, + '/groups': groups, + '/masters/menus': menus, + '/menus': menus, + '/utilities/postal-search': postalSearch, + '/zipcodes': postalSearch, + '/reports': reports, + '/reports/transactions': reportsTransactions, + '/reports/transactions/export': reportsTransactions, + '/reports/approvals': reportsApprovals, + '/reports/approvals/export': reportsApprovals, + }; + + /// 주어진 [resource] 문자열을 서버 표준 경로로 정규화한다. + /// + /// - 앞뒤 공백 및 대소문자를 정리한다. + /// - 맵에 등록된 라우트/별칭을 모두 표준 경로로 통일한다. + static String normalize(String resource) { + final sanitized = _sanitize(resource); + if (sanitized.isEmpty) { + return sanitized; + } + return _aliases[sanitized] ?? sanitized; + } + + static String _sanitize(String resource) { + final trimmed = resource.trim(); + if (trimmed.isEmpty) { + return ''; + } + var lowered = trimmed.toLowerCase(); + + // 절대 URL이 들어오면 path 부분만 추출한다. + final uri = Uri.tryParse(lowered); + if (uri != null && uri.hasScheme) { + lowered = uri.path; + } + + // 쿼리스트링이나 프래그먼트를 제거해 순수 경로만 남긴다. + final queryIndex = lowered.indexOf('?'); + if (queryIndex != -1) { + lowered = lowered.substring(0, queryIndex); + } + final hashIndex = lowered.indexOf('#'); + if (hashIndex != -1) { + lowered = lowered.substring(0, hashIndex); + } + + if (!lowered.startsWith('/')) { + lowered = '/$lowered'; + } + + while (lowered.contains('//')) { + lowered = lowered.replaceAll('//', '/'); + } + + if (lowered.length > 1 && lowered.endsWith('/')) { + lowered = lowered.substring(0, lowered.length - 1); + } + return lowered; + } +} diff --git a/lib/features/reporting/data/repositories/reporting_repository_remote.dart b/lib/features/reporting/data/repositories/reporting_repository_remote.dart new file mode 100644 index 0000000..2f52bad --- /dev/null +++ b/lib/features/reporting/data/repositories/reporting_repository_remote.dart @@ -0,0 +1,140 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; + +import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/core/network/api_routes.dart'; +import 'package:superport_v2/features/reporting/domain/entities/report_download_result.dart'; +import 'package:superport_v2/features/reporting/domain/entities/report_export_format.dart'; +import 'package:superport_v2/features/reporting/domain/entities/report_export_request.dart'; +import 'package:superport_v2/features/reporting/domain/repositories/reporting_repository.dart'; + +/// 보고서 다운로드 API를 호출하는 원격 리포지토리 구현체. +class ReportingRepositoryRemote implements ReportingRepository { + ReportingRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; + + final ApiClient _api; + + static const _transactionsPath = + '${ApiRoutes.apiV1}/reports/transactions/export'; + static const _approvalsPath = '${ApiRoutes.apiV1}/reports/approvals/export'; + + @override + Future exportTransactions( + ReportExportRequest request, + ) async { + final response = await _api.get( + _transactionsPath, + query: _buildQuery(request), + options: Options(responseType: ResponseType.bytes), + ); + return _mapResponse(response, format: request.format); + } + + @override + Future exportApprovals( + ReportExportRequest request, + ) async { + final response = await _api.get( + _approvalsPath, + query: _buildQuery(request), + options: Options(responseType: ResponseType.bytes), + ); + return _mapResponse(response, format: request.format); + } + + Map _buildQuery(ReportExportRequest request) { + return { + 'from': request.from.toIso8601String(), + 'to': request.to.toIso8601String(), + 'format': request.format.apiValue, + if (request.transactionTypeId != null) + 'type_id': request.transactionTypeId, + if (request.statusId != null) 'status_id': request.statusId, + if (request.warehouseId != null) 'warehouse_id': request.warehouseId, + }; + } + + ReportDownloadResult _mapResponse( + Response response, { + required ReportExportFormat format, + }) { + final contentType = response.headers.value('content-type') ?? ''; + final disposition = response.headers.value('content-disposition') ?? ''; + + if (contentType.contains('application/json')) { + final body = _decodeJson(response.data); + final map = body is Map ? body : {}; + final data = map['data'] is Map + ? map['data'] as Map + : map; + final url = _readString(data, 'download_url'); + return ReportDownloadResult( + downloadUrl: url == null ? null : Uri.tryParse(url), + filename: _readString(data, 'filename'), + mimeType: _readString(data, 'mime_type') ?? _defaultMimeType(format), + expiresAt: _parseDateTime(_readString(data, 'expires_at')), + ); + } + + final filename = _parseFilename(disposition) ?? 'report.${format.apiValue}'; + final bytes = response.data ?? Uint8List(0); + + return ReportDownloadResult( + bytes: bytes, + filename: filename, + mimeType: contentType.isEmpty ? _defaultMimeType(format) : contentType, + ); + } + + dynamic _decodeJson(Uint8List? bytes) { + if (bytes == null || bytes.isEmpty) { + return const {}; + } + try { + final decoded = utf8.decode(bytes); + return jsonDecode(decoded); + } catch (_) { + return const {}; + } + } + + String? _readString(Map map, String key) { + final value = map[key]; + if (value is String && value.trim().isNotEmpty) { + return value.trim(); + } + return null; + } + + DateTime? _parseDateTime(String? value) { + if (value == null || value.isEmpty) { + return null; + } + return DateTime.tryParse(value); + } + + String? _parseFilename(String disposition) { + if (disposition.isEmpty) { + return null; + } + final parts = disposition.split(';'); + for (final part in parts) { + final trimmed = part.trim(); + if (trimmed.toLowerCase().startsWith('filename=')) { + final encoded = trimmed.substring('filename='.length); + return encoded.replaceAll('"', ''); + } + } + return null; + } + + String _defaultMimeType(ReportExportFormat format) { + return switch (format) { + ReportExportFormat.xlsx => + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ReportExportFormat.pdf => 'application/pdf', + }; + } +} diff --git a/lib/features/reporting/domain/entities/report_download_result.dart b/lib/features/reporting/domain/entities/report_download_result.dart new file mode 100644 index 0000000..46da5bd --- /dev/null +++ b/lib/features/reporting/domain/entities/report_download_result.dart @@ -0,0 +1,33 @@ +import 'dart:typed_data'; + +/// 보고서 다운로드 요청 결과 모델. +class ReportDownloadResult { + const ReportDownloadResult({ + this.downloadUrl, + this.filename, + this.mimeType, + this.bytes, + this.expiresAt, + }); + + /// 사전 서명된 다운로드 URL. + final Uri? downloadUrl; + + /// 서버가 제안한 파일명. + final String? filename; + + /// 응답 콘텐츠 타입. + final String? mimeType; + + /// 서버가 직접 전송한 파일 데이터(옵션). + final Uint8List? bytes; + + /// 다운로드 링크 또는 토큰의 만료 시각. + final DateTime? expiresAt; + + /// URL 기반 다운로드가 가능한지 여부. + bool get hasDownloadUrl => downloadUrl != null; + + /// 바이너리 데이터가 포함되어 있는지 여부. + bool get hasBytes => bytes != null && bytes!.isNotEmpty; +} diff --git a/lib/features/reporting/domain/entities/report_export_format.dart b/lib/features/reporting/domain/entities/report_export_format.dart new file mode 100644 index 0000000..f4fbf96 --- /dev/null +++ b/lib/features/reporting/domain/entities/report_export_format.dart @@ -0,0 +1,16 @@ +/// 보고서 내보내기 파일 형식. +enum ReportExportFormat { xlsx, pdf } + +extension ReportExportFormatX on ReportExportFormat { + /// API 요청에 전달되는 문자열 포맷. + String get apiValue => switch (this) { + ReportExportFormat.xlsx => 'xlsx', + ReportExportFormat.pdf => 'pdf', + }; + + /// 사용자에게 노출되는 레이블. + String get label => switch (this) { + ReportExportFormat.xlsx => 'XLSX', + ReportExportFormat.pdf => 'PDF', + }; +} diff --git a/lib/features/reporting/domain/entities/report_export_request.dart b/lib/features/reporting/domain/entities/report_export_request.dart new file mode 100644 index 0000000..192d529 --- /dev/null +++ b/lib/features/reporting/domain/entities/report_export_request.dart @@ -0,0 +1,31 @@ +import 'package:superport_v2/features/reporting/domain/entities/report_export_format.dart'; + +/// 보고서 내보내기 요청을 표현하는 모델. +class ReportExportRequest { + const ReportExportRequest({ + required this.from, + required this.to, + required this.format, + this.transactionTypeId, + this.statusId, + this.warehouseId, + }); + + /// 조회 시작 일자. + final DateTime from; + + /// 조회 종료 일자. + final DateTime to; + + /// 내보내기 파일 형식. + final ReportExportFormat format; + + /// 재고 트랜잭션 유형 식별자. + final int? transactionTypeId; + + /// 결재 상태 식별자. + final int? statusId; + + /// 창고 식별자. + final int? warehouseId; +} diff --git a/lib/features/reporting/domain/repositories/reporting_repository.dart b/lib/features/reporting/domain/repositories/reporting_repository.dart new file mode 100644 index 0000000..941be64 --- /dev/null +++ b/lib/features/reporting/domain/repositories/reporting_repository.dart @@ -0,0 +1,11 @@ +import 'package:superport_v2/features/reporting/domain/entities/report_download_result.dart'; +import 'package:superport_v2/features/reporting/domain/entities/report_export_request.dart'; + +/// 보고서 다운로드 관련 리포지토리 계약. +abstract class ReportingRepository { + /// 재고 트랜잭션 보고서를 내보낸다. + Future exportTransactions(ReportExportRequest request); + + /// 결재 보고서를 내보낸다. + Future exportApprovals(ReportExportRequest request); +} diff --git a/lib/injection_container.dart b/lib/injection_container.dart index aef6130..1622683 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -7,6 +7,12 @@ import 'core/network/api_client.dart'; import 'core/network/api_error.dart'; import 'core/network/interceptors/auth_interceptor.dart'; import 'core/services/token_storage.dart'; +import 'features/inventory/lookups/data/repositories/inventory_lookup_repository_remote.dart'; +import 'features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; +import 'features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart'; +import 'features/inventory/transactions/data/repositories/transaction_customer_repository_remote.dart'; +import 'features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart'; +import 'features/inventory/transactions/domain/repositories/stock_transaction_repository.dart'; import 'features/masters/customer/data/repositories/customer_repository_remote.dart'; import 'features/masters/customer/domain/repositories/customer_repository.dart'; import 'features/masters/group/data/repositories/group_repository_remote.dart'; @@ -35,6 +41,8 @@ import 'features/approvals/domain/repositories/approval_template_repository.dart import 'features/approvals/step/domain/repositories/approval_step_repository.dart'; import 'features/util/postal_search/data/repositories/postal_search_repository_remote.dart'; import 'features/util/postal_search/domain/repositories/postal_search_repository.dart'; +import 'features/reporting/data/repositories/reporting_repository_remote.dart'; +import 'features/reporting/domain/repositories/reporting_repository.dart'; /// 전역 DI 컨테이너 final GetIt sl = GetIt.instance; @@ -128,4 +136,24 @@ Future initInjection({ sl.registerLazySingleton( () => PostalSearchRepositoryRemote(apiClient: sl()), ); + + sl.registerLazySingleton( + () => InventoryLookupRepositoryRemote(apiClient: sl()), + ); + + sl.registerLazySingleton( + () => StockTransactionRepositoryRemote(apiClient: sl()), + ); + + sl.registerLazySingleton( + () => TransactionLineRepositoryRemote(apiClient: sl()), + ); + + sl.registerLazySingleton( + () => TransactionCustomerRepositoryRemote(apiClient: sl()), + ); + + sl.registerLazySingleton( + () => ReportingRepositoryRemote(apiClient: sl()), + ); } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d0e7f79..38dd0bc 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index b29e9ba..65240e9 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 15a1671..5efd5bd 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,8 +7,10 @@ import Foundation import flutter_secure_storage_macos import path_provider_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..f84fab2 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,35 @@ +PODS: + - flutter_secure_storage_macos (6.1.3): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +EXTERNAL SOURCES: + flutter_secure_storage_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos + FlutterMacOS: + :path: Flutter/ephemeral + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + flutter_secure_storage_macos: c2754d3483d20bb207bb9e5a14f1b8e771abcdb9 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 3f2935b..d958cb5 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 37F758093A1AEA5BAF00D85B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A32A030F236AD9E49D10327 /* Pods_Runner.framework */; }; + 7F56BCAE27A8EA167C62800B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0BBED327CEE1D6DD142492EF /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0BBED327CEE1D6DD142492EF /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* superport_v2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "superport_v2.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* superport_v2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = superport_v2.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +79,15 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 49B67878BF0F3BF4B151E4B6 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 5A32A030F236AD9E49D10327 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 71D1BE2DCCBC4CD5B4A77168 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + B2EA1058A08FF313CABCAAFD /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + DB7F01D88D151324C95DADE7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + F727904914C33F5A486241E6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + FD86D9C1321338144FC65054 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 7F56BCAE27A8EA167C62800B /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 37F758093A1AEA5BAF00D85B /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + FAF81764370EF4E216256F29 /* Pods */, ); sourceTree = ""; }; @@ -175,10 +188,26 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 5A32A030F236AD9E49D10327 /* Pods_Runner.framework */, + 0BBED327CEE1D6DD142492EF /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; }; + FAF81764370EF4E216256F29 /* Pods */ = { + isa = PBXGroup; + children = ( + FD86D9C1321338144FC65054 /* Pods-Runner.debug.xcconfig */, + B2EA1058A08FF313CABCAAFD /* Pods-Runner.release.xcconfig */, + F727904914C33F5A486241E6 /* Pods-Runner.profile.xcconfig */, + 49B67878BF0F3BF4B151E4B6 /* Pods-RunnerTests.debug.xcconfig */, + DB7F01D88D151324C95DADE7 /* Pods-RunnerTests.release.xcconfig */, + 71D1BE2DCCBC4CD5B4A77168 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 039842304FE99306B814507F /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 645522EC9C304EC96AC71CCD /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 69C58EF3926BE525BD73F111 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -291,6 +323,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 039842304FE99306B814507F /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -329,6 +383,45 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 645522EC9C304EC96AC71CCD /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 69C58EF3926BE525BD73F111 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 49B67878BF0F3BF4B151E4B6 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = DB7F01D88D151324C95DADE7 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 71D1BE2DCCBC4CD5B4A77168 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/pubspec.lock b/pubspec.lock index b588e03..0cfb467 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -121,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -142,6 +150,11 @@ packages: url: "https://pub.dev" source: hosted version: "5.2.1" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -229,6 +242,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" get_it: dependency: "direct main" description: @@ -269,6 +287,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -469,6 +492,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" shadcn_ui: dependency: "direct main" description: @@ -514,6 +545,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -554,6 +593,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.11" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9" + url: "https://pub.dev" + source: hosted + version: "6.3.24" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + url: "https://pub.dev" + source: hosted + version: "6.3.4" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" vector_graphics: dependency: transitive description: @@ -602,6 +705,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fbc4a14..4fa4365 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,11 +45,14 @@ dependencies: get_it: ^7.7.0 flutter_dotenv: ^5.1.0 flutter_secure_storage: ^9.2.2 + url_launcher: ^6.3.0 dev_dependencies: flutter_test: sdk: flutter pretty_dio_logger: ^1.3.1 + integration_test: + sdk: flutter # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is diff --git a/test/core/network/api_client_test.dart b/test/core/network/api_client_test.dart new file mode 100644 index 0000000..197e62e --- /dev/null +++ b/test/core/network/api_client_test.dart @@ -0,0 +1,97 @@ +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/core/network/api_error.dart'; + +class _MockDio extends Mock implements Dio {} + +class _MockApiErrorMapper extends Mock implements ApiErrorMapper {} + +void main() { + setUpAll(() { + registerFallbackValue(RequestOptions(path: '/fallback')); + registerFallbackValue(Options()); + registerFallbackValue(CancelToken()); + }); + + group('ApiClient', () { + late Dio dio; + late ApiErrorMapper mapper; + late ApiClient client; + + setUp(() { + dio = _MockDio(); + mapper = _MockApiErrorMapper(); + client = ApiClient(dio: dio, errorMapper: mapper); + }); + + test('성공 응답을 반환한다', () async { + final requestOptions = RequestOptions(path: '/vendors'); + final response = Response>( + data: {'data': []}, + statusCode: 200, + requestOptions: requestOptions, + ); + + when( + () => dio.get>( + any(), + queryParameters: any(named: 'queryParameters'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer((_) async => response); + + final result = await client.get>('/vendors'); + + expect(result, same(response)); + verify( + () => dio.get>( + any(), + queryParameters: any(named: 'queryParameters'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).called(1); + }); + + test('DioException을 ApiException으로 매핑한다', () async { + final requestOptions = RequestOptions(path: '/vendors'); + final dioError = DioException( + requestOptions: requestOptions, + type: DioExceptionType.badResponse, + response: Response( + requestOptions: requestOptions, + statusCode: 400, + data: {'message': '잘못된 요청입니다.'}, + ), + ); + final expected = ApiException( + code: ApiErrorCode.badRequest, + message: '잘못된 요청입니다.', + statusCode: 400, + cause: dioError, + ); + + when( + () => dio.get>( + any(), + queryParameters: any(named: 'queryParameters'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenThrow(dioError); + + when(() => mapper.map(dioError)).thenReturn(expected); + + expect( + () => client.get>('/vendors'), + throwsA(same(expected)), + ); + + verify(() => mapper.map(dioError)).called(1); + }); + }); +} diff --git a/test/core/network/auth_interceptor_test.dart b/test/core/network/auth_interceptor_test.dart new file mode 100644 index 0000000..6f4e4ac --- /dev/null +++ b/test/core/network/auth_interceptor_test.dart @@ -0,0 +1,122 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/network/interceptors/auth_interceptor.dart'; +import 'package:superport_v2/core/services/token_storage.dart'; + +class _MockTokenStorage extends Mock implements TokenStorage {} + +class _MockDio extends Mock implements Dio {} + +class _CapturingErrorHandler extends ErrorInterceptorHandler { + Response? resolved; + DioException? forwarded; + + @override + void resolve(Response response) { + resolved = response; + } + + @override + void next(DioException err) { + forwarded = err; + } +} + +void main() { + setUpAll(() { + registerFallbackValue(RequestOptions(path: '/fallback')); + }); + + group('AuthInterceptor', () { + late TokenStorage storage; + late Dio dio; + + setUp(() { + storage = _MockTokenStorage(); + dio = _MockDio(); + when(() => storage.writeAccessToken(any())).thenAnswer((_) async {}); + when(() => storage.writeRefreshToken(any())).thenAnswer((_) async {}); + when(() => storage.clear()).thenAnswer((_) async {}); + }); + + test('401 응답 시 토큰을 갱신하고 요청을 재시도한다', () async { + when( + () => storage.readAccessToken(), + ).thenAnswer((_) async => 'renewed-access'); + when(() => dio.fetch(any())).thenAnswer((invocation) async { + final options = invocation.positionalArguments.first as RequestOptions; + return Response( + requestOptions: options, + statusCode: 200, + data: {'ok': true}, + ); + }); + + final interceptor = AuthInterceptor( + tokenStorage: storage, + dio: dio, + onRefresh: () async => const TokenPair( + accessToken: 'renewed-access', + refreshToken: 'renewed-refresh', + ), + ); + + final requestOptions = RequestOptions(path: '/approvals'); + requestOptions.headers['Authorization'] = 'Bearer legacy-token'; + final error = DioException( + requestOptions: requestOptions, + response: Response( + requestOptions: requestOptions, + statusCode: 401, + ), + type: DioExceptionType.badResponse, + ); + + final handler = _CapturingErrorHandler(); + await interceptor.onError(error, handler); + + expect(handler.forwarded, isNull); + expect(handler.resolved, isNotNull); + expect( + requestOptions.headers['Authorization'], + equals('Bearer renewed-access'), + ); + verify(() => storage.writeAccessToken('renewed-access')).called(1); + verify(() => storage.writeRefreshToken('renewed-refresh')).called(1); + verifyNever(() => storage.clear()); + verify(() => dio.fetch(any())).called(1); + }); + + test('토큰 갱신 실패 시 저장소를 초기화하고 오류를 전달한다', () async { + when( + () => storage.readAccessToken(), + ).thenAnswer((_) async => 'legacy-token'); + + final interceptor = AuthInterceptor( + tokenStorage: storage, + dio: dio, + onRefresh: () async => null, + ); + + final requestOptions = RequestOptions(path: '/approvals'); + final error = DioException( + requestOptions: requestOptions, + response: Response( + requestOptions: requestOptions, + statusCode: 401, + ), + type: DioExceptionType.badResponse, + ); + + final handler = _CapturingErrorHandler(); + await interceptor.onError(error, handler); + + expect(handler.resolved, isNull); + expect(handler.forwarded, same(error)); + verify(() => storage.clear()).called(1); + verifyNever(() => dio.fetch(any())); + }); + }); +} diff --git a/test/features/login/presentation/pages/login_page_test.dart b/test/features/login/presentation/pages/login_page_test.dart new file mode 100644 index 0000000..4eeddad --- /dev/null +++ b/test/features/login/presentation/pages/login_page_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/permissions/permission_manager.dart'; +import 'package:superport_v2/core/permissions/permission_resources.dart'; +import 'package:superport_v2/features/login/presentation/pages/login_page.dart'; +import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; +import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/repositories/group_permission_repository.dart'; + +class _MockGroupRepository extends Mock implements GroupRepository {} + +class _MockGroupPermissionRepository extends Mock + implements GroupPermissionRepository {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(() async { + await GetIt.I.reset(); + }); + + testWidgets('로그인 성공 시 권한 동기화 후 대시보드로 이동한다', (tester) async { + final groupRepository = _MockGroupRepository(); + final permissionRepository = _MockGroupPermissionRepository(); + GetIt.I.registerSingleton(groupRepository); + GetIt.I.registerSingleton(permissionRepository); + + when( + () => groupRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isDefault: any(named: 'isDefault'), + isActive: any(named: 'isActive'), + includePermissions: any(named: 'includePermissions'), + includeEmployees: any(named: 'includeEmployees'), + ), + ).thenAnswer((invocation) async { + final items = [Group(id: 1, groupName: '관리자')]; + return PaginatedResult( + items: items, + page: 1, + pageSize: 1, + total: 1, + ); + }); + + when( + () => permissionRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: any(named: 'groupId'), + menuId: any(named: 'menuId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [ + GroupPermission( + id: 1, + group: GroupPermissionGroup(id: 1, groupName: '관리자'), + menu: GroupPermissionMenu( + id: 10, + menuCode: 'INBOUND', + menuName: '입고', + path: '/inventory/inbound', + ), + canCreate: true, + canRead: true, + ), + ], + page: 1, + pageSize: 200, + total: 1, + ), + ); + + final manager = PermissionManager(); + + final router = GoRouter( + initialLocation: loginRoutePath, + routes: [ + GoRoute( + path: loginRoutePath, + builder: (context, state) => const LoginPage(), + ), + GoRoute( + path: dashboardRoutePath, + builder: (context, state) => + const Scaffold(body: Center(child: Text('대시보드'))), + ), + ], + ); + + await tester.pumpWidget( + PermissionScope( + manager: manager, + child: ShadApp.router(routerConfig: router), + ), + ); + + await tester.enterText(find.byType(EditableText).at(0), 'user@superport'); + await tester.enterText(find.byType(EditableText).at(1), 'password'); + await tester.tap(find.text('로그인')); + await tester.pumpAndSettle(); + + expect( + router.routerDelegate.currentConfiguration.last.matchedLocation, + dashboardRoutePath, + ); + expect( + manager.can(PermissionResources.stockTransactions, PermissionAction.view), + isTrue, + ); + expect( + manager.can( + PermissionResources.stockTransactions, + PermissionAction.create, + ), + isTrue, + ); + }); + + testWidgets('권한 동기화 실패 시 오류 메시지를 표시한다', (tester) async { + final groupRepository = _MockGroupRepository(); + final permissionRepository = _MockGroupPermissionRepository(); + GetIt.I.registerSingleton(groupRepository); + GetIt.I.registerSingleton(permissionRepository); + + when( + () => groupRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isDefault: any(named: 'isDefault'), + isActive: any(named: 'isActive'), + includePermissions: any(named: 'includePermissions'), + includeEmployees: any(named: 'includeEmployees'), + ), + ).thenThrow(Exception('network error')); + + final manager = PermissionManager(); + final router = GoRouter( + initialLocation: loginRoutePath, + routes: [ + GoRoute( + path: loginRoutePath, + builder: (context, state) => const LoginPage(), + ), + GoRoute( + path: dashboardRoutePath, + builder: (context, state) => + const Scaffold(body: Center(child: Text('대시보드'))), + ), + ], + ); + + await tester.pumpWidget( + PermissionScope( + manager: manager, + child: ShadApp.router(routerConfig: router), + ), + ); + + await tester.enterText(find.byType(EditableText).at(0), 'user@superport'); + await tester.enterText(find.byType(EditableText).at(1), 'password'); + await tester.tap(find.text('로그인')); + await tester.pumpAndSettle(); + + expect(find.text('권한 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.'), findsOneWidget); + expect( + router.routerDelegate.currentConfiguration.last.matchedLocation, + loginRoutePath, + ); + expect( + manager.can(PermissionResources.stockTransactions, PermissionAction.view), + isTrue, + ); + }); +} diff --git a/test/features/reporting/data/reporting_repository_remote_test.dart b/test/features/reporting/data/reporting_repository_remote_test.dart new file mode 100644 index 0000000..9aae13d --- /dev/null +++ b/test/features/reporting/data/reporting_repository_remote_test.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +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/reporting/data/repositories/reporting_repository_remote.dart'; +import 'package:superport_v2/features/reporting/domain/entities/report_export_format.dart'; +import 'package:superport_v2/features/reporting/domain/entities/report_export_request.dart'; + +class _MockApiClient extends Mock implements ApiClient {} + +void main() { + late ApiClient apiClient; + late ReportingRepositoryRemote repository; + + setUpAll(() { + registerFallbackValue(Options()); + registerFallbackValue(CancelToken()); + }); + + setUp(() { + apiClient = _MockApiClient(); + repository = ReportingRepositoryRemote(apiClient: apiClient); + }); + + Response jsonResponse(String path, Map body) { + final bytes = Uint8List.fromList(jsonEncode(body).codeUnits); + return Response( + data: bytes, + headers: Headers.fromMap({ + 'content-type': ['application/json'], + }), + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + } + + Response binaryResponse( + String path, { + required List bytes, + required String filename, + required String mimeType, + }) { + return Response( + data: Uint8List.fromList(bytes), + headers: Headers.fromMap({ + 'content-type': [mimeType], + 'content-disposition': ['attachment; filename="$filename"'], + }), + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + } + + test('exportTransactions는 download_url을 파싱한다', () async { + const path = '/api/v1/reports/transactions/export'; + when( + () => apiClient.get( + path, + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => jsonResponse(path, { + 'data': { + 'download_url': 'https://example.com/report.xlsx', + 'filename': 'report.xlsx', + 'mime_type': + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'expires_at': '2025-01-01T00:00:00Z', + }, + }), + ); + + final request = ReportExportRequest( + from: DateTime(2024, 1, 1), + to: DateTime(2024, 1, 31), + format: ReportExportFormat.xlsx, + transactionTypeId: 3, + statusId: 1, + warehouseId: 9, + ); + + final result = await repository.exportTransactions(request); + + final captured = verify( + () => apiClient.get( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + + expect(captured.first, equals(path)); + final query = captured[1] as Map; + expect(query['from'], request.from.toIso8601String()); + expect(query['to'], request.to.toIso8601String()); + expect(query['format'], 'xlsx'); + expect(query['type_id'], 3); + expect(query['status_id'], 1); + expect(query['warehouse_id'], 9); + + expect(result.downloadUrl.toString(), 'https://example.com/report.xlsx'); + expect(result.filename, 'report.xlsx'); + expect( + result.mimeType, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + expect(result.expiresAt, DateTime.parse('2025-01-01T00:00:00Z')); + expect(result.hasDownloadUrl, isTrue); + expect(result.hasBytes, isFalse); + }); + + test('exportApprovals는 바이너리 응답을 처리한다', () async { + const path = '/api/v1/reports/approvals/export'; + when( + () => apiClient.get( + path, + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => binaryResponse( + path, + bytes: [1, 2, 3], + filename: 'approval.pdf', + mimeType: 'application/pdf', + ), + ); + + final request = ReportExportRequest( + from: DateTime(2024, 2, 1), + to: DateTime(2024, 2, 15), + format: ReportExportFormat.pdf, + statusId: 5, + ); + + final result = await repository.exportApprovals(request); + + expect(result.hasBytes, isTrue); + expect(result.bytes, isNotNull); + expect(result.filename, 'approval.pdf'); + expect(result.mimeType, 'application/pdf'); + expect(result.hasDownloadUrl, isFalse); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0c50753..2048c45 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,11 @@ #include "generated_plugin_registrant.h" #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4fc759c..de626cc 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST