diff --git a/src/test/kotlin/roomescape/data/DataParser.kt b/src/test/kotlin/roomescape/data/DataParser.kt deleted file mode 100644 index b0c7627d..00000000 --- a/src/test/kotlin/roomescape/data/DataParser.kt +++ /dev/null @@ -1,99 +0,0 @@ -package roomescape.data - -import io.kotest.core.spec.style.StringSpec -import org.apache.poi.xssf.usermodel.XSSFWorkbook -import java.io.File - -const val BASE_DIR = "data" -const val PARSED_REGION_POPULATION_FILE = "$BASE_DIR/region_population.txt" -const val REGION_SQL_FILE = "data/region.sql" -const val MIN_POPULATION_FOR_PER_STORE = 200_000 - -/** - * 행안부 202508 인구 동향 데이터 사용( /data/population.xlsx ) - */ -class PopulationDataSqlParser() : StringSpec({ - - val regionCodePattern = Regex("^[0-9]{10}$") - - "인구 데이터를 이용하여 지역 정보 SQL 파일로 변환하고, 추가로 $MIN_POPULATION_FOR_PER_STORE 이상의 시/군/구는 매장 데이터 생성을 위해 따로 분류한다.".config( - enabled = false - ) { - val populationXlsx = XSSFWorkbook(File("data/population.xlsx")) - val sheet = populationXlsx.getSheetAt(0) - val allRegion = mutableListOf>() - val regionsMoreThanMinPopulation = mutableListOf>() - - sheet.rowIterator().forEach { row -> - val regionCode = row.getCell(0)?.stringCellValue ?: return@forEach - - if (regionCodePattern.matches(regionCode).not()) { - return@forEach - } - val sidoCode = regionCode.substring(0, 2) - val sigunguCode = regionCode.substring(2, 5) - - val population = row.getCell(2).stringCellValue.replace(",", "") - if (Regex("^[0-9]+$").matches(population).not()) { - return@forEach - } - - val regionName = row.getCell(1).stringCellValue - if (!regionName.trim().contains(" ")) { - return@forEach - } - - val parts = regionName.split(" ") - if (parts.size < 2) { - return@forEach - } - - val sidoName = parts[0].trim() - val sigunguName = parts[1].trim() - val populationInt = population.toInt() - - if (populationInt <= 0) { - return@forEach - } - - if (populationInt >= MIN_POPULATION_FOR_PER_STORE) { - regionsMoreThanMinPopulation.add( - listOf( - regionCode, - sidoCode, - sigunguCode, - sidoName, - sigunguName, - population - ) - ) - } - allRegion.add(listOf(regionCode, sidoCode, sigunguCode, sidoName, sigunguName)) - } - - regionsMoreThanMinPopulation.filter { - val sidoName = it[3] - val sigunguName = it[4] - val sameSigungu = allRegion.filter { r -> r[3] == sidoName && r[4] == sigunguName } - !((sameSigungu.size > 1) && sameSigungu.minByOrNull { r -> r[2].toInt() }!![2] != it[2]) - }.mapIndexed { idx, values -> - "${values[0]}, ${values[1]}, ${values[2]}, ${values[3]}, ${values[4]}, ${values[5]}" - }.joinToString(separator = "\n").also { - File(PARSED_REGION_POPULATION_FILE).writeText(it) - } - - allRegion.distinctBy { it[1] to it[2] }.filter { - val sidoName = it[3] - val sigunguName = it[4] - val sameSigungu = allRegion.filter { r -> r[3] == sidoName && r[4] == sigunguName } - !((sameSigungu.size > 1) && sameSigungu.minByOrNull { r -> r[2].toInt() }!![2] != it[2]) - }.joinToString( - prefix = "INSERT INTO region(code, sido_code, sigungu_code, sido_name, sigungu_name) VALUES ", - separator = ",\n" - ) { region -> - "('${region[0]}', '${region[1]}', '${region[2]}', '${region[3]}', '${region[4]}')" - }.also { - File(REGION_SQL_FILE).writeText("${it};") - } - } -}) diff --git a/src/test/kotlin/roomescape/data/PopulationDataParser.kt b/src/test/kotlin/roomescape/data/PopulationDataParser.kt new file mode 100644 index 00000000..d511b234 --- /dev/null +++ b/src/test/kotlin/roomescape/data/PopulationDataParser.kt @@ -0,0 +1,236 @@ +package roomescape.data + +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import roomescape.common.config.next +import roomescape.store.infrastructure.persistence.StoreStatus +import roomescape.supports.randomPhoneNumber +import roomescape.supports.tsidFactory +import java.io.File +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.random.Random + +const val BASE_DIR = "data" +const val PARSED_REGION_POPULATION_FILE = "$BASE_DIR/region_population.txt" +const val REGION_SQL_FILE = "data/region.sql" +const val MIN_POPULATION_FOR_PER_STORE = 200_000 + +/** + * 행안부 202508 인구 동향 데이터 사용( /data/population.xlsx ) + */ +class PopulationDataSqlParser() { + + val regionCodePattern = Regex("^[0-9]{10}$") + + // 인구 데이터를 이용하여 지역 정보 SQL 파일로 변환하고, 추가로 $MIN_POPULATION_FOR_PER_STORE 이상의 시/군/구는 매장 데이터 생성을 위해 따로 분류한다. + fun createParsedRegionPopulationFiles() { + val populationXlsx = XSSFWorkbook(File("data/population.xlsx")) + val sheet = populationXlsx.getSheetAt(0) + val allRegion = mutableListOf>() + val regionsMoreThanMinPopulation = mutableListOf>() + + sheet.rowIterator().forEach { row -> + val regionCode = row.getCell(0)?.stringCellValue ?: return@forEach + + if (regionCodePattern.matches(regionCode).not()) { + return@forEach + } + val sidoCode = regionCode.substring(0, 2) + val sigunguCode = regionCode.substring(2, 5) + + val population = row.getCell(2).stringCellValue.replace(",", "") + if (Regex("^[0-9]+$").matches(population).not()) { + return@forEach + } + + val regionName = row.getCell(1).stringCellValue + if (!regionName.trim().contains(" ")) { + return@forEach + } + + val parts = regionName.split(" ") + if (parts.size < 2) { + return@forEach + } + + val sidoName = parts[0].trim() + val sigunguName = parts[1].trim() + val populationInt = population.toInt() + + if (populationInt <= 0) { + return@forEach + } + + if (populationInt >= MIN_POPULATION_FOR_PER_STORE) { + regionsMoreThanMinPopulation.add( + listOf( + regionCode, + sidoCode, + sigunguCode, + sidoName, + sigunguName, + population + ) + ) + } + allRegion.add(listOf(regionCode, sidoCode, sigunguCode, sidoName, sigunguName)) + } + + regionsMoreThanMinPopulation.filter { + val sidoName = it[3] + val sigunguName = it[4] + val sameSigungu = allRegion.filter { r -> r[3] == sidoName && r[4] == sigunguName } + !((sameSigungu.size > 1) && sameSigungu.minByOrNull { r -> r[2].toInt() }!![2] != it[2]) + }.mapIndexed { idx, values -> + "${values[0]}, ${values[1]}, ${values[2]}, ${values[3]}, ${values[4]}, ${values[5]}" + }.joinToString(separator = "\n").also { + File(PARSED_REGION_POPULATION_FILE).writeText(it) + } + + allRegion.distinctBy { it[1] to it[2] }.filter { + val sidoName = it[3] + val sigunguName = it[4] + val sameSigungu = allRegion.filter { r -> r[3] == sidoName && r[4] == sigunguName } + !((sameSigungu.size > 1) && sameSigungu.minByOrNull { r -> r[2].toInt() }!![2] != it[2]) + }.joinToString( + prefix = "INSERT INTO region(code, sido_code, sigungu_code, sido_name, sigungu_name) VALUES ", + separator = ",\n" + ) { region -> + "('${region[0]}', '${region[1]}', '${region[2]}', '${region[3]}', '${region[4]}')" + }.also { + File(REGION_SQL_FILE).writeText("${it};") + } + } +} + +/** + * PopulationDataSqlParser에서 전처리된 지역 + 인구 정보를 이용하여 Store 초기 데이터 SQL 생성 + */ +class StoreDataInitializer { + + val positiveWords = listOf( + "사랑", "행복", "희망", "감사", "기쁨", "미소", "축복", "선물", "평화", + "열정", "미래", "자유", "도전", "지혜", "행운" + ) + + fun createStoreDataSqlFile(creatableAdminIds: List): File { + val regions = initializeRegionWithStoreCount() + val usedStoreName = mutableListOf() + val usedBusinessRegNums = mutableListOf() + val storeSqlRows = mutableListOf() + val storeDataRows = mutableListOf() + val storeIds = mutableListOf() + + regions.forEachIndexed { idx, region -> + for (i in 0..region.storeCount) { + var address: String + var storeName: String + do { + val randomPositiveWord = positiveWords.random() + storeName = "${parseSigunguName(region.sigunguName)}${randomPositiveWord}점" + address = + "${region.sidoName} ${region.sigunguName} ${randomPositiveWord}${Random.nextInt(1, 10)}길 ${Random.nextInt(1, 100)}" + } while (usedStoreName.contains(storeName)) + usedStoreName.add(storeName) + + val contact = randomPhoneNumber() + + var businessRegNum: String + do { + businessRegNum = generateBusinessRegNum() + } while (usedBusinessRegNums.contains(businessRegNum)) + usedBusinessRegNums.add(businessRegNum) + + val createdAt = randomLocalDateTime() + val updatedAt = createdAt + + val id: Long = tsidFactory.next().also { storeIds.add(it) } + + val createdBy = creatableAdminIds.random() + + storeSqlRows.add( + "(${id}, '$storeName', '$address', '$contact', '$businessRegNum', '${region.regionCode}', '${StoreStatus.ACTIVE.name}', '$createdAt', '${createdBy}', '$updatedAt', '${createdBy}')" + ) + storeDataRows.add( + "$id, $storeName, $address, $contact, $businessRegNum, ${region.regionCode}, ${StoreStatus.ACTIVE.name}, $createdAt, $createdBy, $updatedAt, $createdBy" + ) + } + } + + File("$BASE_DIR/store_data.txt").also { + if (it.exists()) { it.delete() } + }.writeText( + storeDataRows.joinToString("\n") + ) + + return File("$BASE_DIR/store_data.sql").also { + if (it.exists()) { it.delete() } + + StringBuilder("INSERT INTO store (id, name, address, contact, business_reg_num, region_code, status, created_at, created_by, updated_at, updated_by) VALUES ") + .append(storeSqlRows.joinToString(",\n")) + .append(";") + .toString() + .also { sql -> it.writeText(sql) } + } + } +} + +private fun parseSigunguName(sigunguName: String): String { + if (sigunguName.length == 2) { + return sigunguName + } + return sigunguName.substring(0, sigunguName.length - 1) +} + +private fun randomLocalDateTime(): String { + val year = Random.nextInt(2020, 2024) + val month = Random.nextInt(1, 13) + val day = when (month) { + 2 -> Random.nextInt(1, 29) + else -> Random.nextInt(1, 31) + } + val hour = Random.nextInt(9, 19) + val minute = Random.nextInt(0, 60) + val second = Random.nextInt(0, 60) + + return LocalDateTime.of(year, month, day, hour, minute, second) + .atZone(ZoneId.systemDefault()) + .toOffsetDateTime() + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")) +} + +private fun generateBusinessRegNum(): String { + val part1 = Random.nextInt(100, 1000) + val part2 = Random.nextInt(10, 100) + val part3 = Random.nextInt(10000, 100000) + return "$part1-$part2-$part3" +} + +private fun initializeRegionWithStoreCount(): List { + return File(PARSED_REGION_POPULATION_FILE).also { + if (it.exists().not()) { + PopulationDataSqlParser().createParsedRegionPopulationFiles() + } + }.readText().lines().map { + val parts = it.split(", ") + val regionCode = parts[0] + val sidoName = parts[3] + val sigunguName = parts[4] + val storeCount: Int = (parts[5].toInt() / MIN_POPULATION_FOR_PER_STORE) + + RegionWithStoreCount( + regionCode = regionCode, + sidoName = sidoName, + sigunguName = sigunguName, + storeCount = storeCount + ) + } +} + +data class RegionWithStoreCount( + val regionCode: String, + val sidoName: String, + val sigunguName: String, + var storeCount: Int +) diff --git a/src/test/kotlin/roomescape/data/StoreDataInitializer.kt b/src/test/kotlin/roomescape/data/StoreDataInitializer.kt deleted file mode 100644 index 76152f95..00000000 --- a/src/test/kotlin/roomescape/data/StoreDataInitializer.kt +++ /dev/null @@ -1,130 +0,0 @@ -package roomescape.data - -import io.kotest.core.spec.style.StringSpec -import roomescape.common.config.next -import roomescape.supports.randomPhoneNumber -import roomescape.supports.tsidFactory -import java.io.File -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import kotlin.random.Random - -/** - * DataParser에서 전처리된 지역 + 인구 정보를 이용하여 Store 초기 데이터 생성 - */ -class StoreDataInitializer : StringSpec({ - - val positiveWords = listOf( - "사랑", "행복", "희망", "감사", "기쁨", "미소", "축복", "선물", "평화", - "열정", "미래", "자유", "도전", "지혜", "행운" - ) - - "초기 매장 데이터를 준비한다.".config(enabled = false) { - val regions = initializeRegionWithStoreCount() - val usedStoreName = mutableListOf() - val usedBusinessRegNums = mutableListOf() - val storeSqlRows = mutableListOf() - val storeDataRows = mutableListOf() - val storeIds = mutableListOf() - - regions.forEachIndexed { idx, region -> - for (i in 0..region.storeCount) { - var address: String - var storeName: String - do { - val randomPositiveWord = positiveWords.random() - storeName = "${parseSigunguName(region.sigunguName)}${randomPositiveWord}점" - address = - "${region.sidoName} ${region.sigunguName} ${randomPositiveWord}${Random.nextInt(1, 10)}길 ${Random.nextInt(1, 100)}" - } while (usedStoreName.contains(storeName)) - usedStoreName.add(storeName) - - val contact = randomPhoneNumber() - - var businessRegNum: String - do { - businessRegNum = generateBusinessRegNum() - } while (usedBusinessRegNums.contains(businessRegNum)) - usedBusinessRegNums.add(businessRegNum) - - val createdAt = randomLocalDateTime() - val updatedAt = createdAt - - val id: Long = tsidFactory.next().also { storeIds.add(it) } - - storeSqlRows.add( - "(${id}, '$storeName', '$address', '$contact', '$businessRegNum', '${region.regionCode}', '$createdAt', '$updatedAt')" - ) - storeDataRows.add( - "$id, $storeName, $address, $contact, $businessRegNum, ${region.regionCode}, $createdAt, $updatedAt" - ) - } - } - - StringBuilder("INSERT INTO store (id, name, address, contact, business_reg_num, region_code, created_at, updated_at) VALUES ") - .append(storeSqlRows.joinToString(",\n")) - .append(";") - .toString() - .also { File("$BASE_DIR/store_data.sql").writeText(it) } - - File("$BASE_DIR/store_data.txt").writeText( - storeDataRows.joinToString("\n") - ) - } -}) - -private fun parseSigunguName(sigunguName: String): String { - if (sigunguName.length == 2) { - return sigunguName - } - return sigunguName.substring(0, sigunguName.length - 1) -} - -private fun randomLocalDateTime(): String { - val year = Random.nextInt(2020, 2024) - val month = Random.nextInt(1, 13) - val day = when (month) { - 2 -> Random.nextInt(1, 29) - else -> Random.nextInt(1, 31) - } - val hour = Random.nextInt(9, 19) - val minute = Random.nextInt(0, 60) - val second = Random.nextInt(0, 60) - - return LocalDateTime.of(year, month, day, hour, minute, second) - .atZone(ZoneId.systemDefault()) - .toOffsetDateTime() - .format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")) -} - -private fun generateBusinessRegNum(): String { - val part1 = Random.nextInt(100, 1000) - val part2 = Random.nextInt(10, 100) - val part3 = Random.nextInt(10000, 100000) - return "$part1-$part2-$part3" -} - -private fun initializeRegionWithStoreCount(): List { - return File(PARSED_REGION_POPULATION_FILE).readText().lines().map { - val parts = it.split(", ") - val regionCode = parts[0] - val sidoName = parts[3] - val sigunguName = parts[4] - val storeCount: Int = (parts[5].toInt() / MIN_POPULATION_FOR_PER_STORE) - - RegionWithStoreCount( - regionCode = regionCode, - sidoName = sidoName, - sigunguName = sigunguName, - storeCount = storeCount - ) - } -} - -data class RegionWithStoreCount( - val regionCode: String, - val sidoName: String, - val sigunguName: String, - var storeCount: Int -)