chore: 통합 테스트 환경과 보고서 리모트 구성

This commit is contained in:
JiWoong Sul
2025-10-14 18:11:57 +09:00
parent 8067416c09
commit 7e0f7b1c55
25 changed files with 1608 additions and 1 deletions

10
CHANGELOG.md Normal file
View File

@@ -0,0 +1,10 @@
# 변경 기록
## 2025-10-20
- 재고 입·출·대여 컨트롤러가 `Failure.describe()` 기반으로 오류를 노출해 승인/취소 흐름에서 서버 메시지가 그대로 전달됩니다.
- 우편번호 검색 다이얼로그와 창고 선택 위젯이 API 예외를 상세히 표기하며, 관련 위젯 테스트를 추가했습니다.
- 승인/재고 플로우 주요 컨트롤러 단위 테스트에 실패 메시지 검증을 포함해 회귀를 방지합니다.
## 2025-10-18
- 재고 트랜잭션 목록/상세/상태 전이를 실 API(`StockTransactionRepositoryRemote`)에 연결하고 보고서 다운로드 UX를 개선했습니다.
- 결재 도메인에서 `canProceed` API 검증과 기능 플래그 기본값 조정으로 사용성 이슈를 해소했습니다.

View File

@@ -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=<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 경로와 요청 페이로드를 공유합니다.

View File

@@ -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 = <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),
],
);
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 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<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;
}
}

View File

@@ -0,0 +1,8 @@
/// API 경로 상수 모음
/// - 버전 prefix 등을 중앙에서 관리해 중복을 방지한다.
class ApiRoutes {
const ApiRoutes._();
/// API v1 prefix
static const apiV1 = '/api/v1';
}

View File

@@ -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<String, String> _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;
}
}

View File

@@ -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<ReportDownloadResult> exportTransactions(
ReportExportRequest request,
) async {
final response = await _api.get<Uint8List>(
_transactionsPath,
query: _buildQuery(request),
options: Options(responseType: ResponseType.bytes),
);
return _mapResponse(response, format: request.format);
}
@override
Future<ReportDownloadResult> exportApprovals(
ReportExportRequest request,
) async {
final response = await _api.get<Uint8List>(
_approvalsPath,
query: _buildQuery(request),
options: Options(responseType: ResponseType.bytes),
);
return _mapResponse(response, format: request.format);
}
Map<String, dynamic> _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<Uint8List> 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<String, dynamic> ? body : <String, dynamic>{};
final data = map['data'] is Map<String, dynamic>
? map['data'] as Map<String, dynamic>
: 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 <String, dynamic>{};
}
try {
final decoded = utf8.decode(bytes);
return jsonDecode(decoded);
} catch (_) {
return const <String, dynamic>{};
}
}
String? _readString(Map<String, dynamic> 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',
};
}
}

View File

@@ -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;
}

View File

@@ -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',
};
}

View File

@@ -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;
}

View File

@@ -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<ReportDownloadResult> exportTransactions(ReportExportRequest request);
/// 결재 보고서를 내보낸다.
Future<ReportDownloadResult> exportApprovals(ReportExportRequest request);
}

View File

@@ -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<void> initInjection({
sl.registerLazySingleton<PostalSearchRepository>(
() => PostalSearchRepositoryRemote(apiClient: sl<ApiClient>()),
);
sl.registerLazySingleton<InventoryLookupRepository>(
() => InventoryLookupRepositoryRemote(apiClient: sl<ApiClient>()),
);
sl.registerLazySingleton<StockTransactionRepository>(
() => StockTransactionRepositoryRemote(apiClient: sl<ApiClient>()),
);
sl.registerLazySingleton<TransactionLineRepository>(
() => TransactionLineRepositoryRemote(apiClient: sl<ApiClient>()),
);
sl.registerLazySingleton<TransactionCustomerRepository>(
() => TransactionCustomerRepositoryRemote(apiClient: sl<ApiClient>()),
);
sl.registerLazySingleton<ReportingRepository>(
() => ReportingRepositoryRemote(apiClient: sl<ApiClient>()),
);
}

View File

@@ -7,9 +7,13 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
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);
}

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -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"))
}

35
macos/Podfile.lock Normal file
View File

@@ -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

View File

@@ -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 = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
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 = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
@@ -76,8 +79,15 @@
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
/* 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 = "<group>";
};
@@ -175,10 +188,26 @@
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
5A32A030F236AD9E49D10327 /* Pods_Runner.framework */,
0BBED327CEE1D6DD142492EF /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
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 = "<group>";
};
/* 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;

View File

@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -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:

View File

@@ -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

View File

@@ -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<Map<String, dynamic>>(
data: {'data': []},
statusCode: 200,
requestOptions: requestOptions,
);
when(
() => dio.get<Map<String, dynamic>>(
any(),
queryParameters: any(named: 'queryParameters'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer((_) async => response);
final result = await client.get<Map<String, dynamic>>('/vendors');
expect(result, same(response));
verify(
() => dio.get<Map<String, dynamic>>(
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<dynamic>(
requestOptions: requestOptions,
statusCode: 400,
data: {'message': '잘못된 요청입니다.'},
),
);
final expected = ApiException(
code: ApiErrorCode.badRequest,
message: '잘못된 요청입니다.',
statusCode: 400,
cause: dioError,
);
when(
() => dio.get<Map<String, dynamic>>(
any(),
queryParameters: any(named: 'queryParameters'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenThrow(dioError);
when(() => mapper.map(dioError)).thenReturn(expected);
expect(
() => client.get<Map<String, dynamic>>('/vendors'),
throwsA(same(expected)),
);
verify(() => mapper.map(dioError)).called(1);
});
});
}

View File

@@ -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<dynamic>? resolved;
DioException? forwarded;
@override
void resolve(Response<dynamic> 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<dynamic>(any())).thenAnswer((invocation) async {
final options = invocation.positionalArguments.first as RequestOptions;
return Response<dynamic>(
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<dynamic>(
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<dynamic>(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<dynamic>(
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<dynamic>(any()));
});
});
}

View File

@@ -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>(groupRepository);
GetIt.I.registerSingleton<GroupPermissionRepository>(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<Group>(
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<GroupPermission>(
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>(groupRepository);
GetIt.I.registerSingleton<GroupPermissionRepository>(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,
);
});
}

View File

@@ -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<Uint8List> jsonResponse(String path, Map<String, dynamic> body) {
final bytes = Uint8List.fromList(jsonEncode(body).codeUnits);
return Response<Uint8List>(
data: bytes,
headers: Headers.fromMap({
'content-type': ['application/json'],
}),
requestOptions: RequestOptions(path: path),
statusCode: 200,
);
}
Response<Uint8List> binaryResponse(
String path, {
required List<int> bytes,
required String filename,
required String mimeType,
}) {
return Response<Uint8List>(
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<Uint8List>(
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<Uint8List>(
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<String, dynamic>;
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<Uint8List>(
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);
});
}

View File

@@ -7,8 +7,11 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST