엑셀 파일을 파싱하고 이를 활용하여 급여 데이터를 등록하는 작업은 많은 데이터를 처리할 때 유용합니다. 이 글에서는 Kotlin을 사용하여 엑셀 파일을 동적으로 파싱하고 급여 데이터를 효율적으로 등록하는 방법을 설명하겠습니다.
abstract class ExcelFileProcessor<S: Workbook, D> : DataProcessor<S, D> {
abstract val startRow: Int
abstract val chunk: Int
override fun <H: Header> process(source: S, validator: Validator<H, in D>): List<D> {
val sheet = source.getSheetAt(0)
val header = header(sheet)
val res: MutableList<D> = mutableListOf()
@Suppress("UNCHECKED_CAST")
validator.checkHeader(header as H)
run breaker@{
(startRow..sheet.lastRowNum step chunk).forEach {
val rows = (it until (it + chunk)).map { idx -> sheet.getRow(idx) }
if (isBreaking(rows[0])) {
return@breaker
}
val processedData = processRowData(source, header, rows, it) ?: return@forEach
try {
validator.checkData(processedData)
res.add(processedData)
} catch (t: Throwable) {
log.error(t) { "error!!!" }
log.info { "Data failed the predicate check, not adding to the result list" }
}
}
}
return res
}
protected abstract fun header(sheet: Sheet): Header?
protected abstract fun processRowData(source: S, header: Header?, unit: List<Row>, idx: Int): D?
protected abstract fun isBreaking(row: Row): Boolean
}
PayrollRegisterMultiRowProcessor
class PayrollRegisterMultiRowProcessor: ExcelFileProcessor<PayrollSource, PayrollRegister>() {
override val startRow: Int = 10
override val chunk: Int = 4
// JSON 파싱을 위한 ObjectMapper 인스턴스 생성
private val objectMapper = ObjectMapper()
private val String.toWorkTimes get(): BigDecimal =
this.replace("""(일|시간)""".toRegex(), "").trim().toBigDecimal()
private object formatter {
fun of(pattern: String) = DateTimeFormatter.ofPattern(pattern)!!
}
data class SalaryHeader(override val items: List<I>,
private val properties: Map<Property, Any>): Header {
data class I(
val group: String,
override val name: String,
override val row: Int,
override val column: Int
): Item
@Suppress("UNCHECKED_CAST")
override fun <P : Any> property(key: Any): P? = properties[key as Property] as P?
}
override fun header(sheet: Sheet): Header {
val regExp = "\\s".toRegex()
val h = sheet.getRow(4)
var temp = ""
val group = (0 until h.physicalNumberOfCells).associateWith {
h.getCell(it).stringCellValue
.takeIf {v -> v.isNotEmpty() }
?.let inner@{v ->
temp = v.replace(regExp, "")
return@inner temp
} ?: temp
}
val headers = (5..8).map {r ->
val row = sheet.getRow(r)
(0 until row.physicalNumberOfCells).map {c ->
SalaryHeader.I(
group = group[c]!!,
name = row.getCell(c).stringCellValue.replace(regExp, ""),
row = r-5,
column = c
)
}
}.flatten()
val properties = sheet.getRow(2).let { r ->
val hospitalName = r.getCell(0)
val dates = (1 until r.lastCellNum)
.first { r.getCell(it)?.stringCellValue?.contains("귀속") ?: false }
.let {
val pattern = """\[(귀속|지급):([\s\w년월일]+)]""".toRegex()
val space = """\s""".toRegex()
pattern.findAll(r.getCell(it).stringCellValue).associate { x ->
val (key, value) = x.destructured
key.replace(space, "") to value.replace(space, "")
}
}
listOf(
HOSPITAL_NAME to hospitalName.toString(),
SALARY_MONTH to dates["귀속"]?.let {d -> YearMonth.parse(d, formatter.of("yyyy년M월")) },
PAYMENT_AT to dates["지급"]?.let {d -> LocalDate.parse(d, formatter.of("yyyy년M월d일")) },
).filter {(_, v) -> v != null}.associate {(k, v) -> k to v!!}
}
return SalaryHeader(items = headers, properties = properties)
}
override fun processRowData(source: PayrollSource, header: Header?, unit: List<Row>, idx: Int): PayrollRegister {
val h = header as SalaryHeader
// 인적, 수당, 공제 정보를 필터링하여 매핑
val employeeDetails = h.items.filter { it.group.contains("인적") }
.associate { it.name to stringValue(it, unit) }
val salaryDetails = h.items.filter { it.group.contains("수당") }
.associate { it.name to numericValue(it, unit) }
val deductionDetails = h.items.filter { it.group.contains("공제") }
.associate { it.name to numericValue(it, unit) }
// 병원명 가져오기
val hospitalName = h.property<String>(HOSPITAL_NAME) ?: throw IllegalArgumentException(
"""
{
"title": "급여대장 업로드 확인",
"content": "병원명이 필요합니다."
}
""".trimIndent()
)
// 지급일자 가져오기
val paymentDate = h.property<LocalDate>(PAYMENT_AT) ?: throw IllegalArgumentException(
"""
{
"title": "급여대장 업로드 확인",
"content": "지급일자가 필요합니다."
}
""".trimIndent()
)
// 귀속월 가져오기
val salaryMonth = h.property<YearMonth>(SALARY_MONTH) ?: throw IllegalArgumentException(
"""
{
"title": "급여대장 업로드 확인",
"content": "귀속월이 필요합니다."
}
""".trimIndent()
)
// 병원명 및 귀속월 검증
require(source.hospitalName.equals(hospitalName, ignoreCase = true)) {
"""{
"title": "급여대장 업로드 확인",
"content": "업로드하신 급여대장은 '$hospitalName' 자료 입니다. 현재 '${source.hospitalName}' 급여대장 등록 작업중입니다.\r\n 엑셀파일 확인 후 다시 시도해 주십시오."
}
""".trimIndent()
}
require(source.salaryMonth == salaryMonth) {
"""{
"title": "급여대장 업로드 확인",
"content": "업로드하신 급여대장은 ${salaryMonth.year}년 ${salaryMonth.monthValue}월(귀속)분 입니다.\r\n 현재 ${source.salaryMonth.year}년 ${source.salaryMonth.monthValue}월(귀속)분 등록 작업중입니다. 엑셀파일 확인 후 다시 시도해 주십시오."
}
""".trimIndent()
}
// 비과세 항목 리스트
val taxExemptionList = listOf(
"식대", "식비", "식대(비과세)", "차량유지비", "자가운전",
"자가운전보조금", "자가운전수당", "차량보조금",
"차량운전보조금", "차량지원비", "연구개발비"
)
// 비과세 금액 계산 (식대, 운전보조금, 연구개발비 - 항목 당 최대 20만원)
val taxExemptionAmount = salaryDetails
.filterKeys { it in taxExemptionList }
.values
.map { it?.min(BigDecimal(200000)) ?: BigDecimal.ZERO }
.reduce { acc, v -> acc.add(v) }
// 직원 정보 생성
val employee = _employee(
number = stringValue(h["사원번호"], unit),
name = stringValue(h["성명"], unit) ?: throw IllegalArgumentException("성명이 필요합니다."),
...
).apply {
employeeDetails.forEach { (k, v) -> items += k to (v ?: "") }
}
// 급여 정보 생성
val salary = _salary(
basicSalary = numericValue(h["기본급"], unit) ?: throw IllegalArgumentException("기본급이 필요합니다."),
totalSalary = numericValue(h["지급합계"], unit) ?: throw IllegalArgumentException("지급합계가 필요합니다."),
taxExemption = taxExemptionAmount
).apply {
salaryDetails.forEach { (k, v) -> items += k to (v ?: BigDecimal.ZERO) }
}
// 공제 정보 생성
val deduction = _deduction(
totalDeduction = numericValue(h["공제합계"], unit) ?: throw IllegalArgumentException("공제합계가 필요합니다."),
).apply {
deductionDetails.forEach { (k, v) -> items += k to (v ?: BigDecimal.ZERO) }
}
return employeeAndSalary(
source.salaryMonth,
paymentDate,
employee,
salary,
deduction
)
}
override fun isBreaking(row: Row): Boolean {
return row.getCell(0).stringCellValue.trim().contains("합계")
}
private fun numericValue(header: Item?, unit: List<Row>): BigDecimal? {
try {
return header?.let { unit[it.row].getCell(it.column).numericCellValue.toBigDecimal() }
} catch (t: Throwable) {
processFail(t) { "Fail to read data from cell ${header?.let { "[${it.name}](${it.column}, ${unit[it.row].rowNum})" }} : ${t.message}" }
}
}
private fun stringValue(header: Item?, unit: List<Row>): String? {
try {
return header?.let { unit[it.row].getCell(it.column).stringCellValue }?.takeIf { it.trim().isNotEmpty() }
} catch (t: Throwable) {
processFail(t) { "Fail to read data from cell ${header?.let { "[${it.name}](${it.column}, ${unit[it.row].rowNum})" }} : ${t.message}" }
}
}
}
PayrollValidator
PayrollValidator
클래스는 Validator
인터페이스를 구현하여 급여 데이터의 유효성을 검사하는 역할을 합니다.class PayrollValidator : Validator<SalaryHeader, PayrollRegister> {
private val log = KotlinLogging.logger {}
override fun checkHeader(header: SalaryHeader): SalaryHeader {
val duplicates = header.items.groupingBy { item -> item.name }
.eachCount()
.filterKeys { it.isNotEmpty() }
.filterValues { it > 1 }
if (duplicates.isNotEmpty()) {
log.info("duplicates :: $duplicates")
processFail {
"""{
"title": "급여대장 동일항목 확인",
"content": "업로드하신 엑셀 파일에 동일한 항목이 중복으로 있습니다. \r\n [${duplicates.keys.joinToString(",")}] \r\n 엑셀파일 확인 후 다시 시도해 주십시오."
}
""".trimIndent()
}
}
return header
}
override fun checkData(data: PayrollRegister): PayrollRegister {
val required = mapOf(
"성명" to data.employee.name,
"주민등록번호" to data.employee.rrn,
"기본급 " to data.salary.basicSalary,
).filterValues { it == null }.keys
if(required.isNotEmpty()) {
processFail {
log.info("required :: $required")
"""{
"title": "급여대장 필수정보 누락",
"content": "업로드하신 엑셀 파일에서 ${required.joinToString(",")}을 확인할 수 없습니다. \r\n 엑셀파일 확인 후 다시 시도해 주십시오."
}
""".trimIndent()
}
}
return data
}
}