chore: 통합 테스트 환경과 보고서 리모트 구성
This commit is contained in:
8
lib/core/network/api_routes.dart
Normal file
8
lib/core/network/api_routes.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
/// API 경로 상수 모음
|
||||
/// - 버전 prefix 등을 중앙에서 관리해 중복을 방지한다.
|
||||
class ApiRoutes {
|
||||
const ApiRoutes._();
|
||||
|
||||
/// API v1 prefix
|
||||
static const apiV1 = '/api/v1';
|
||||
}
|
||||
116
lib/core/permissions/permission_resources.dart
Normal file
116
lib/core/permissions/permission_resources.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>()),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user