2 Commits f31dfba6a6 ... 82ee9f8702

Author SHA1 Message Date
  masarifyuli 82ee9f8702 migration file and datasource 1 month ago
  masarifyuli 4a1a7dc1a0 update migration datasource 1 month ago

+ 1 - 1
config/general-setting.yml

@@ -182,7 +182,7 @@ redirectUrl:
 - http://localhost:4200/oauth2redirect
 - https://cmpd.telmessenger.com/oauth2redirect
 - https://app.insomnia.rest/oauth/redirect
-dataKey: RMyvjTZXDMBnCr3jymw9oEKiJUoCWfNcVgeIqzSvkOSyrrTcWRDz8u2NZSZUGeZ7a%2F8b89eIG3GcoHX0NtFZGeaqFUNFasg1zxDUSJKy7HpgxW7ca69yD0ZUlMQvlOrI
+dataKey: jRA4iyjy1ZCaIdlJBe6Cm%2BRPGb%2BwcO85%2FWR7Z9%2B8AFX7dJUSJjVnyr4JXSEyayg6i74%2F7IcBsn9iY%2BJ1ArmTtLSCD0plJfr%2B1S6%2ByBesS3dVTDSwlrEAuv2Pm%2BhYLmQ6f560DMvqCuahhKkkybkl5g%3D%3D
 
 #database: 
 #  type: sqlserver

+ 155 - 101
config/migration.yml

@@ -1,101 +1,155 @@
-organization:
-  id: history.organization_id
-  code: organization_code
-  name: organization_name
-  description: description
-  email: organization_email
-  emailOnOverBudget: sendemail_overbudget
-  parent_id: history.parent_organization_id
-  structure: history.organization_id_structure
-  appliedDate: history.app_datetime
-  expiredDate: history.exp_datetime
-  cpid: history.organization_id
-costCenter:
-  id: history.costcenter_id
-  code: costcenter_code
-  name: costcenter_name
-  description: description
-  email: costcenter_email
-  parent_id: history.parent_costcenter_id
-  structure: history.costcenter_id_structure
-  appliedDate: history.app_datetime
-  expiredDate: history.exp_datetime
-pbx:
-  code: pbx_code
-  name: description
-  ipPbx: ip_pbx
-  pbxSupporter: pbx_supporter
-  sharePath: share_path
-#  appliedDate: ext_start
-#  expiredDate: ext_end
-trunk:
-  id: history.trunk_id
-  code: trunk_code
-  name: description
-  pbx_id: location_code
-  direction: cap_direction
-  appliedDate: history.app_datetime
-  expiredDate: history.exp_datetime
-callTransaction:
-  direction: direction
-  startOfCall: start_of_call
-  duration: duration
-  extension: ext_used
-  pin: pin
-  phoneUser_id: phoneuser_code
-  extTransferFrom: ext_transfer_from
-  extTransferTo: ext_transfer_to
-  number: dialed_number
-  callerNumber: caller_number
-  accessNumber: access_number
-  organization_id: organization_code
-  costCenter_id: costcenter_code
-  cost: cost_1
-  service: cost_service
-  tax: cost_tax1
-  discount: cost_adjustment
-  currency: currency
-  pbx_id: pbx_code
-  terminationCode: call_termination
-  redirectReason: redirect_reason
-  account_id: account_code
-  trunk_id: trunk_code
-webUser:
-  username: login_id
-  password: login_password
-  name: description
-  phoneUser_id: phoneuser_code
-  loginPin: used_pin
-phoneUser:
-  id: history.phoneuser_id
-  code: phoneuser_code
-  name: phoneuser_name
-  email: phoneuser_email
-  whatsapp: phoneuser_mobile
-  position: position
-  emailOnOverBudget: sendemail_overbudget
-  pin: history.phoneuser_pin
-  extension: history.default_extension
-  organization_id: history.organization_code
-  costCenter_id: history.costcenter_code
-  pbx_id: history.pbx_code
-  pbx.list: group.pbx_list
-  appliedDate: history.app_datetime
-  expiredDate: history.exp_datetime
-  budget.maxCost: history.max_cost
-  budget.warnCost: history.warn_cost
-corcos:
-  command: command
-  name: short_desc
-  description: description
-provider:
-  code: provider_prefix
-  name: description
-  description: description
-account:
-  id: history.account_id
-  code: account_code
-  name: history.account_number
-  description: description
-  appliedDate: history.app_datetime
-  expiredDate: history.exp_datetime
+migration:
+  type: FILE
+  sourceDatabase:
+    type: sqlserver
+    host: 192.168.100.25
+    port: 1433
+    name: cpcimb
+    username: sa
+    password: D4tacom!
+    properties: encrypt=true;trustServerCertificate=true;
+  target: null
+  schema:
+  - target: pbx
+    table: pbxlist
+    history: null
+    group: null
+    attribute:
+      code: pbx_code
+      name: description
+      ipPbx: ip_pbx
+      pbxSupporter: pbx_supporter
+      sharePath: share_path
+      appliedDate: ext_start
+      expiredDate: ext_end
+  - target: corcos
+    table: womastercorcos
+    history: null
+    group: null
+    attribute:
+      command: command
+      name: short_desc
+      description: description
+  - target: provider
+    table: provider
+    history: null
+    group: null
+    attribute:
+      code: provider_prefix
+      name: description
+      description: description
+  - target: account
+    table: account
+    history: accounthistory
+    group: null
+    attribute:
+      id: history.account_id
+      code: account_code
+      name: history.account_number
+      description: description
+      appliedDate: history.app_datetime
+      expiredDate: history.exp_datetime
+  - target: trunk
+    table: trunk
+    history: trunkhistory
+    group: null
+    attribute:
+      id: history.trunk_id
+      code: trunk_code
+      name: description
+      pbx_id: location_code
+      direction: cap_direction
+      appliedDate: history.app_datetime
+      expiredDate: history.exp_datetime
+  - target: costCenter
+    table: costcenter
+    history: costcenterhistory
+    group: null
+    attribute:
+      id: history.costcenter_id
+      code: costcenter_code
+      name: costcenter_name
+      description: description
+      email: costcenter_email
+      parent_id: history.parent_costcenter_id
+      structure: history.costcenter_id_structure
+      appliedDate: history.app_datetime
+      expiredDate: history.exp_datetime
+  - target: organization
+    table: organization
+    history: organizationhistory
+    group: null
+    attribute:
+      id: history.organization_id
+      code: organization_code
+      name: organization_name
+      description: description
+      email: organization_email
+      emailOnOverBudget: sendemail_overbudget
+      parent_id: history.parent_organization_id
+      structure: history.organization_id_structure
+      appliedDate: history.app_datetime
+      expiredDate: history.exp_datetime
+      cpid: history.organization_id
+  - target: phoneUser
+    table: phoneuser
+    history: phoneuserhistory
+    group: phoneuserextlist
+    attribute:
+      id: history.phoneuser_id
+      code: phoneuser_code
+      name: phoneuser_name
+      email: phoneuser_email
+      whatsapp: phoneuser_mobile
+      position: position
+      emailOnOverBudget: sendemail_overbudget
+      pin: history.phoneuser_pin
+      extension: history.default_extension
+      organization_id: history.organization_code
+      costCenter_id: history.costcenter_code
+      pbx_id: history.pbx_code
+      appliedDate: history.app_datetime
+      expiredDate: history.exp_datetime
+      pbx__list: group.pbx_list
+      budget__maxCost: history.max_cost
+      budget__warnCost: history.warn_cost
+  - target: callTransaction
+    table: calltransaction_20260127
+    history: null
+    group: null
+    attribute:
+      direction: direction
+      startOfCall: start_of_call
+      duration: duration
+      extension: ext_used
+      pin: pin
+      phoneUser_id: phoneuser_code
+      extTransferFrom: ext_transfer_from
+      extTransferTo: ext_transfer_to
+      number: dialed_number
+      callerNumber: caller_number
+      accessNumber: access_number
+      organization_id: organization_code
+      costCenter_id: costcenter_code
+      cost: cost_1
+      service: cost_service
+      tax: cost_tax1
+      discount: cost_adjustment
+      currency: currency
+      pbx_id: pbx_code
+      terminationCode: call_termination
+      redirectReason: redirect_reason
+      account_id: account_code
+      trunk_id: trunk_code
+  - target: webUser
+    table: usersetup
+    history: null
+    group: null
+    attribute:
+      username: login_id
+      password: login_password
+      name: description
+      phoneUser_id: phoneuser_code
+      loginPin: used_pin
+  lastTarget: pbx;corcos;provider;account;trunk;costCenter;organization;phoneUser;callTransaction;webUser
+  lastAction: '2026-03-02 16:31:09'

