diff --git a/src/test/kotlin/roomescape/data/DefaultDataInitializer.kt b/src/test/kotlin/roomescape/data/DefaultDataInitializer.kt new file mode 100644 index 00000000..eed4c74d --- /dev/null +++ b/src/test/kotlin/roomescape/data/DefaultDataInitializer.kt @@ -0,0 +1,911 @@ +package roomescape.data + +import com.github.f4b6a3.tsid.TsidFactory +import io.kotest.core.test.TestCaseOrder +import jakarta.persistence.EntityManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ActiveProfiles +import roomescape.admin.infrastructure.persistence.AdminEntity +import roomescape.admin.infrastructure.persistence.AdminPermissionLevel +import roomescape.admin.infrastructure.persistence.AdminType +import roomescape.common.config.next +import roomescape.common.util.TransactionExecutionUtil +import roomescape.payment.infrastructure.common.* +import roomescape.reservation.infrastructure.persistence.ReservationStatus +import roomescape.schedule.infrastructure.persistence.ScheduleStatus +import roomescape.supports.AdminFixture +import roomescape.supports.FunSpecSpringbootTest +import roomescape.supports.randomPhoneNumber +import roomescape.supports.randomString +import roomescape.theme.infrastructure.persistence.Difficulty +import roomescape.user.business.SIGNUP +import roomescape.user.infrastructure.persistence.UserEntity +import roomescape.user.infrastructure.persistence.UserStatus +import roomescape.user.web.UserContactResponse +import java.sql.Timestamp +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.OffsetDateTime + +@ActiveProfiles("test", "test-mysql") +abstract class AbstractDataInitializer( + val semaphore: Semaphore = Semaphore(permits = 10), +) : FunSpecSpringbootTest( + enableCleanerExtension = false +) { + @Autowired + lateinit var entityManager: EntityManager + + @Autowired + lateinit var jdbcTemplate: JdbcTemplate + + @Autowired + lateinit var transactionExecutionUtil: TransactionExecutionUtil + + @Autowired + lateinit var tsidFactory: TsidFactory + + override fun testCaseOrder(): TestCaseOrder? = TestCaseOrder.Sequential + + suspend fun initialize() { + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0") + + jdbcTemplate.query("SHOW TABLES") { rs, _ -> + rs.getString(1).lowercase() + }.forEach { + jdbcTemplate.execute("TRUNCATE TABLE $it") + } + + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1") + + this::class.java.getResource("/schema/region-data.sql")?.readText()?.let { sql -> + jdbcTemplate.execute(sql) + } + } + } + + suspend fun executeBatch(sql: String, batchArgs: List>) { + semaphore.acquire() + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + jdbcTemplate.batchUpdate(sql, batchArgs) + } + + semaphore.release() + } +} + +class DefaultDataInitializer : AbstractDataInitializer() { + + // 1. HQ Admin 추가 + // 2. Store 추가 -> CreatedBy / UpdatedBy는 HQ Admin 중 한명으로 고정 + // 3. Store Admin 추가 -> CreatedBy / UpdatedBy는 HQ Admin 본인으로 고정 + init { + lateinit var superHQAdmin: AdminEntity + + // 모든 테이블 초기화 + 지역 데이터 삽입 + HQ 관리자 1명 생성 + beforeSpec { + initialize() + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + superHQAdmin = testAuthUtil.createAdmin(AdminFixture.hqDefault.apply { + this.createdBy = this.id + this.updatedBy = this.id + }) + } + } + + context("관리자, 매장, 테마 초기 데이터 생성") { + test("각 PermissionLevel 마다 20명의 HQ 관리자 생성") { + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + AdminPermissionLevel.entries.forEach { + repeat(20) { index -> + AdminFixture.create( + account = "hq_${it.name.lowercase()}_$index", + name = randomKoreanName(), + phone = randomPhoneNumber(), + type = AdminType.HQ, + permissionLevel = it + ).apply { + this.createdBy = superHQAdmin.id + this.updatedBy = superHQAdmin.id + }.also { + entityManager.persist(it) + } + } + } + } + } + + test("전체 매장 생성") { + val storeDataInitializer = StoreDataInitializer() + val creatableAdminIds: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + "SELECT a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel IN (:permissionLevels)", + Long::class.java + ).setParameter("type", AdminType.HQ) + .setParameter( + "permissionLevels", + listOf(AdminPermissionLevel.FULL_ACCESS, AdminPermissionLevel.WRITABLE) + ) + .resultList + }.map { it.toString() } + + val sqlFile = storeDataInitializer.createStoreDataSqlFile(creatableAdminIds) + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + jdbcTemplate.execute(sqlFile.readText()) + } + } + + test("각 매장당 1명의 ${AdminPermissionLevel.FULL_ACCESS} 권한의 StoreManager 1명 + ${AdminPermissionLevel.WRITABLE}의 2명 + 나머지 권한은 3명씩 생성") { + val storeAdminCountsByPermissionLevel = mapOf( + AdminPermissionLevel.WRITABLE to 2, + AdminPermissionLevel.READ_ALL to 3, + AdminPermissionLevel.READ_SUMMARY to 3 + ) + + val storeIds: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + "SELECT s.id FROM StoreEntity s", + Long::class.java + ).resultList + }.map { it as Long } + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + storeIds.forEach { storeId -> + // StoreManager 1명 생성 + val storeManager = AdminFixture.create( + account = "$storeId", + name = randomKoreanName(), + phone = randomPhoneNumber(), + type = AdminType.STORE, + storeId = storeId, + permissionLevel = AdminPermissionLevel.FULL_ACCESS + ).apply { + this.createdBy = superHQAdmin.id + this.updatedBy = superHQAdmin.id + }.also { + entityManager.persist(it) + } + + storeAdminCountsByPermissionLevel.forEach { (permissionLevel, count) -> + repeat(count) { index -> + AdminFixture.create( + account = randomString(), + name = randomKoreanName(), + phone = randomPhoneNumber(), + type = AdminType.STORE, + storeId = storeId, + permissionLevel = permissionLevel + ).apply { + this.createdBy = storeManager.id + this.updatedBy = storeManager.id + }.also { + entityManager.persist(it) + } + } + } + entityManager.flush() + entityManager.clear() + } + } + } + + test("총 500개의 테마 생성: 지난 2년 전 부터 지금까지의 랜덤 테마 + active 상태인 1달 이내 생성 테마 10개") { + val creatableAdminIds: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + "SELECT a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel IN (:permissionLevels)", + Long::class.java + ).setParameter("type", AdminType.HQ) + .setParameter( + "permissionLevels", + listOf(AdminPermissionLevel.FULL_ACCESS, AdminPermissionLevel.WRITABLE) + ) + .resultList + } + val sql = + "INSERT INTO theme (id, name, description, thumbnail_url, is_active, available_minutes, expected_minutes_from, expected_minutes_to, price, difficulty, min_participants, max_participants, created_at, created_by, updated_at, updated_by) VALUES (?," + + "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + val batchSize = 100 + val batchArgs = mutableListOf>() + + repeat(500) { i -> + val randomDay = if (i <= 9) (1..30).random() else (1..365 * 2).random() + val randomCreatedAt: LocalDateTime = LocalDateTime.now().minusDays(randomDay.toLong()) + val randomThemeName = + (1..7).random().let { repeat -> (1..repeat).joinToString("") { randomKoreanName() } } + val availableMinutes = (6..20).random() * 10 + val expectedMinutesTo = availableMinutes - ((1..3).random() * 10) + val expectedMinutesFrom = expectedMinutesTo - ((1..2).random() * 10) + val randomPrice = (0..40).random() * 500 + val minParticipant = (1..10).random() + val maxParticipant = minParticipant + (1..10).random() + val createdBy = creatableAdminIds.random() + + batchArgs.add( + arrayOf( + tsidFactory.next(), + randomThemeName, + "$randomThemeName 설명이에요!!", + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRFiuCdwdz88l6pdfsRy1nFl0IHUVI7JMTQHg&s", + if (randomDay <= 30) true else false, + availableMinutes.toShort(), + expectedMinutesFrom.toShort(), + expectedMinutesTo.toShort(), + randomPrice, + Difficulty.entries.random().name, + minParticipant.toShort(), + maxParticipant.toShort(), + Timestamp.valueOf(randomCreatedAt), + createdBy, + Timestamp.valueOf(randomCreatedAt), + createdBy + ) + ) + } + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + jdbcTemplate.batchUpdate(sql, batchArgs) + } + } + } + } +} + +class UserDataInitializer : AbstractDataInitializer() { + val userCount = 1_000_000 + + init { + context("유저 초기 데이터 생성") { + test("$userCount 명의 회원 생성") { + val regions: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + "SELECT r.code FROM RegionEntity r", + String::class.java + ).resultList + } + + val chunkSize = 10_000 + val chunks = userCount / chunkSize + + coroutineScope { + (1..chunks).map { + launch(Dispatchers.IO) { + processUsers(chunkSize, regions) + } + }.joinAll() + } + } + + test("휴대폰 번호가 중복된 유저들에게 재배정") { + val duplicatePhoneUsers: List = + transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + """ + SELECT u FROM UserEntity u + WHERE u.phone IN ( + SELECT u2.phone FROM UserEntity u2 + GROUP BY u2.phone + HAVING COUNT(u2.id) > 1 + ) + ORDER BY u.phone, u.id + """.trimIndent(), + UserEntity::class.java + ).resultList + } + + jdbcTemplate.execute("CREATE INDEX idx_users__phone ON users (phone)") + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + var currentPhone: String? = null + duplicatePhoneUsers.forEach { user -> + if (user.phone != currentPhone) { + currentPhone = user.phone + } else { + var newPhone: String + + do { + newPhone = randomPhoneNumber() + val count: Long = entityManager.createQuery( + "SELECT COUNT(u.id) FROM UserEntity u WHERE u.phone = :phone", + Long::class.java + ).setParameter("phone", newPhone) + .singleResult + + if (count == 0L) break + } while (true) + + user.phone = newPhone + user.updatedAt = LocalDateTime.now() + entityManager.merge(user) + } + } + } + + jdbcTemplate.execute("DROP INDEX idx_users__phone ON users") + } + + test("회원 상태 변경 이력 저장") { + val userId: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + "SELECT u.id FROM UserEntity u", + Long::class.java + ).resultList + } + + coroutineScope { + userId.chunked(10_000).map { chunk -> + launch(Dispatchers.IO) { + processStatus(chunk) + } + }.joinAll() + } + } + } + } + + private suspend fun processStatus(userIds: List) { + val sql = """ + INSERT INTO user_status_history ( + id, user_id, reason, status, + created_by, updated_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + val batchArgs = mutableListOf>() + val now = LocalDateTime.now() + + userIds.forEach { userId -> + batchArgs.add( + arrayOf( + tsidFactory.next(), + userId, + SIGNUP, + UserStatus.ACTIVE.name, + userId, + userId, + Timestamp.valueOf(now), + Timestamp.valueOf(now) + ) + ) + } + + executeBatch(sql, batchArgs).also { batchArgs.clear() } + } + + private suspend fun processUsers(chunkSize: Int, regions: List) { + val sql = """ + INSERT INTO users ( + id, name, email, password, phone, region_code, status, + created_by, updated_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + val batchArgs = mutableListOf>() + + repeat(chunkSize) { + val id: Long = tsidFactory.next() + batchArgs.add( + arrayOf( + id, + randomKoreanName(), + "${randomString()}@sangdol.com", + randomString(), + randomPhoneNumber(), + regions.random(), + UserStatus.ACTIVE.name, + id, + id, + Timestamp.valueOf(LocalDateTime.now()), + Timestamp.valueOf(LocalDateTime.now()) + ) + ) + if (batchArgs.size >= 1_000) { + executeBatch(sql, batchArgs).also { batchArgs.clear() } + } + } + + if (batchArgs.isNotEmpty()) executeBatch(sql, batchArgs).also { batchArgs.clear() } + } +} + +class ScheduleDataInitializer : AbstractDataInitializer() { + init { + context("일정 초기 데이터 생성") { + test("테마 생성일 기준으로 다음 3일차, 매일 5개의 일정을 모든 매장에 생성") { + val stores: List> = getStoreWithManagers() + val themes: List> = getThemes() + val maxAvailableMinutes = themes.maxOf { it.second.toInt() } + val scheduleCountPerDay = 5 + + val startTime = LocalTime.of(10, 0) + var lastTime = startTime + val times = mutableListOf() + + repeat(scheduleCountPerDay) { + times.add(lastTime) + lastTime = lastTime.plusMinutes(maxAvailableMinutes.toLong() + 10L) + } + + coroutineScope { + themes.forEach { theme -> + launch(Dispatchers.IO) { + processTheme(theme, stores, times) + } + } + } + } + } + } + + private suspend fun processTheme( + theme: Triple, + stores: List>, + times: List + ) { + val sql = """ + INSERT INTO schedule ( + id, store_id, theme_id, date, time, status, + created_by, updated_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + + val batchArgs = mutableListOf>() + + val now = LocalDateTime.now() + stores.forEach { (storeId, adminId) -> + (1..3).forEach { dayOffset -> + val date = theme.third.toLocalDate().plusDays(dayOffset.toLong()) + + times.forEach { time -> + val scheduledAt = LocalDateTime.of(date, time) + val status = + if (scheduledAt.isAfter(now)) ScheduleStatus.AVAILABLE.name else ScheduleStatus.RESERVED.name + + batchArgs.add( + arrayOf( + tsidFactory.next(), storeId, theme.first, date, time, + status, adminId, adminId, Timestamp.valueOf(now), Timestamp.valueOf(now) + ) + ) + + if (batchArgs.size >= 500) { + executeBatch(sql, batchArgs).also { batchArgs.clear() } + } + } + } + } + + if (batchArgs.isNotEmpty()) { + executeBatch(sql, batchArgs).also { batchArgs.clear() } + } + } + + private fun getStoreWithManagers(): List> { + return transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + "SELECT a.storeId, a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel = :permissionLevel", + List::class.java + ).setParameter("type", AdminType.STORE) + .setParameter("permissionLevel", AdminPermissionLevel.FULL_ACCESS) + .resultList + }.map { + val array = it as List<*> + Pair(array[0] as Long, array[1] as Long) + } + } + + private fun getThemes(): List> { + return transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + "SELECT t._id, t.availableMinutes, t.createdAt FROM ThemeEntity t", + List::class.java + ) + .resultList + }.map { + val array = it as List<*> + Triple(array[0] as Long, array[1] as Short, array[2] as LocalDateTime) + } + } +} + +/** + * 아래의 ReservationDataInitializer 에서 사용할 임시 DTO 클래스 + */ +data class ScheduleWithThemeParticipants( + val scheduleId: Long, + val themeMinParticipants: Short, + val themeMaxParticipants: Short, +) + +class ReservationDataInitializer : AbstractDataInitializer() { + + init { + context("예약 초기 데이터 생성") { + test("${ScheduleStatus.RESERVED}인 모든 일정에 예약을 1개씩 배정한다.") { + val chunkSize = 10_000 + + val chunkedSchedules: List> = entityManager.createQuery( + "SELECT new roomescape.data.ScheduleWithThemeParticipants(s._id, t.minParticipants, t.maxParticipants) FROM ScheduleEntity s JOIN ThemeEntity t ON s.themeId = t.id WHERE s.status = :status", + ScheduleWithThemeParticipants::class.java + ).setParameter("status", ScheduleStatus.RESERVED).resultList.chunked(chunkSize) + + val chunkedUsers: List> = entityManager.createQuery( + "SELECT new roomescape.user.web.UserContactResponse(u._id, u.name, u.phone) FROM UserEntity u", + UserContactResponse::class.java + ).resultList.chunked(chunkSize) + + + coroutineScope { + chunkedSchedules.forEachIndexed { idx, schedules -> + launch(Dispatchers.IO) { + processReservation(chunkedUsers[idx % chunkedUsers.size], schedules) + } + } + } + } + } + } + + private suspend fun processReservation( + users: List, + schedules: List + ) { + val sql = """ + INSERT INTO reservation ( + id, user_id, schedule_id, + reserver_name, reserver_contact, participant_count, requirement, + status, created_at, created_by, updated_at, updated_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + + val batchArgs = mutableListOf>() + + val createdAt = LocalDateTime.now() + + schedules.forEachIndexed { idx, schedule -> + val user: UserContactResponse = users[idx % users.size] + + batchArgs.add( + arrayOf( + tsidFactory.next(), + user.id, + schedule.scheduleId, + user.name, + user.phone, + (schedule.themeMinParticipants..schedule.themeMaxParticipants).random(), + randomKoreanWords(length = (20..100).random()), + ReservationStatus.CONFIRMED.name, + Timestamp.valueOf(createdAt), + user.id, + Timestamp.valueOf(createdAt), + user.id, + ) + ) + + if (batchArgs.size >= 1_000) { + executeBatch(sql, batchArgs).also { batchArgs.clear() } + } + } + + if (batchArgs.isNotEmpty()) executeBatch(sql, batchArgs).also { batchArgs.clear() } + } +} + +class ReservationWithPrice( + themePrice: Int, + participantCount: Short, + + val reservationId: Long, + val totalPrice: Int = (themePrice * participantCount), +) + +data class PaymentWithMethods( + val id: Long, + val totalPrice: Int, + val method: PaymentMethod +) + +class PaymentDataInitializer : AbstractDataInitializer() { + companion object { + val requestedAtCache: Timestamp = Timestamp.valueOf(OffsetDateTime.now().toLocalDateTime()) + val approvedAtCache: Timestamp = Timestamp.valueOf(OffsetDateTime.now().plusSeconds(5).toLocalDateTime()) + val supportedPaymentMethods = listOf(PaymentMethod.TRANSFER, PaymentMethod.EASY_PAY, PaymentMethod.CARD) + val supportedCardType = listOf(CardType.CREDIT, CardType.CHECK) + + val settlementStatus = "COMPLETED" + + val paymentSql: String = """ + INSERT INTO payment( + id, reservation_id, type, method, + payment_key, order_id, total_amount, status, + requested_at, approved_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + + val paymentDetailSql: String = """ + INSERT INTO payment_detail( + id, payment_id, supplied_amount, vat + ) VALUES (?, ?, ?, ?) + """.trimIndent() + + val paymentCardDetailSql: String = """ + INSERT INTO payment_card_detail( + id, issuer_code, card_type, owner_type, + amount, card_number, approval_number, installment_plan_months, + is_interest_free, easypay_provider_code, easypay_discount_amount + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + + val paymentEasypayPrepaidDetailSql: String = """ + INSERT INTO payment_easypay_prepaid_detail( + id, easypay_provider_code, amount, discount_amount + ) VALUES (?, ?, ?, ?) + """.trimIndent() + + val paymentBankTransferDetailSql: String = """ + INSERT INTO payment_bank_transfer_detail( + id, bank_code, settlement_status + ) VALUES (?, ?, ?) + """.trimIndent() + } + + init { + context("결제 데이터 초기화") { + test("모든 예약에 맞춰 1:1로 결제 및 결제 상세 데이터를 생성한다.") { + val allReservations: List = entityManager.createQuery( + "SELECT t.price, r.participantCount, r._id FROM ReservationEntity r JOIN ScheduleEntity s ON s._id = r.scheduleId JOIN ThemeEntity t ON t.id = s.themeId", + List::class.java + ).resultList.map { + val items = it as List<*> + ReservationWithPrice( + themePrice = items[0] as Int, + participantCount = items[1] as Short, + reservationId = items[2] as Long + ) + } + + coroutineScope { + allReservations.chunked(10_000).forEach { reservations -> + launch(Dispatchers.IO) { + processPaymentAndDefaultDetail(reservations) + } + } + } + } + + test("기존 결제 데이터에 상세 정보(계좌이체, 카드, 간편결제) 데이터를 생성한다.") { + val allPayments: List = entityManager.createQuery( + "SELECT new roomescape.data.PaymentWithMethods(pd._id, p.totalAmount, p.method) FROM PaymentEntity p JOIN PaymentDetailEntity pd ON p._id = pd.paymentId", + PaymentWithMethods::class.java + ).resultList + + coroutineScope { + allPayments.chunked(10_000).forEach { payments -> + launch(Dispatchers.IO) { + processPaymentDetail(payments) + } + } + } + } + + test("null 컴파일 에러를 피하기 위해 문자열 null로 임시 지정한 컬럼을 변경한다.") { + jdbcTemplate.execute("CREATE INDEX idx_payment_card_detail_easypay ON payment_card_detail (easypay_provider_code)") + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + jdbcTemplate.update( + "UPDATE payment_card_detail SET easypay_provider_code = ? WHERE easypay_provider_code = ?", + null, + "null" + ) + } + + jdbcTemplate.execute("DROP INDEX idx_payment_card_detail_easypay ON payment_card_detail") + } + } + } + + private suspend fun processPaymentAndDefaultDetail(reservations: List) { + val paymentBatchArgs = mutableListOf>() + val detailBatchArgs = mutableListOf>() + + reservations.forEachIndexed { idx, reservations -> + val id = tsidFactory.next() + val totalPrice = reservations.totalPrice + paymentBatchArgs.add( + arrayOf( + id, + reservations.reservationId, + PaymentType.NORMAL.name, + randomPaymentMethod(), + randomString(length = 64), + randomString(length = 20), + totalPrice, + PaymentStatus.DONE.name, + requestedAtCache, + approvedAtCache, + ) + ) + if (paymentBatchArgs.size >= 1_000) { + executeBatch(paymentSql, paymentBatchArgs).also { paymentBatchArgs.clear() } + } + + val suppliedAmount: Int = (totalPrice * 0.9).toInt() + val vat: Int = (totalPrice - suppliedAmount) + + detailBatchArgs.add( + arrayOf( + tsidFactory.next(), + id, + suppliedAmount, + vat + ) + ) + + if (detailBatchArgs.size >= 1_000) { + executeBatch(paymentDetailSql, detailBatchArgs).also { detailBatchArgs.clear() } + } + } + + if (paymentBatchArgs.isNotEmpty()) { + executeBatch(paymentSql, paymentBatchArgs).also { paymentBatchArgs.clear() } + } + if (detailBatchArgs.isNotEmpty()) { + executeBatch(paymentDetailSql, detailBatchArgs).also { detailBatchArgs.clear() } + } + } + + private suspend fun processPaymentDetail(payments: List) { + val transferBatchArgs = mutableListOf>() + val cardBatchArgs = mutableListOf>() + val easypayPrepaidBatchArgs = mutableListOf>() + + payments.forEach { payment -> + val totalPrice = payment.totalPrice + val randomDiscountAmount = + if (totalPrice < 100 || Math.random() < 0.8) 0 else ((100..totalPrice).random() / 100) * 100 + val amount = totalPrice - randomDiscountAmount + + when (payment.method) { + PaymentMethod.TRANSFER -> { + transferBatchArgs.add( + arrayOf( + payment.id, + BankCode.entries.random().name, + settlementStatus + ) + ) + if (transferBatchArgs.size >= 1_000) { + executeBatch(paymentBankTransferDetailSql, transferBatchArgs).also { transferBatchArgs.clear() } + } + } + + PaymentMethod.EASY_PAY -> { + if (Math.random() <= 0.7) { + cardBatchArgs.add( + arrayOf( + payment.id, + CardIssuerCode.entries.random().name, + supportedCardType.random().name, + CardOwnerType.PERSONAL.name, + amount, + randomCardNumber(), + randomApprovalNumber(), + randomInstallmentPlanMonths(amount), + true, + EasyPayCompanyCode.entries.random().name, + randomDiscountAmount + ) + ) + + if (cardBatchArgs.size >= 1_000) { + executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() } + } + + } else { + easypayPrepaidBatchArgs.add( + arrayOf( + payment.id, + EasyPayCompanyCode.entries.random().name, + amount, + randomDiscountAmount, + ) + ) + + if (easypayPrepaidBatchArgs.size >= 1_000) { + executeBatch(paymentEasypayPrepaidDetailSql, easypayPrepaidBatchArgs).also { easypayPrepaidBatchArgs.clear() } + } + } + } + + PaymentMethod.CARD -> { + cardBatchArgs.add( + arrayOf( + payment.id, + CardIssuerCode.entries.random().name, + supportedCardType.random().name, + CardOwnerType.PERSONAL.name, + totalPrice, + randomCardNumber(), + randomApprovalNumber(), + randomInstallmentPlanMonths(totalPrice), + true, + "null", + 0, + ) + ) + + if (cardBatchArgs.size >= 1_000) { + executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() } + } + } + + else -> return@forEach + } + } + if (transferBatchArgs.isNotEmpty()) { + executeBatch(paymentBankTransferDetailSql, transferBatchArgs).also { transferBatchArgs.clear() } + } + if (cardBatchArgs.isNotEmpty()) { + executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() } + } + if (easypayPrepaidBatchArgs.isNotEmpty()) { + executeBatch(paymentEasypayPrepaidDetailSql, easypayPrepaidBatchArgs).also { easypayPrepaidBatchArgs.clear() } + } + } + + private suspend fun randomPaymentMethod(): String { + val random = Math.random() + + return if (random <= 0.5) { + PaymentMethod.EASY_PAY.name + } else if (random <= 0.9) { + PaymentMethod.CARD.name + } else { + PaymentMethod.TRANSFER.name + } + } + + private suspend fun randomCardNumber(): String { + return "${(10000000..99999999).random()}****${(100..999).random()}*" + } + + private suspend fun randomApprovalNumber(): String { + return "${(10000000..99999999).random()}" + } + + private suspend fun randomInstallmentPlanMonths(amount: Int): Int { + return if (amount < 50_000 || Math.random() < 0.9) { + 0 + } else { + (1..6).random() + } + } +} + +fun randomKoreanName(): String { + val lastNames = listOf( + "김", "이", "박", "최", "정", "강", "조", "윤", "장", "임", + "오", "서", "신", "권", "황", "안", "송", "류", "홍", "전", "고", "문", "양", "손", "배", "백", "허", "유", "남", "심", "노" + ) + + return "${lastNames.random()}${if (Math.random() < 0.1) randomKoreanWords(1) else randomKoreanWords(2)}" +} + +fun randomKoreanWords(length: Int = 1): String { + val words = listOf( + "가", "나", "다", "라", "마", "바", "사", "아", "자", "차", + "카", "타", "파", "하", "강", "민", "서", "윤", "우", "진", + "현", "지", "은", "혜", "수", "영", "주", "원", "희", "경", + "선", "아", "나", "다", "라", "마", "바", "사", "아", "자", + "차", "카", "타", "파", "하", + ) + + return (1..length).joinToString("") { words.random() } +}