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

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