+ 101 - 0
config/migration__.yml

@@ -0,0 +1,101 @@
+organization:
+  id: history.organization_id
+  code: organization_code
+  name: organization_name
+  description: description
+  email: organization_email
+  emailOnOverBudget: sendemail_overbudget
+  parent_id: history.parent_organization_id
+  structure: history.organization_id_structure
+  appliedDate: history.app_datetime
+  expiredDate: history.exp_datetime
+  cpid: history.organization_id
+costCenter:
+  id: history.costcenter_id
+  code: costcenter_code
+  name: costcenter_name
+  description: description
+  email: costcenter_email
+  parent_id: history.parent_costcenter_id
+  structure: history.costcenter_id_structure
+  appliedDate: history.app_datetime
+  expiredDate: history.exp_datetime
+pbx:
+  code: pbx_code
+  name: description
+  ipPbx: ip_pbx
+  pbxSupporter: pbx_supporter
+  sharePath: share_path
+#  appliedDate: ext_start
+#  expiredDate: ext_end
+trunk:
+  id: history.trunk_id
+  code: trunk_code
+  name: description
+  pbx_id: location_code
+  direction: cap_direction
+  appliedDate: history.app_datetime
+  expiredDate: history.exp_datetime
+callTransaction:
+  direction: direction
+  startOfCall: start_of_call
+  duration: duration
+  extension: ext_used
+  pin: pin
+  phoneUser_id: phoneuser_code
+  extTransferFrom: ext_transfer_from
+  extTransferTo: ext_transfer_to
+  number: dialed_number
+  callerNumber: caller_number
+  accessNumber: access_number
+  organization_id: organization_code
+  costCenter_id: costcenter_code
+  cost: cost_1
+  service: cost_service
+  tax: cost_tax1
+  discount: cost_adjustment
+  currency: currency
+  pbx_id: pbx_code
+  terminationCode: call_termination
+  redirectReason: redirect_reason
+  account_id: account_code
+  trunk_id: trunk_code
+webUser:
+  username: login_id
+  password: login_password
+  name: description
+  phoneUser_id: phoneuser_code
+  loginPin: used_pin
+phoneUser:
+  id: history.phoneuser_id
+  code: phoneuser_code
+  name: phoneuser_name
+  email: phoneuser_email
+  whatsapp: phoneuser_mobile
+  position: position
+  emailOnOverBudget: sendemail_overbudget
+  pin: history.phoneuser_pin
+  extension: history.default_extension
+  organization_id: history.organization_code
+  costCenter_id: history.costcenter_code
+  pbx_id: history.pbx_code
+  pbx.list: group.pbx_list
+  appliedDate: history.app_datetime
+  expiredDate: history.exp_datetime
+  budget.maxCost: history.max_cost
+  budget.warnCost: history.warn_cost
+corcos:
+  command: command
+  name: short_desc
+  description: description
+provider:
+  code: provider_prefix
+  name: description
+  description: description
+account:
+  id: history.account_id
+  code: account_code
+  name: history.account_number
+  description: description
+  appliedDate: history.app_datetime
+  expiredDate: history.exp_datetime

