diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 278593b..e1adc86 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -1,11 +1,7 @@ -// Main.kt - import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable @@ -19,6 +15,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -35,14 +32,10 @@ import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* import io.ktor.client.request.* import io.ktor.client.statement.* -import io.ktor.http.ContentType import io.ktor.http.HttpHeaders -import io.ktor.http.contentType import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.* -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.awt.FileDialog import java.awt.Frame @@ -62,168 +55,278 @@ import java.util.zip.GZIPInputStream // --- 상수 및 Enum 정의 --- private const val ISSUER_ID_KEY = "APP_STORE_ISSUER_ID" private const val KEY_ID_KEY = "APP_STORE_KEY_ID" -private const val APP_ID_KEY = "APP_STORE_APP_ID" +private const val SKU_KEY = "APP_STORE_SKU" private const val VENDOR_NUMBER_KEY = "APP_STORE_VENDOR_NUMBER" -enum class ReportType { SALES, ANALYTICS } +enum class ReportType { SALES, APP_SPECIFIC_SALES } // --- 데이터 클래스 정의 --- -data class SalesReportRecord(val title: String, val units: Int) -data class AnalyticsResult(val installations: Long = 0, val redownloads: Long = 0, val updates: Long = 0) -@Serializable data class AnalyticsReportRequestResponse(val data: AnalyticsReportRequestData) -@Serializable data class AnalyticsReportRequestData(val id: String, val links: Links) -@Serializable data class Links(val self: String) -@Serializable data class AnalyticsReportResponse(val data: List = emptyList()) -@Serializable data class AnalyticsReportData(val attributes: ReportAttributes? = null) -@Serializable data class ReportAttributes(val downloadUrl: String? = null) +// Main.kt + +// ⬇️ countryCode: String 필드를 추가합니다. +data class SalesReportRecord(val title: String, val sku: String, val units: Int, val countryCode: String) // --- 유틸리티 함수 --- fun loadPrivateKey(path: String): PrivateKey { val keyBytes = Files.readAllBytes(Paths.get(path)); val keyPem = String(keyBytes).replace("-----BEGIN PRIVATE KEY-----", "").replace("-----END PRIVATE KEY-----", "").replace("\\s".toRegex(), ""); val decoded = Base64.getDecoder().decode(keyPem); val keySpec = PKCS8EncodedKeySpec(decoded); val kf = KeyFactory.getInstance("EC"); return kf.generatePrivate(keySpec) } -fun createJWT(privateKey: PrivateKey, issuerId: String, keyId: String, scope: List?): String { - val now = Instant.now(); val expire = now.plusSeconds(20 * 60); val builder = Jwts.builder().setHeaderParam("alg", "ES256").setHeaderParam("kid", keyId).setHeaderParam("typ", "JWT").setIssuer(issuerId).setAudience("appstoreconnect-v1").setIssuedAt(Date.from(now)).setExpiration(Date.from(expire)); if (!scope.isNullOrEmpty()) { builder.claim("scope", scope) }; return builder.signWith(privateKey, SignatureAlgorithm.ES256).compact() +fun createJWT(privateKey: PrivateKey, issuerId: String, keyId: String): String { + val now = Instant.now(); val expire = now.plusSeconds(20 * 60); return Jwts.builder().setHeaderParam("alg", "ES256").setHeaderParam("kid", keyId).setHeaderParam("typ", "JWT").setIssuer(issuerId).setAudience("appstoreconnect-v1").setIssuedAt(Date.from(now)).setExpiration(Date.from(expire)).signWith(privateKey, SignatureAlgorithm.ES256).compact() } fun parseSalesReport(tsvData: String): List { val records = mutableListOf() val lines = tsvData.split("\n").drop(1) - lines.forEach { line -> if (line.isBlank()) return@forEach; val columns = line.split("\t"); try { records.add(SalesReportRecord(title = columns.getOrElse(4) { "N/A" }, units = columns.getOrElse(7) { "0" }.toIntOrNull() ?: 0)) } catch (e: Exception) { println("TSV 파싱 오류: $line, ${e.message}") } } - return records.groupBy { it.title }.map { (title, unitList) -> SalesReportRecord(title, unitList.sumOf { it.units }) }.sortedByDescending { it.units } -} -fun parseAnalyticsReport(csvData: String): AnalyticsResult { - var installs = 0L; var redownloads = 0L; var updates = 0L - val lines = csvData.split("\n").drop(1) - lines.forEach { line -> if (line.isBlank()) return@forEach; val columns = line.split(","); try { when (columns.getOrNull(0)?.trim()) { "INSTALLATIONS" -> installs = columns.getOrNull(1)?.toLongOrNull() ?: 0L; "REDOWNLOADS" -> redownloads = columns.getOrNull(1)?.toLongOrNull() ?: 0L; "UPDATES" -> updates = columns.getOrNull(1)?.toLongOrNull() ?: 0L } } catch (e: Exception) { println("Analytics CSV 파싱 오류: $line, ${e.message}") } } - return AnalyticsResult(installations = installs, redownloads = redownloads, updates = updates) + lines.forEach { line -> + if (line.isBlank()) return@forEach + val columns = line.split("\t") + try { + // ⬇️ countryCode를 13번째 열(인덱스 12)에서 가져오도록 수정합니다. + records.add(SalesReportRecord( + title = columns.getOrElse(4) { "N/A" }, + sku = columns.getOrElse(2) { "N/A" }, + units = columns.getOrElse(7) { "0" }.toIntOrNull() ?: 0, + countryCode = columns.getOrElse(12) { "N/A" } + )) + } catch (e: Exception) { + println("TSV 파싱 오류: $line, ${e.message}") + } + } + return records } -// --- 메인 화면 및 컴포저블 --- +// --- 메인 화면 --- @Composable -fun App(onSaveTsvRequest: (period: String, tsvData: String) -> Unit, onSaveCsvRequest: (appId: String, date: LocalDate, result: AnalyticsResult) -> Unit) { - val prefs = remember { Preferences.userRoot().node("com.example.combinedreportgenerator") }; var selectedTab by remember { mutableStateOf(ReportType.SALES) }; var filePath by remember { mutableStateOf("") }; var issuerId by remember { mutableStateOf(prefs.get(ISSUER_ID_KEY, "")) }; var keyId by remember { mutableStateOf(prefs.get(KEY_ID_KEY, "")) }; var error by remember { mutableStateOf(null) }; val privateKey: PrivateKey? by remember(filePath) { derivedStateOf { if (filePath.isNotBlank()) { try { error = null; loadPrivateKey(filePath) } catch (e: Exception) { error = "개인키 파일 로드 실패: ${e.message}"; null } } else { null } } }; val client = remember { HttpClient(CIO) { install(Logging) { logger = object : Logger { override fun log(message: String) { println("KTOR LOG: $message") } }; level = LogLevel.ALL }; install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; isLenient = true; }) } } }; DisposableEffect(Unit) { onDispose { client.close() } } - Row(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.width(350.dp).fillMaxHeight().verticalScroll(rememberScrollState()).padding(16.dp)) { - Text("API Key 정보", style = MaterialTheme.typography.h6); Spacer(Modifier.height(8.dp)); Text("Issuer ID"); TextField(value = issuerId, onValueChange = {issuerId = it; prefs.put(ISSUER_ID_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)); Text("Key ID"); TextField(value = keyId, onValueChange = {keyId = it; prefs.put(KEY_ID_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)); Text("개인키(.p8) 파일") - Row(verticalAlignment = Alignment.CenterVertically) { - TextField(value = filePath, onValueChange = { filePath = it }, singleLine = true, modifier = Modifier.weight(1f), readOnly = true); Spacer(Modifier.width(8.dp)); Button(onClick = { val dialog = FileDialog(Frame(), "개인키(.p8) 파일을 선택하세요", FileDialog.LOAD).apply { setFile("*.p8"); isVisible = true }; if (dialog.directory != null && dialog.file != null) { filePath = dialog.directory + dialog.file } }) { Text("찾기") } +fun App(onSaveTsvRequest: (name: String, tsvData: String) -> Unit) { + val prefs = remember { Preferences.userRoot().node("com.example.combinedreportgenerator") }; val scope = rememberCoroutineScope() + var selectedTab by remember { mutableStateOf(ReportType.SALES) }; var filePath by remember { mutableStateOf("") }; var issuerId by remember { mutableStateOf(prefs.get(ISSUER_ID_KEY, "")) }; var keyId by remember { mutableStateOf(prefs.get(KEY_ID_KEY, "")) }; var error by remember { mutableStateOf(null) }; val privateKey: PrivateKey? by remember(filePath) { derivedStateOf { if (filePath.isNotBlank()) { try { error = null; loadPrivateKey(filePath) } catch (e: Exception) { error = "개인키 파일 로드 실패: ${e.message}"; null } } else { null } } } + var logText by remember { mutableStateOf("여기에 통신 기록이 표시됩니다.") } + var salesResult by remember { mutableStateOf>(emptyList()) }; var rawTsvReport by remember { mutableStateOf("") }; var statusMessage by remember { mutableStateOf("리포트 조회를 위한 정보를 입력하세요.") }; var totalUnitsBySku by remember { mutableStateOf(null) } + + // ⬇️ 변경점 1: 로그 기록 방식을 위에서 아래로 쌓이도록 변경합니다. + val client = remember { + HttpClient(CIO) { + install(Logging) { + level = LogLevel.ALL + logger = object : Logger { + override fun log(message: String) { + val timestamp = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + // 기존 로그에 새로운 로그를 덧붙입니다. + logText += "[$timestamp]\n$message\n\n" + } + } } - if (error != null) { Text(text = error ?: "", color = Color.Red, modifier = Modifier.padding(top = 8.dp)) } - Divider(modifier = Modifier.padding(vertical = 16.dp)); TabRow(selectedTabIndex = selectedTab.ordinal) { Tab(selected = selectedTab == ReportType.SALES, onClick = { selectedTab = ReportType.SALES }, text = { Text("판매 리포트") }); Tab(selected = selectedTab == ReportType.ANALYTICS, onClick = { selectedTab = ReportType.ANALYTICS }, text = { Text("앱 분석") }) }; Spacer(Modifier.height(16.dp)) - when (selectedTab) { - ReportType.SALES -> SalesReportSettings(prefs, privateKey, client, issuerId, keyId, onSaveTsvRequest) - ReportType.ANALYTICS -> AnalyticsReportSettings(prefs, privateKey, client, issuerId, keyId, onSaveCsvRequest) + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + isLenient = true + }) } } + } + DisposableEffect(Unit) { onDispose { client.close() } } + + Row(modifier = Modifier.fillMaxSize()) { + SettingsPanel( + modifier = Modifier.width(350.dp), prefs = prefs, selectedTab = selectedTab, onTabSelected = { selectedTab = it }, filePath = filePath, onFilePathChange = { filePath = it }, issuerId = issuerId, onIssuerIdChange = { issuerId = it; prefs.put(ISSUER_ID_KEY, it) }, keyId = keyId, onKeyIdChange = { keyId = it; prefs.put(KEY_ID_KEY, it) }, error = error, + onGenerateRequest = { + scope.launch { + // ⬇️ 변경점 2: API 요청 시작 시, 기존 로그를 모두 지웁니다. + logText = "" + + val key = privateKey + if (key == null) { statusMessage = "오류: 개인키를 선택하세요"; return@launch } + try { + val token = createJWT(key, issuerId, keyId) + when (selectedTab) { + ReportType.SALES -> { + val (vendorNumber, freq, period) = it as Triple + if(vendorNumber.isBlank()) { statusMessage = "오류: Vendor Number를 입력하세요."; return@launch } + statusMessage = "[$period] 전체 판매 리포트 로딩 중..."; salesResult = emptyList(); totalUnitsBySku = null + val response: HttpResponse = client.get("https://api.appstoreconnect.apple.com/v1/salesReports") { header(HttpHeaders.Accept, "application/a-gzip"); header(HttpHeaders.Authorization, "Bearer $token"); parameter("filter[reportType]", "SALES"); parameter("filter[reportSubType]", "SUMMARY"); parameter("filter[vendorNumber]", vendorNumber); parameter("filter[frequency]", freq); when (freq) { "DAILY", "WEEKLY" -> parameter("filter[reportDate]", period); "MONTHLY" -> parameter("filter[reportMonth]", period); "YEARLY" -> parameter("filter[reportYear]", period) } } + if (!response.status.isSuccess()) throw Exception("API 오류 (${response.status}): ${response.bodyAsText()}") + rawTsvReport = GZIPInputStream(ByteArrayInputStream(response.body())).bufferedReader(Charsets.UTF_8).use { it.readText() }; val parsedRecords = parseSalesReport(rawTsvReport) + + // ⬇️ 기존의 groupBy 로직을 삭제하고, 파싱된 원본 데이터를 그대로 사용합니다. + salesResult = parsedRecords + statusMessage = "[$period] 리포트 조회 완료" + } + ReportType.APP_SPECIFIC_SALES -> { + val (vendorNumber, sku, date) = it as Triple + if(vendorNumber.isBlank() || sku.isBlank()) { statusMessage = "오류: Vendor Number와 SKU를 입력하세요."; return@launch } + val dateString = date.format(DateTimeFormatter.ISO_LOCAL_DATE) + statusMessage = "[$dateString] 앱별 판매 리포트 로딩 중..."; totalUnitsBySku = null; salesResult = emptyList() + val response: HttpResponse = client.get("https://api.appstoreconnect.apple.com/v1/salesReports") { header(HttpHeaders.Accept, "application/a-gzip"); header(HttpHeaders.Authorization, "Bearer $token"); parameter("filter[reportType]", "SALES"); parameter("filter[reportSubType]", "SUMMARY"); parameter("filter[vendorNumber]", vendorNumber); parameter("filter[frequency]", "DAILY"); parameter("filter[reportDate]", dateString) } + if (!response.status.isSuccess()) throw Exception("API 오류 (${response.status}): ${response.bodyAsText()}") + rawTsvReport = GZIPInputStream(ByteArrayInputStream(response.body())).bufferedReader(Charsets.UTF_8).use { it.readText() }; val allRecords = parseSalesReport(rawTsvReport) + val filteredRecords = allRecords.filter { it.sku.equals(sku, ignoreCase = true) } + totalUnitsBySku = filteredRecords.sumOf { it.units }.toLong() + statusMessage = if (filteredRecords.isEmpty() && allRecords.isNotEmpty()) "[$dateString] 해당 SKU를 찾을 수 없음" else "[$dateString] 조회 완료" + } + } + } catch (e: Exception) { e.printStackTrace(); statusMessage = "오류: ${e.message}" } + } + } + ) Divider(modifier = Modifier.fillMaxHeight().width(1.dp)) + ResultsPanel(modifier = Modifier.weight(1f), selectedTab = selectedTab, statusMessage = statusMessage, salesResult = salesResult, totalUnitsBySku = totalUnitsBySku, onSaveRequest = { onSaveTsvRequest(it, rawTsvReport) }) + Divider(modifier = Modifier.fillMaxHeight().width(1.dp)) + LogPanel(logText, modifier = Modifier.width(400.dp)) } } -// --- 판매 리포트 관련 컴포저블 --- @Composable -fun SalesReportSettings(prefs: Preferences, privateKey: PrivateKey?, client: HttpClient, issuerId: String, keyId: String, onSaveTsvRequest: (period: String, tsvData: String) -> Unit) { - var vendorNumber by remember { mutableStateOf(prefs.get(VENDOR_NUMBER_KEY, "")) }; var statusMessage by remember { mutableStateOf("리포트 조회를 위한 정보를 입력하세요.") }; var salesResult by remember { mutableStateOf>(emptyList()) }; var rawTsvReport by remember { mutableStateOf("") }; val scope = rememberCoroutineScope(); val frequencies = listOf("DAILY", "WEEKLY", "MONTHLY", "YEARLY"); var selectedFrequency by remember { mutableStateOf(frequencies.first()) }; var reportPeriod by remember { mutableStateOf(LocalDate.now().minusDays(3).format(DateTimeFormatter.ISO_LOCAL_DATE)) } +fun SettingsPanel(modifier: Modifier = Modifier, prefs: Preferences, selectedTab: ReportType, onTabSelected: (ReportType) -> Unit, filePath: String, onFilePathChange: (String) -> Unit, issuerId: String, onIssuerIdChange: (String) -> Unit, keyId: String, onKeyIdChange: (String) -> Unit, error: String?, onGenerateRequest: (Any) -> Unit) { + Column(modifier = modifier.fillMaxHeight().verticalScroll(rememberScrollState()).padding(16.dp)) { + Text("API Key 정보", style = MaterialTheme.typography.h6); Spacer(Modifier.height(8.dp)); Text("Issuer ID"); TextField(value = issuerId, onValueChange = onIssuerIdChange, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)); Text("Key ID"); TextField(value = keyId, onValueChange = onKeyIdChange, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)); Text("개인키(.p8) 파일") + Row(verticalAlignment = Alignment.CenterVertically) { + TextField(value = filePath, onValueChange = onFilePathChange, singleLine = true, modifier = Modifier.weight(1f), readOnly = true); Spacer(Modifier.width(8.dp)); Button(onClick = { val dialog = FileDialog(Frame(), "개인키(.p8) 파일을 선택하세요", FileDialog.LOAD).apply { setFile("*.p8"); isVisible = true }; if (dialog.directory != null && dialog.file != null) { onFilePathChange(dialog.directory + dialog.file) } }) { Text("찾기") } + } + if (error != null) { Text(text = error, color = Color.Red, modifier = Modifier.padding(top = 8.dp)) } + Divider(modifier = Modifier.padding(vertical = 16.dp)); TabRow(selectedTabIndex = selectedTab.ordinal) { Tab(selected = selectedTab == ReportType.SALES, onClick = { onTabSelected(ReportType.SALES) }, text = { Text("전체 판매") }); Tab(selected = selectedTab == ReportType.APP_SPECIFIC_SALES, onClick = { onTabSelected(ReportType.APP_SPECIFIC_SALES) }, text = { Text("앱별 판매") }) }; Spacer(Modifier.height(16.dp)) + when (selectedTab) { + ReportType.SALES -> SalesReportSettings(prefs, onGenerateRequest) + ReportType.APP_SPECIFIC_SALES -> AppSpecificSalesSettings(prefs, onGenerateRequest) + } + } +} + +@Composable +fun SalesReportSettings(prefs: Preferences, onGenerateRequest: (Triple) -> Unit) { + var vendorNumber by remember { mutableStateOf(prefs.get(VENDOR_NUMBER_KEY, "")) }; val frequencies = listOf("DAILY", "WEEKLY", "MONTHLY", "YEARLY"); var selectedFrequency by remember { mutableStateOf(frequencies.first()) }; var reportPeriod by remember { mutableStateOf(LocalDate.now().minusDays(3).format(DateTimeFormatter.ISO_LOCAL_DATE)) }; var showDatePicker by remember { mutableStateOf(false) } Text("Vendor Number"); TextField(value = vendorNumber, onValueChange = {vendorNumber = it; prefs.put(VENDOR_NUMBER_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)); Text("리포트 빈도") frequencies.forEach { freq -> Row(Modifier.fillMaxWidth().selectable(selected = (freq == selectedFrequency), onClick = { selectedFrequency = freq }).padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) { RadioButton(selected = (freq == selectedFrequency), onClick = null); Text(text = freq, modifier = Modifier.padding(start = 8.dp)) } } - Spacer(Modifier.height(8.dp)); Text("조회 기간"); OutlinedTextField(value = reportPeriod, onValueChange = { reportPeriod = it }, modifier = Modifier.fillMaxWidth()); Text("DAILY/WEEKLY: YYYY-MM-DD, MONTHLY: YYYY-MM, YEARLY: YYYY", style = MaterialTheme.typography.caption); Spacer(Modifier.height(16.dp)) - Button(onClick = { - scope.launch { - if(privateKey == null || vendorNumber.isBlank() || issuerId.isBlank() || keyId.isBlank()) { statusMessage = "오류: 모든 정보를 입력해주세요."; return@launch } - try { - statusMessage = "[$reportPeriod] 판매 리포트 로딩 중..."; salesResult = emptyList() - val token = createJWT(privateKey, issuerId, keyId, null) - val response: HttpResponse = client.get("https://api.appstoreconnect.apple.com/v1/salesReports") { - header(HttpHeaders.Accept, "application/a-gzip"); header(HttpHeaders.Authorization, "Bearer $token") - parameter("filter[reportType]", "SALES"); parameter("filter[reportSubType]", "SUMMARY"); parameter("filter[vendorNumber]", vendorNumber); parameter("filter[frequency]", selectedFrequency) - when (selectedFrequency) { "DAILY", "WEEKLY" -> parameter("filter[reportDate]", reportPeriod); "MONTHLY" -> parameter("filter[reportMonth]", reportPeriod); "YEARLY" -> parameter("filter[reportYear]", reportPeriod) } - } - if (!response.status.isSuccess()) throw Exception("API 오류 (${response.status}): ${response.bodyAsText()}") - rawTsvReport = GZIPInputStream(ByteArrayInputStream(response.body())).bufferedReader(Charsets.UTF_8).use { it.readText() } - salesResult = parseSalesReport(rawTsvReport); statusMessage = "[$reportPeriod] 리포트 조회 완료" - } catch (e: Exception) { e.printStackTrace(); statusMessage = "오류: ${e.message}" } - } - }, modifier = Modifier.fillMaxWidth(), enabled = privateKey != null) { Text("판매 리포트 조회") } - Spacer(Modifier.height(16.dp)); Divider(); SalesResultView(statusMessage, salesResult) { onSaveTsvRequest(reportPeriod, rawTsvReport) } + Spacer(Modifier.height(8.dp)); Text("조회 기간"); val isCalendarEnabled = selectedFrequency in listOf("DAILY", "WEEKLY") + Box(modifier = if(isCalendarEnabled) Modifier.clickable { showDatePicker = true } else Modifier) { OutlinedTextField(value = reportPeriod, onValueChange = { if (!isCalendarEnabled) reportPeriod = it }, readOnly = isCalendarEnabled, modifier = Modifier.fillMaxWidth(), trailingIcon = { if(isCalendarEnabled) Icon(Icons.Default.DateRange, "날짜 선택") }, enabled = !isCalendarEnabled, colors = TextFieldDefaults.outlinedTextFieldColors(disabledTextColor = LocalContentColor.current.copy(LocalContentAlpha.current), disabledBorderColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled))) } + if (showDatePicker) { SimpleDatePicker(initialDate = try { LocalDate.parse(reportPeriod) } catch(e:Exception){LocalDate.now().minusDays(3)}, onDateSelected = { reportPeriod = it.format(DateTimeFormatter.ISO_LOCAL_DATE); showDatePicker = false }, onDismissRequest = { showDatePicker = false }) } + Text("DAILY/WEEKLY: 달력 사용, MONTHLY: YYYY-MM, YEARLY: YYYY", style = MaterialTheme.typography.caption); Spacer(Modifier.height(16.dp)) + Button(onClick = { onGenerateRequest(Triple(vendorNumber, selectedFrequency, reportPeriod)) }, modifier = Modifier.fillMaxWidth()) { Text("전체 판매 리포트 조회") } } +@Composable +fun AppSpecificSalesSettings(prefs: Preferences, onGenerateRequest: (Triple) -> Unit) { + var vendorNumber by remember { mutableStateOf(prefs.get(VENDOR_NUMBER_KEY, "")) }; var sku by remember { mutableStateOf(prefs.get(SKU_KEY, "")) }; var reportDate by remember { mutableStateOf(LocalDate.now().minusDays(3)) }; var showDatePicker by remember { mutableStateOf(false) } + Text("Vendor Number"); TextField(value = vendorNumber, onValueChange = {vendorNumber = it; prefs.put(VENDOR_NUMBER_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)); Text("SKU (제품 번호)"); TextField(value = sku, onValueChange = {sku = it; prefs.put(SKU_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)); Text("조회 날짜") + Box(modifier = Modifier.clickable { showDatePicker = true }) { OutlinedTextField(value = reportDate.format(DateTimeFormatter.ISO_LOCAL_DATE), onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxWidth(), trailingIcon = { Icon(Icons.Default.DateRange, "날짜 선택") }, enabled = false, colors = TextFieldDefaults.outlinedTextFieldColors(disabledTextColor = LocalContentColor.current.copy(LocalContentAlpha.current), disabledBorderColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled))) } + if (showDatePicker) { SimpleDatePicker(initialDate = reportDate, onDateSelected = { reportDate = it; showDatePicker = false }, onDismissRequest = { showDatePicker = false }) } + Text("3~4일 이전 날짜 권장", style = MaterialTheme.typography.caption); Spacer(Modifier.height(16.dp)) + Button(onClick = { onGenerateRequest(Triple(vendorNumber, sku, reportDate)) }, modifier = Modifier.fillMaxWidth()) { Text("앱별 판매(Units) 조회") } +} + +@Composable +fun ResultsPanel(modifier: Modifier = Modifier, selectedTab: ReportType, statusMessage: String, salesResult: List, totalUnitsBySku: Long?, onSaveRequest: (String) -> Unit) { + Column(modifier = modifier.fillMaxHeight().padding(16.dp)) { + when(selectedTab) { + ReportType.SALES -> SalesResultView(statusMessage, salesResult) { onSaveRequest("sales-report-${System.currentTimeMillis()}") } + ReportType.APP_SPECIFIC_SALES -> AppSpecificResultView(statusMessage, totalUnitsBySku) { onSaveRequest("app-sales-report-${System.currentTimeMillis()}") } + } + } +} + +// Main.kt + @Composable fun SalesResultView(statusMessage: String, result: List, onSaveRequest: () -> Unit) { - Column(Modifier.fillMaxSize().padding(top = 16.dp)) { - Text(statusMessage, style = MaterialTheme.typography.subtitle2); Spacer(Modifier.height(8.dp)) - if (result.isNotEmpty()) { - Text("총 다운로드 (Units): ${NumberFormat.getInstance().format(result.sumOf { it.units })}", fontWeight = FontWeight.Bold); Spacer(Modifier.height(8.dp)); Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text("앱 이름", Modifier.weight(1f), fontWeight = FontWeight.Bold); Text("다운로드 수", Modifier.width(100.dp), fontWeight = FontWeight.Bold, textAlign = TextAlign.End) }; Divider() - LazyColumn(modifier = Modifier.weight(1f)) { items(result) { (title, units) -> Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { Text(title, Modifier.weight(1f)); Text(NumberFormat.getInstance().format(units), Modifier.width(100.dp), textAlign = TextAlign.End) }; Divider(color = Color.Gray.copy(alpha = 0.2f)) } } - Spacer(Modifier.height(16.dp)); Button(onClick = onSaveRequest, modifier = Modifier.fillMaxWidth()) { Text("원본 TSV 다운로드") } - } - } -} + Text(statusMessage, style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(bottom = 8.dp)) + if (result.isNotEmpty()) { + // 전체 총합계 (기존과 동일) + Text("총 다운로드 (Units): ${NumberFormat.getInstance().format(result.sumOf { it.units })}", fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 8.dp)) -// --- 앱 분석 관련 컴포저블 --- -@Composable -fun AnalyticsReportSettings(prefs: Preferences, privateKey: PrivateKey?, client: HttpClient, issuerId: String, keyId: String, onSaveCsvRequest: (appId: String, date: LocalDate, result: AnalyticsResult) -> Unit) { - var appId by remember { mutableStateOf(prefs.get(APP_ID_KEY, "")) }; var statusMessage by remember { mutableStateOf("분석할 앱의 정보를 입력하세요.") }; var analyticsResult by remember { mutableStateOf(null) }; var reportDate by remember { mutableStateOf(LocalDate.now().minusDays(3)) }; val scope = rememberCoroutineScope(); var showDatePicker by remember { mutableStateOf(false) } - Text("App ID"); TextField(value = appId, onValueChange = {appId = it; prefs.put(APP_ID_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)); Text("조회 날짜") - Box(modifier = Modifier.clickable { showDatePicker = true }) { OutlinedTextField(value = reportDate.format(DateTimeFormatter.ISO_LOCAL_DATE), onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxWidth(), trailingIcon = { Icon(Icons.Default.DateRange, "날짜 선택") }, enabled = false, colors = TextFieldDefaults.outlinedTextFieldColors(disabledTextColor = LocalContentColor.current.copy(LocalContentAlpha.current), disabledBorderColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled))) } - if (showDatePicker) { SimpleDatePicker(initialDate = reportDate, onDateSelected = { reportDate = it; showDatePicker = false }, onDismissRequest = { showDatePicker = false }) } - Text("YYYY-MM-DD 형식. 3~4일 이전 날짜 권장", style = MaterialTheme.typography.caption); Spacer(Modifier.height(16.dp)) - Button(onClick = { - scope.launch { - if(privateKey == null || appId.isBlank() || issuerId.isBlank() || keyId.isBlank()) { statusMessage = "오류: 모든 정보를 입력해주세요."; return@launch } - try { - val dateString = reportDate.format(DateTimeFormatter.ISO_LOCAL_DATE) - statusMessage = "[$dateString] 분석 리포트 요청 중..."; analyticsResult = null - val token = createJWT(privateKey, issuerId, keyId, null) + // ⬇️ 데이터를 앱별로 그룹화하고 다운로드 수에 따라 정렬하는 로직 추가 + val sortedAndGroupedApps = result + .groupBy { it.title } // 1. 앱 이름으로 그룹화 + .mapValues { (_, records) -> + records.sortedByDescending { it.units } // 2. 각 그룹 내에서 국가를 다운로드 수로 정렬 + } + .toList() + .sortedByDescending { (_, records) -> records.sumOf { it.units } } // 3. 앱 자체를 총 다운로드 수로 정렬 - // ⬇️ 1. 리포트 생성 요청 (POST) - API 버전을 v1으로 수정 - val requestBody = """{"data":{"type":"analyticsReportRequests","attributes":{"accessType":"ONE_TIME_SNAPSHOT","stopped":true,"granularity":"DAILY","dimensionFilters":[{"dimensionKey":"app","optionKeys":["$appId"]}],"metricFilters":[{"metricType":"INSTALLATIONS"},{"metricType":"REDOWNLOADS"},{"metricType":"UPDATES"}]}}}""" - val requestResponse: HttpResponse = client.post("https://api.appstoreconnect.apple.com/v1/analyticsReportRequests") { - header(HttpHeaders.Authorization, "Bearer $token"); contentType(ContentType.Application.Json); setBody(requestBody) - } - if (!requestResponse.status.isSuccess()) throw Exception("리포트 요청 실패 (${requestResponse.status}): ${requestResponse.bodyAsText()}") - val reportRequestData: AnalyticsReportRequestResponse = requestResponse.body() + Divider() - // ⬇️ 2. 리포트 생성 대기 및 확인 - var downloadUrl: String? = null - repeat(10) { - statusMessage = "[$dateString] 리포트 생성 대기 중... (${it + 1}/10)"; delay(5000) - val reportListResponse: HttpResponse = client.get(reportRequestData.data.links.self + "/reports") { header(HttpHeaders.Authorization, "Bearer $token") } - if (reportListResponse.status.isSuccess()) { - val reports: AnalyticsReportResponse = reportListResponse.body() - downloadUrl = reports.data.firstOrNull()?.attributes?.downloadUrl - if (downloadUrl != null) { statusMessage = "[$dateString] 리포트 다운로드 중..."; return@repeat } + // ⬇️ 그룹화된 데이터를 표시하기 위한 LazyColumn 재구성 + LazyColumn() { + // 정렬된 앱 리스트를 순회 + sortedAndGroupedApps.forEach { (title, records) -> + + // --- 1. 앱 제목 헤더 --- + item { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colors.onSurface.copy(alpha = 0.1f)) + .padding(vertical = 8.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = title, style = MaterialTheme.typography.h6.copy(fontWeight = FontWeight.Bold)) } } - if (downloadUrl == null) throw Exception("리포트 생성 시간 초과") - // ⬇️ 3. 생성된 리포트 다운로드 및 파싱 - val reportDataResponse: HttpResponse = client.get(downloadUrl!!) { header(HttpHeaders.Authorization, "Bearer $token") } - if (!reportDataResponse.status.isSuccess()) throw Exception("리포트 다운로드 실패: ${reportDataResponse.status}") - val reportCsv = GZIPInputStream(ByteArrayInputStream(reportDataResponse.body())).bufferedReader(Charsets.UTF_8).use { it.readText() } - analyticsResult = parseAnalyticsReport(reportCsv); statusMessage = "[$dateString] 분석 완료" - } catch (e: Exception) { e.printStackTrace(); statusMessage = "오류: ${e.message}" } - } - }, modifier = Modifier.fillMaxWidth(), enabled = privateKey != null) { Text("앱 분석 통계 조회") } - Spacer(Modifier.height(16.dp)); Divider(); AnalyticsResultView(statusMessage, analyticsResult) { analyticsResult?.let { onSaveCsvRequest(appId, reportDate, it) } } -} + // --- 2. 국가별 상세 내역 --- + items(records) { record -> + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 8.dp) + .padding(start = 16.dp), // 들여쓰기 효과 + verticalAlignment = Alignment.CenterVertically + ) { + // 국가 코드 + Text(text = record.countryCode, modifier = Modifier.weight(1f)) + // 다운로드 수 + Text( + text = NumberFormat.getInstance().format(record.units), + modifier = Modifier.width(100.dp), // 너비를 고정하여 정렬 유지 + textAlign = TextAlign.End + ) + } + Divider(color = Color.Gray.copy(alpha = 0.2f), modifier = Modifier.padding(horizontal = 8.dp)) + } -@Composable -fun AnalyticsResultView(statusMessage: String, result: AnalyticsResult?, onSaveRequest: () -> Unit) { - Column(Modifier.fillMaxSize().padding(top = 16.dp)) { - Text(statusMessage, style = MaterialTheme.typography.subtitle2); Spacer(Modifier.height(16.dp)) - if (result != null) { - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp)) { - AnalyticsCard("최초 설치", result.installations, MaterialTheme.colors.primary); AnalyticsCard("재다운로드", result.redownloads, Color(0xFF00897B)); AnalyticsCard("업데이트", result.updates, Color(0xFF5E35B1)) + // --- 3. 앱별 소계 --- + item { + Row( + Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "앱 소계", style = MaterialTheme.typography.body1, fontWeight = FontWeight.Bold) + Spacer(Modifier.width(16.dp)) + Text( + text = NumberFormat.getInstance().format(records.sumOf { it.units }), + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.Bold, + modifier = Modifier.width(100.dp), + textAlign = TextAlign.End + ) + } + Divider(thickness = 2.dp, color = Color.Gray) // 앱과 앱 사이를 구분하는 더 두꺼운 구분선 + } } - Spacer(Modifier.height(16.dp)); Button(onClick = onSaveRequest, modifier = Modifier.fillMaxWidth()) { Text("CSV로 저장") } + } + Spacer(Modifier.height(16.dp)) + Button(onClick = onSaveRequest, modifier = Modifier.fillMaxWidth(), enabled = result.isNotEmpty()) { + Text("원본 데이터 다운로드 (TSV)") } } } + @Composable -fun AnalyticsCard(title: String, value: Long, color: Color) { Card(elevation = 4.dp, modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text(title, style = MaterialTheme.typography.h6); Spacer(Modifier.height(8.dp)); Text(NumberFormat.getInstance().format(value), style = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold), color = color) } } } +fun AppSpecificResultView(statusMessage: String, totalUnits: Long?, onSaveRequest: () -> Unit) { + Text(statusMessage, style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(bottom = 16.dp)) + if (totalUnits != null) { + Card(elevation = 4.dp, modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text("해당 SKU 총 다운로드 (Units)", style = MaterialTheme.typography.h6); Spacer(Modifier.height(8.dp)); Text(NumberFormat.getInstance().format(totalUnits), style = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colors.primary) } } + Spacer(Modifier.height(16.dp)); Button(onClick = onSaveRequest, modifier = Modifier.fillMaxWidth()) { Text("전체 원본 데이터 다운로드 (TSV)") } + } +} + +@Composable +fun LogPanel(logText: String, modifier: Modifier = Modifier) { + val scrollState = rememberScrollState(); Column(modifier = modifier.fillMaxHeight().background(Color(0xFFF5F5F5)).padding(16.dp)) { Text("통신 전문", style = MaterialTheme.typography.h6, modifier = Modifier.padding(bottom = 8.dp)); Divider(); Box(modifier = Modifier.weight(1f).background(Color.White, MaterialTheme.shapes.small).padding(8.dp)) { Text(text = logText, modifier = Modifier.fillMaxSize().verticalScroll(scrollState), fontFamily = FontFamily.Monospace, fontSize = 12.sp) } } +} @Composable fun SimpleDatePicker(initialDate: LocalDate, onDateSelected: (LocalDate) -> Unit, onDismissRequest: () -> Unit) { var displayedYearMonth by remember { mutableStateOf(YearMonth.from(initialDate)) }; Dialog(onDismissRequest = onDismissRequest) { Surface(shape = MaterialTheme.shapes.medium, modifier = Modifier.padding(16.dp).widthIn(max = 320.dp), elevation = 8.dp) { Column(modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Button(onClick = { displayedYearMonth = displayedYearMonth.minusMonths(1) }) { Text("<") }; Text(text = displayedYearMonth.format(DateTimeFormatter.ofPattern("yyyy년 MM월")), style = MaterialTheme.typography.h6); Button(onClick = { displayedYearMonth = displayedYearMonth.plusMonths(1) }) { Text(">") } }; Spacer(Modifier.height(16.dp)); Row(modifier = Modifier.fillMaxWidth()) { listOf("일", "월", "화", "수", "목", "금", "토").forEach { day -> Text(text = day, modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) } }; Spacer(Modifier.height(8.dp)); val daysInMonth = displayedYearMonth.lengthOfMonth(); val firstDayOfMonth = displayedYearMonth.atDay(1).dayOfWeek.value % 7 - LazyVerticalGrid(columns = GridCells.Fixed(7), contentPadding = PaddingValues(vertical = 4.dp)) { items(firstDayOfMonth) { Box(Modifier.size(40.dp)) }; items(daysInMonth) { day -> val date = displayedYearMonth.atDay(day + 1); val isSelected = date == initialDate; Box(modifier = Modifier.size(40.dp).clip(CircleShape).background(if (isSelected) MaterialTheme.colors.primary else Color.Transparent).clickable { onDateSelected(date) }, contentAlignment = Alignment.Center) { Text(text = "${day + 1}", color = if (isSelected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface) } } } + androidx.compose.foundation.lazy.grid.LazyVerticalGrid(columns = androidx.compose.foundation.lazy.grid.GridCells.Fixed(7), contentPadding = PaddingValues(vertical = 4.dp)) { items(firstDayOfMonth) { Box(Modifier.size(40.dp)) }; items(daysInMonth) { day -> val date = displayedYearMonth.atDay(day + 1); val isSelected = date == initialDate; Box(modifier = Modifier.size(40.dp).clip(CircleShape).background(if (isSelected) MaterialTheme.colors.primary else Color.Transparent).clickable { onDateSelected(date) }, contentAlignment = Alignment.Center) { Text(text = "${day + 1}", color = if (isSelected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface) } } } } } } } @@ -233,10 +336,7 @@ fun main() = application { MaterialTheme { Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { paddingValues -> Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { - App( - onSaveTsvRequest = { period, tsvData -> val dialog = FileDialog(Frame(), "TSV 파일로 저장", FileDialog.SAVE).apply { file = "sales-report-$period.tsv"; isVisible = true }; if (dialog.directory != null && dialog.file != null) { try { Files.writeString(Paths.get(dialog.directory, dialog.file), tsvData); scope.launch { snackbarHostState.showSnackbar("TSV 파일 저장 완료") } } catch (e: Exception) { scope.launch { snackbarHostState.showSnackbar("파일 저장 실패: ${e.message}") } } } }, - onSaveCsvRequest = { appId, date, result -> val csvContent = "Metric,Count\nInstallations,${result.installations}\nRedownloads,${result.redownloads}\nUpdates,${result.updates}"; val dialog = FileDialog(Frame(), "CSV 파일로 저장", FileDialog.SAVE).apply { file = "analytics-$appId-${date.format(DateTimeFormatter.ISO_LOCAL_DATE)}.csv"; isVisible = true }; if (dialog.directory != null && dialog.file != null) { try { Files.writeString(Paths.get(dialog.directory, dialog.file), csvContent); scope.launch { snackbarHostState.showSnackbar("CSV 파일 저장 완료") } } catch (e: Exception) { scope.launch { snackbarHostState.showSnackbar("파일 저장 실패: ${e.message}") } } } } - ) + App( onSaveTsvRequest = { name, tsvData -> val dialog = FileDialog(Frame(), "TSV 파일로 저장", FileDialog.SAVE).apply { file = "$name.tsv"; isVisible = true }; if (dialog.directory != null && dialog.file != null) { try { Files.writeString(Paths.get(dialog.directory, dialog.file), tsvData); scope.launch { snackbarHostState.showSnackbar("TSV 파일 저장 완료") } } catch (e: Exception) { scope.launch { snackbarHostState.showSnackbar("파일 저장 실패: ${e.message}") } } } } ) } } }