+ 7 - 3
src/main/kotlin/com/datacomsolusindo/migration/MigrationApplication.kt

@@ -2,6 +2,7 @@ package com.datacomsolusindo.migration
 
 import com.datacomsolusindo.cpx_shared_code.utility.AppListener
 import com.datacomsolusindo.cpx_shared_code.utility.SimpleLogger
+import com.datacomsolusindo.cpx_shared_code.utility.Util
 import kotlinx.coroutines.runBlocking
 import org.springframework.boot.autoconfigure.SpringBootApplication
 import org.springframework.boot.autoconfigure.domain.EntityScan
@@ -37,13 +38,17 @@ fun main(args: Array<String>) {
         .listeners(AppListener("migration"))
         .run(
             *args,
-            "--spring.config.location=classpath:/application.yml, ./config/general-setting.yml"
+            "--spring.config.location=classpath:/application.yml, " +
+                    "./config/general-setting.yml, " +
+                    "./config/migration.yml"
         )
 }
 
 @Suppress("UNCHECKED_CAST")
 @Component
-class AppEvent(val migrationService: MigrationService) {
+class AppEvent(
+    val migrationService: MigrationService
+) {
     private val logger = SimpleLogger.getLogger(this::class.java)
 
     @EventListener(ApplicationReadyEvent::class)
@@ -52,7 +57,6 @@ class AppEvent(val migrationService: MigrationService) {
         val folder = Paths.get("migration")
         migrationService.startInsertWorker()
         migrationService.scanAndQueue(folder)
-
 //        exitProcess(1)
     }
 

+ 93 - 0
src/main/kotlin/com/datacomsolusindo/migration/MigrationData.kt

@@ -0,0 +1,93 @@
+package com.datacomsolusindo.migration
+
+import com.datacomsolusindo.cpx_shared_code.utility.SimpleLogger
+import com.datacomsolusindo.cpx_shared_code.utility.Util
+import org.springframework.beans.factory.annotation.Qualifier
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.boot.context.properties.ConfigurationProperties
+import org.springframework.boot.jdbc.DataSourceBuilder
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.context.annotation.Primary
+import org.springframework.jdbc.core.JdbcTemplate
+import org.springframework.stereotype.Service
+import javax.sql.DataSource
+import kotlin.time.measureTimedValue
+
+@Configuration
+class PrimaryDbConfig {
+
+    @Bean
+    @Primary
+    @ConfigurationProperties("spring.datasource")
+    fun dataSource(): DataSource {
+        val db = Util.generalConfig!!["database"] as Map<String, Any>
+        return DataSourceBuilder.create()
+            .driverClassName("com.microsoft.sqlserver.jdbc.SQLServerDriver")
+            .url(
+                "jdbc:sqlserver://${db["host"]}:${db["port"]};" +
+                        "databaseName=${db["name"]};${db["properties"]}"
+            )
+            .username(db["username"].toString())
+            .password(db["password"].toString())
+            .build()
+    }
+
+}
+
+@Configuration
+class MigrationSqlServerConfig(
+    private val migrationSettingService: MigrationSettingService
+) {
+
+    @Bean
+    @ConditionalOnProperty(name = ["migration.type"], havingValue = "DATABASE")
+    fun migrationDataSource(): DataSource {
+
+        val db = migrationSettingService.sourceDatabase
+
+        return DataSourceBuilder.create()
+            .driverClassName("com.microsoft.sqlserver.jdbc.SQLServerDriver")
+            .url(
+                "jdbc:sqlserver://${db["host"]}:${db["port"]};" +
+                        "databaseName=${db["name"]};${db["properties"]}"
+            )
+            .username(db["username"].toString())
+            .password(db["password"].toString())
+            .build()
+    }
+
+    @Bean
+    @ConditionalOnProperty(name = ["migration.type"], havingValue = "DATABASE")
+    fun migrationJdbcTemplate(
+        @Qualifier("migrationDataSource") ds: DataSource
+    ): JdbcTemplate {
+        return JdbcTemplate(ds)
+    }
+}
+
+@Service
+class SourceDataMigrationService(@param:Qualifier("migrationJdbcTemplate") private val migrationJdbcTemplate: JdbcTemplate?) {
+    private val logger = SimpleLogger.getLogger(this::class.java)
+
+    fun getData(table: String, column: List<String>): MutableList<MutableMap<String, Any?>> {
+        return if (table.isBlank()) mutableListOf() else {
+            val process = measureTimedValue {
+                try {
+                    migrationJdbcTemplate?.queryForList(
+                        "SELECT * FROM $table"
+                    ) ?: mutableListOf()
+                } catch (e: Exception) {
+                    logger.error("failed get data $table", e)
+                    mutableListOf()
+                }
+            }
+            logger.info(
+                "get source data from table $table ${process.value.size} " +
+                        "takes time ${process.duration.inWholeMilliseconds}ms"
+            )
+            process.value
+        }
+    }
+
+}

+ 148 - 181
src/main/kotlin/com/datacomsolusindo/migration/MigrationEntity.kt

@@ -1,3 +1,5 @@
+@file:Suppress("UNCHECKED_CAST")
+
 package com.datacomsolusindo.migration
 
 import com.datacomsolusindo.cpx_shared_code.entity.Account
@@ -48,181 +50,146 @@ class MigrationEntity(val passwordEncoder: PasswordEncoder, val queryNativeServi
 
     private val logger = SimpleLogger.getLogger(this::class.java)
 
-//    fun <T> dataToMap(
-//        clazz: Class<T>,
-//        fields: Map<String, String>,
-//        unique: String,
-//        rootFile: File,
-//        historyFile: File? = null,
-//        groupFile: File? = null
-//    ): List<MutableMap<String, Any?>> {
-//        logger.info("prepare data migration class ${clazz.simpleName}")
-//        val process = measureTimedValue {
-//            val historyData = historyFile?.let { readQueryDataToMap(it) }
-//            val groupData = groupFile?.let { readQueryDataToMap(it) }
-//            val rootData = readQueryDataToMap(rootFile)
-//            val mapRootData = rootData.mapIndexed { index, map ->
-//                val data: MutableMap<String, Any?> = mutableMapOf()
-//                val fieldRoots = fields.toList().filterNot { f -> f.second.contains(".") }
-//                val joinRoots = fields.toList().filter { f -> f.second.contains(".") }
-//
-//                fieldRoots.forEach { f ->
-//                    data[f.first] = map[f.second]
-//                }
-//                joinRoots.sortedByDescending { it.second }.forEach { f ->
-//                    data[f.first] = when {
-//                        f.second.startsWith("history.") ->
-//                            getValueAnotherFile(fields, historyData, unique, data, f.first, f.second, "history")
-//
-//                        f.second.toString().startsWith("group.") ->
-//                            getValueAnotherFile(fields, groupData, unique, data, f.first, f.second, "group")
-//
-//                        else -> map[f.second]
-//                    }
-//                }
-//                data
-//            }
-//            mapRootData
-//        }
-//        logger.info("finish prepare data [${process.value.size}] migration class ${clazz.simpleName} takes time ${process.duration.inWholeMilliseconds}ms")
-//        return process.value
-//    }
-//
-//    private fun getValueAnotherFile(
-//        fields: Map<String, String>,
-//        dataFile: List<Map<String, Any?>>?,
-//        unique: String,
-//        rootData: Map<String, Any?>,
-//        keyRoot: String,
-//        valRoot: String,
-//        key: String
-//    ): Any? {
-//        val uniqueField = dataFile?.firstOrNull()?.let {
-//            if (it.any { a -> a.key == fields[unique] }) unique else "id"
-//        } ?: "id"
-//        val fieldUnique = fields[uniqueField]!!.split(".").last()
-//
-//        val value = dataFile?.firstOrNull { f ->
-//            f[fieldUnique].toString() == rootData[uniqueField].toString()
-//        }?.get(valRoot.replace("${key}.", ""))
-//
-//        return if (keyRoot.contains("_")) value?.let { id ->
-//            dataFile.firstOrNull { f ->
-//                f[fields["id"]!!.split(".").last()].toString() == id.toString()
-//            }?.get(fields["code"]) ?: value
-//        } else value
-//    }
-//
-//    private fun readQueryDataToMap(file: File): List<Map<String, Any?>> {
-//        val map = measureTimedValue {
-//            file.bufferedReader(StandardCharsets.UTF_8)
-//                .readLines().filter {
-//                    it.startsWith("INSERT")
-//                }.mapNotNull {
-//                    try {
-//                        insertSqlToMap(it)
-//                    } catch (_: Exception) {
-//                        null
-//                    }
-//                }
-//        }
-//        logger.info("migration read query data ${map.value.size} takes time ${map.duration.inWholeMilliseconds}")
-//        return map.value
-//    }
-
-    fun <T> dataToMap(
+    private fun <T> dataToMap(
         clazz: Class<T>,
         fields: Map<String, String>,
-        unique: String,
         rootFile: File,
         historyFile: File? = null,
         groupFile: File? = null
-    ): List<MutableMap<String, Any?>> {
-
-        logger.info("prepare data migration class ${clazz.simpleName}")
-
-        val process = measureTimedValue {
+    ) {
+        processData(
+            clazz,
+            fields,
+            rootData = readQueryDataToMap(rootFile) as MutableList<MutableMap<String, Any?>>,
+            historyData = historyFile?.let { readQueryDataToMap(it) as MutableList<MutableMap<String, Any?>> },
+            groupData = groupFile?.let { readQueryDataToMap(it) as MutableList<MutableMap<String, Any?>> }
+        )
+    }
 
-            // --- Read Files ---
-            val historyData = historyFile?.let { readQueryDataToMap(it) }
-            val groupData = groupFile?.let { readQueryDataToMap(it) }
-            val rootData = readQueryDataToMap(rootFile)
+    private fun <T> dataToMapWithDataSource(
+        clazz: Class<T>,
+        fields: Map<String, String>,
+        rootData: MutableList<MutableMap<String, Any?>>,
+        historyData: MutableList<MutableMap<String, Any?>>? = null,
+        groupData: MutableList<MutableMap<String, Any?>>? = null
+    ) {
+        processData(
+            clazz,
+            fields,
+            rootData,
+            historyData,
+            groupData
+        )
+    }
 
-            // --- Pre-calc field names (avoid split(".") repeatedly) ---
-            val fieldMapping = fields.mapValues { it.value.substringAfterLast(".") }
-            val uniqueField = fieldMapping[unique] ?: "id"
-            val uniqueFieldId = fieldMapping["id"]
+    private fun <T> processData(
+        clazz: Class<T>,
+        fields: Map<String, String>,
+        rootData: MutableList<MutableMap<String, Any?>>,
+        historyData: MutableList<MutableMap<String, Any?>>? = null,
+        groupData: MutableList<MutableMap<String, Any?>>? = null
+    ) {
+        val fieldMapping = fields.mapValues { it.value.substringAfterLast(".") }
+        val uniqueField = fieldMapping["code"] ?: "id"
+        val uniqueFieldId = fieldMapping["id"]
+
+        val historyIndex = buildHistoryIndex(historyData, uniqueField, uniqueFieldId)
+        val groupIndex = buildGroupIndex(groupData, uniqueFieldId ?: uniqueField)
+
+        val fieldRoots = fields.filterValues { !it.contains(".") }
+        val joinRoots = fields.filterValues { it.contains(".") }
+
+        val chunkData = rootData.chunked(1000)
+        logger.info("data migration class ${clazz.simpleName} chunk data ${chunkData.size}")
+        chunkData.forEachIndexed { _, data ->
+            val dataMap = data.map { row ->
+                buildRow(
+                    row,
+                    fieldRoots,
+                    joinRoots,
+                    historyIndex,
+                    groupIndex,
+                    uniqueField,
+                    uniqueFieldId
+                )
+            }.map { postProcessPassword(it) }
+            queueInsertData.put((clazz as Class<out BaseEntity>) to dataMap)
+        }
+    }
 
-            // --- Create Indexes (O(n)) ---
-            val historyIndex = historyData?.groupBy { it[uniqueField] }?.mapValues { (_, items) ->
+    private fun buildHistoryIndex(
+        historyData: List<Map<String, Any?>>?,
+        uniqueField: String,
+        uniqueFieldId: String?
+    ): Map<Any?, Map<String, Any?>>? {
+        if (historyData == null || uniqueFieldId == null) return null
+        return historyData
+            .groupBy { it[uniqueField] }
+            .mapValues { (_, items) ->
                 items.maxByOrNull {
-                    it[uniqueFieldId!!.removePrefix("history.")].toString().toInt()
-                }
+                    it[uniqueFieldId.removePrefix("history.")]
+                        ?.toString()
+                        ?.toIntOrNull() ?: 0
+                } as Map<String, Any?>
             }
-            //?.associateBy { it[uniqueField]?.toString() }
-            val groupIndex = groupData?.associateBy { it[uniqueFieldId ?: uniqueField]?.toString() }
-
-            val fieldRoots = fields.filter { !it.value.contains(".") }
-            val joinRoots = fields.filter { it.value.contains(".") }
-                .toList().sortedByDescending { it.second }.toMap()
+    }
 
-            rootData.mapIndexed { index, row ->
-                val data = mutableMapOf<String, Any?>()
+    private fun buildGroupIndex(groupData: List<Map<String, Any?>>?, key: String): Map<String?, Map<String, Any?>>? {
+        return groupData?.associateBy { it[key]?.toString() }
+    }
 
-                // --- Direct Fields ---
-                fieldRoots.forEach { (targetKey, sourceKey) ->
-                    data[targetKey] = row[sourceKey]
+    private fun buildRow(
+        row: Map<String, Any?>,
+        fieldRoots: Map<String, String>,
+        joinRoots: Map<String, String>,
+        historyIndex: Map<Any?, Map<String, Any?>>?,
+        groupIndex: Map<String?, Map<String, Any?>>?,
+        uniqueField: String,
+        uniqueFieldId: String?
+    ): MutableMap<String, Any?> {
+        val data = mutableMapOf<String, Any?>()
+        // Direct fields
+        fieldRoots.forEach { (target, source) -> data[target] = row[source] }
+        // Join fields
+        joinRoots.forEach { (target, sourceFull) ->
+
+            val value = when {
+                sourceFull.startsWith("history.") -> {
+                    val key = sourceFull.removePrefix("history.")
+                    historyIndex
+                        ?.get(row[uniqueField])
+                        ?.get(key.substringAfterLast("."))
                 }
 
-                // --- Join Fields ---
-                joinRoots.forEach { (targetKey, sourceKeyFull) ->
-
-                    val value = when {
-                        sourceKeyFull.startsWith("history.") -> {
-                            val sourceKey = sourceKeyFull.removePrefix("history.")
-                            historyIndex
-                                ?.get(row[uniqueField]?.toString())
-                                ?.get(sourceKey.substringAfterLast("."))
-                        }
-
-                        sourceKeyFull.startsWith("group.") -> {
-                            val sourceKey = sourceKeyFull.removePrefix("group.")
-                            val idKey = historyIndex
-                                ?.get(row[uniqueField]?.toString())
-                                ?.get(uniqueFieldId) ?: row[uniqueFieldId]
-                            groupIndex
-                                ?.get(idKey.toString())
-                                ?.get(sourceKey.substringAfterLast("."))
-                        }
-
-                        else -> row[sourceKeyFull]
-                    }
-
-                    data[targetKey] = value
+                sourceFull.startsWith("group.") -> {
+                    val key = sourceFull.removePrefix("group.")
+                    val idKey = historyIndex
+                        ?.get(row[uniqueField])
+                        ?.get(uniqueFieldId)
+                        ?: row[uniqueFieldId]
+                    groupIndex
+                        ?.get(idKey?.toString())
+                        ?.get(key.substringAfterLast("."))
                 }
-                data
+
+                else -> row[sourceFull]
             }
+            data[target] = value
         }
+        return data
+    }
 
-        val value = process.value.map { m ->
-            val dt = m as MutableMap<String, Any?>
-            dt["password"]?.toString()?.let {
-                dt["password"] = if (it.isBlank()) ""
-                else cpDecrypt.decrypt(it)?.let { p ->
-                    tempPassword[p] ?: run {
-                        val pass = passwordEncoder.encode(p)
-                        tempPassword[p] = pass
-                        pass
-                    }
-                } ?: ""
-            }
-            dt
+    private fun postProcessPassword(data: MutableMap<String, Any?>): MutableMap<String, Any?> {
+        val raw = data["password"]?.toString() ?: return data
+        data["password"] = when {
+            raw.isBlank() -> ""
+            else -> cpDecrypt.decrypt(raw)?.let { plain ->
+                tempPassword[plain] ?: passwordEncoder.encode(plain).also {
+                    tempPassword[plain] = it
+                }
+            } ?: ""
         }
-        logger.info(
-            "finish prepare data [${process.value.size}] migration class ${clazz.simpleName} " +
-                    "takes time ${process.duration.inWholeMilliseconds}ms"
-        )
-        return value
+        return data
     }
 
     private fun readQueryDataToMap(file: File): List<Map<String, Any?>> {
@@ -309,16 +276,22 @@ class MigrationEntity(val passwordEncoder: PasswordEncoder, val queryNativeServi
     }
 
     private val failed: MutableList<Any> = mutableListOf()
+
     fun <T : BaseEntity> execute(
         clazz: Class<T>,
         fields: Map<String, String>,
         rootFile: File,
         historyFile: File?,
         groupFile: File?
-    ): List<MutableMap<String, Any?>> {
-        val data = dataToMap(clazz, fields, "code", rootFile, historyFile, groupFile)
-        return data
-    }
+    ) = dataToMap(clazz, fields, rootFile, historyFile, groupFile)
+
+    fun <T : BaseEntity> executeWithDataSource(
+        clazz: Class<T>,
+        fields: Map<String, String>,
+        rootData: MutableList<MutableMap<String, Any?>>,
+        historyData: MutableList<MutableMap<String, Any?>>?,
+        groupData: MutableList<MutableMap<String, Any?>>?
+    ) = dataToMapWithDataSource(clazz, fields,  rootData, historyData, groupData)
 
     fun clazzEntity(migrationTarget: String): Class<out BaseEntity>? {
         return when (migrationTarget) {
@@ -351,8 +324,8 @@ class MigrationEntity(val passwordEncoder: PasswordEncoder, val queryNativeServi
                 val phoneUserExtension = finalizer["extension"]
 
                 // budget
-                val budgetAnnual = finalizer["budget.maxCost"]?.toString()?.toDoubleOrNull()
-                val warningAnnual = finalizer["budget.warnCost"]?.toString()?.toDoubleOrNull()
+                val budgetAnnual = (finalizer["budget.maxCost"]?.toString() ?: finalizer["budget__maxCost"]?.toString())?.toDoubleOrNull()
+                val warningAnnual = (finalizer["budget.warnCost"]?.toString() ?: finalizer["budget__warnCost"]?.toString())?.toDoubleOrNull()
                 val budgetMaxCost = budgetAnnual?.let { max ->
                     List(12) { max }.joinToString(";")
                 }
@@ -365,7 +338,7 @@ class MigrationEntity(val passwordEncoder: PasswordEncoder, val queryNativeServi
                 // phoneUserPbx
                 val phoneUserPbxIds: MutableList<Any> = mutableListOf()
                 val data = if (clazz.simpleName == "PhoneUser") {
-                    finalizer["pbx.list"]?.toString()?.let {
+                    (finalizer["pbx.list"]?.toString() ?: finalizer["pbx__list"]?.toString())?.let {
                         it.split(";").forEach { fi ->
                             findUidByCode(Pbx::class.java, fi)?.let { id -> phoneUserPbxIds.add(id) }
                         }
@@ -378,11 +351,14 @@ class MigrationEntity(val passwordEncoder: PasswordEncoder, val queryNativeServi
                     val finalMap = finalizer.filterNot { fi ->
                         listOf(
                             "pbx.list",
+                            "pbx__list",
                             "pbx_id",
                             "extension",
                             "pin",
                             "budget.maxCost",
+                            "budget__maxCost",
                             "budget.warnCost",
+                            "budget__warnCost",
                             "maxCost",
                             "warnCost"
                         ).any { a -> a == fi.key }
@@ -393,7 +369,7 @@ class MigrationEntity(val passwordEncoder: PasswordEncoder, val queryNativeServi
                         }
                     }]"
                     if (phoneUserPbxIds.isEmpty()) {
-                        finalMap["expiredDate"] = LocalDate.now().atStartOfDay()
+                        finalMap["expiredDate"] = finalMap["expiredDate"] ?: LocalDate.now().atStartOfDay()
                     }
                     queryNativeService.insertDataWithNativeQuery(clazz, finalMap)
                 } else {
@@ -503,17 +479,6 @@ class MigrationEntity(val passwordEncoder: PasswordEncoder, val queryNativeServi
             }
         }
 
-//        mapFinalize["password"]?.toString()?.let {
-//            mapFinalize["password"] = if (it.isBlank()) ""
-//            else cpDecrypt.decrypt(it)?.let { p ->
-//                tempPassword[p] ?: run {
-//                    val pass = passwordEncoder.encode(p)
-//                    tempPassword[p] = pass
-//                    pass
-//                }
-//            } ?: ""
-//        }
-
         if (className == "transaction") {
             val to = mapFinalize["extTransferTo"]?.toString() ?: ""
             val from = mapFinalize["extTransferFrom"]?.toString() ?: ""
@@ -594,13 +559,15 @@ class MigrationEntity(val passwordEncoder: PasswordEncoder, val queryNativeServi
     }
 
     private fun <T : BaseEntity> findUidByCode(clazz: Class<T>, value: Any): String? {
-        val tmpData = temporaryData[clazz.simpleName] ?: run {
-            val data = apiService.findListAll(clazz)
-                .associateBy { it["code"]?.toString() ?: it["id"]!!.toString() }
-            temporaryData[clazz.simpleName] = data
-            data
+        return tempDataParent["${clazz.simpleName}_$value"] ?: run {
+            val tmpData = temporaryData[clazz.simpleName] ?: run {
+                val data = apiService.findListAll(clazz)
+                    .associateBy { it["code"]?.toString() ?: it["id"]!!.toString() }
+                temporaryData[clazz.simpleName] = data
+                data
+            }
+            tmpData[value.toString()]?.get("uid")?.toString()
         }
-        return tmpData[value.toString()]?.get("uid")?.toString() ?: tempDataParent["${clazz.simpleName}_$value"]
     }
 
     private fun findPinPhonePbx(phoneUserCode: String): Pair<String, String>? {

+ 80 - 18
src/main/kotlin/com/datacomsolusindo/migration/MigrationService.kt

@@ -5,7 +5,11 @@ import com.datacomsolusindo.cpx_shared_code.utility.SimpleLogger
 import org.springframework.beans.factory.config.YamlPropertiesFactoryBean
 import org.springframework.core.io.FileSystemResource
 import org.springframework.stereotype.Service
+import org.yaml.snakeyaml.DumperOptions
+import org.yaml.snakeyaml.Yaml
 import java.io.File
+import java.io.FileInputStream
+import java.io.FileWriter
 import java.nio.file.Files
 import java.nio.file.Path
 import java.nio.file.StandardCopyOption
@@ -14,7 +18,7 @@ import java.time.format.DateTimeFormatter
 import java.util.concurrent.Executors
 import java.util.concurrent.LinkedBlockingQueue
 import kotlin.time.measureTimedValue
-import kotlin.use
+
 
 data class PreparedMigration(
     val clazz: Class<out BaseEntity>,
@@ -28,18 +32,22 @@ val temporaryData: MutableMap<String, Map<String, Map<String, Any?>>> = mutableM
 val tempDataParent: MutableMap<String, String?> = mutableMapOf()
 val tempPassword: MutableMap<String, String?> = mutableMapOf()
 
+val executorPrepareData = Executors.newSingleThreadExecutor()
+val executorInsertData = Executors.newSingleThreadExecutor()
+val queueInsertData = LinkedBlockingQueue<Pair<Class<out BaseEntity>, List<MutableMap<String, Any?>>>>(20)
+
 @Service
-class MigrationService(val migrationEntity: MigrationEntity) {
+class MigrationService(
+    val migrationEntity: MigrationEntity,
+    val sourceDataMigrationService: SourceDataMigrationService,
+    val migrationSettingService: MigrationSettingService
+) {
 
     private val logger = SimpleLogger.getLogger(this::class.java)
     private val TEMP = "temp_"
     private val MIGRATION = "_migration_"
     private val MIGRATION_CONFIG = "config/migration.yml"
 
-    private val executorPrepareData = Executors.newSingleThreadExecutor()
-    private val executorInsertData = Executors.newSingleThreadExecutor()
-    private val queueInsertData = LinkedBlockingQueue<Pair<Class<out BaseEntity>, List<MutableMap<String, Any?>>>>(20)
-
     fun isMigrationFile(path: Path): Boolean = path.fileName.toString().contains(MIGRATION)
 
     fun isTempFile(path: Path): Boolean = path.fileName.toString().startsWith(TEMP)
@@ -64,6 +72,45 @@ class MigrationService(val migrationEntity: MigrationEntity) {
     }
 
     fun scanAndQueue(folder: Path) {
+        if (migrationSettingService.type.uppercase() == "FILE")
+            scanAndQueueWithFile(folder)
+        else
+            scanAndQueueWithDataSource()
+    }
+
+    private fun scanAndQueueWithDataSource() {
+        logger.info("service migration prepare data migration with data source")
+        val migrationTarget = migrationSettingService.target ?: arrayOf()
+        val migrationSchema = migrationSettingService.schema.filter { migrationTarget.any { a -> a == it.target } }
+        rewriteMigrationYaml(migrationTarget.joinToString(";"))
+        migrationSchema.forEach {
+            try {
+                val rootTable = sourceDataMigrationService.getData(
+                    it.table, it.attribute.filterNot { i ->
+                        i.value.toString().startsWith("history.") || i.value.toString().startsWith("group.")
+                    }.map { m -> m.value.toString() }) ?: mutableListOf()
+                val rootGroup = it.group?.let { group ->
+                    sourceDataMigrationService.getData(
+                        group, it.attribute.filter { i -> i.value.toString().startsWith("group.") }
+                            .map { m -> m.value.toString().removePrefix("group.") })
+                } ?: mutableListOf()
+                val rootHistory = it.history?.let { history ->
+                    sourceDataMigrationService.getData(
+                        history, it.attribute.filter { i -> i.value.toString().startsWith("history.") }
+                            .map { m -> m.value.toString().removePrefix("history.") })
+                } ?: mutableListOf()
+                migrationEntity.clazzEntity(it.target)?.let { clazz ->
+                    migrationEntity.executeWithDataSource(
+                        clazz, it.attribute as MutableMap<String, String>, rootTable, rootHistory, rootGroup
+                    )
+                }
+            } catch (e: Exception) {
+                logger.error("failed to migration data target ${it.target}", e)
+            }
+        }
+    }
+
+    private fun scanAndQueueWithFile(folder: Path) {
         logger.info("service migration read files")
         Files.list(folder).use { stream ->
             val tempFiles = stream
@@ -84,7 +131,9 @@ class MigrationService(val migrationEntity: MigrationEntity) {
                 executorPrepareData.submit {
                     val filename = mi.fileName.toString()
                     val migrationTarget = filename.split("_")[2].replace(".txt", "")
-                    val fields = loadYamlAsMap(migrationTarget)
+                    val fields = migrationSettingService.schema.firstOrNull { fi ->
+                        fi.target == migrationTarget}?.attribute?.let { it as MutableMap<String, String> } ?: mutableMapOf()
+                    //loadYamlAsMap(migrationTarget)
 
                     if (fields.isNotEmpty()) {
                         val rootFile = renameToTemp(mi)
@@ -99,14 +148,12 @@ class MigrationService(val migrationEntity: MigrationEntity) {
                             }?.let { addFile -> renameToTemp(addFile) }
                         } else null
                         migrationEntity.clazzEntity(migrationTarget)?.let { clazz ->
-                            queueInsertData.put(
-                                clazz to migrationEntity.execute(
-                                    clazz,
-                                    fields,
-                                    rootFile.toFile(),
-                                    historyFile?.toFile(),
-                                    groupFile?.toFile()
-                                )
+                            migrationEntity.execute(
+                                clazz,
+                                fields,
+                                rootFile.toFile(),
+                                historyFile?.toFile(),
+                                groupFile?.toFile()
                             )
                         }
                     }
@@ -116,15 +163,13 @@ class MigrationService(val migrationEntity: MigrationEntity) {
     }
 
     fun startInsertWorker() {
-
         executorInsertData.submit {
             while (true) {
                 val data = queueInsertData.take()
                 try {
-                    logger.info("started process migration ${data.first.simpleName} data ${data.second.size}")
                     val process = measureTimedValue { migrationEntity.insertData(data.first, data.second) }
                     logger.info(
-                        "finished process migration ${data.first.simpleName} " +
+                        "add data migration ${data.first.simpleName} " +
                                 "data ${data.second.size} " +
                                 "success ${data.second.size - process.value.size} " +
                                 "failed ${process.value.size} " +
@@ -149,4 +194,21 @@ class MigrationService(val migrationEntity: MigrationEntity) {
             .mapValues { it.value.toString() }
     }
 
+    private fun rewriteMigrationYaml(lastMigration: String) {
+        val options = DumperOptions()
+        options.defaultFlowStyle = DumperOptions.FlowStyle.BLOCK
+        options.isPrettyFlow = true
+        val yaml: Yaml = Yaml(options)
+        val obj: MutableMap<String?, Any?> = yaml.load(FileInputStream(MIGRATION_CONFIG))
+
+        val migration = obj["migration"] as MutableMap<String?, Any?>
+        migration["target"] = null
+        migration["lastTarget"] = lastMigration
+        migration["lastAction"] = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
+
+        val writer = FileWriter(MIGRATION_CONFIG)
+        yaml.dump(obj, writer)
+        writer.close()
+    }
+
 }

+ 21 - 0
src/main/kotlin/com/datacomsolusindo/migration/MigrationSetting.kt

@@ -0,0 +1,21 @@
+package com.datacomsolusindo.migration
+
+import org.springframework.boot.context.properties.ConfigurationProperties
+import org.springframework.stereotype.Service
+
+@Service
+@ConfigurationProperties(prefix = "migration")
+class MigrationSettingService {
+    var type: String = "FILE"
+    var sourceDatabase: MutableMap<String, Any> = mutableMapOf()
+    var target: Array<String>? = arrayOf()
+    var schema: Array<MigrationSchema> = arrayOf()
+}
+
+class MigrationSchema(
+    val target: String,
+    val table: String,
+    val history: String? = null,
+    val group: String? = null,
+    val attribute: MutableMap<String, Any>
+)