Skip to content

Commit

Permalink
Merge pull request #21 from bw-company/fix-age-calc
Browse files Browse the repository at this point in the history
fix: UKE匿名化ツールの年齢計算を修正
  • Loading branch information
KengoTODA committed Apr 13, 2023
2 parents 3028b1c + a0c1b6d commit 1ccdf3f
Show file tree
Hide file tree
Showing 5 changed files with 278 additions and 37 deletions.
2 changes: 1 addition & 1 deletion .idea/kotlinc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 1 addition & 3 deletions app/src/main/kotlin/jp/henry/uke/mask/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import java.nio.charset.Charset
import java.nio.file.Files
import java.nio.file.Path
import java.security.SecureRandom
import java.time.Clock
import java.time.ZoneId
import java.util.concurrent.Callable
import kotlin.system.exitProcess

Expand All @@ -32,7 +30,7 @@ class App : Callable<Int> {
}

println("マスク処理を開始します(シード値 $seed)……")
val engine = MaskingEngine(seed, Clock.system(ZoneId.of("Asia/Tokyo")))
val engine = MaskingEngine(seed)

Files.newBufferedWriter(output, CHARSET).use { writer ->
Files.newBufferedReader(input.toPath(), CHARSET).lines().map {
Expand Down
57 changes: 50 additions & 7 deletions app/src/main/kotlin/jp/henry/uke/mask/MaskingEngine.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
package jp.henry.uke.mask

import java.time.Clock
import java.time.LocalDate
import java.time.Period
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import kotlin.random.Random

class MaskingEngine(seed: Int, private val clock: Clock) {
class MaskingEngine(seed: Int) {
private val random = Random(seed)
private val names = HashMap<String, HashMap<String, String>>()
private val numbers = HashMap<Int, HashMap<String, String>>()
Expand All @@ -27,8 +26,13 @@ class MaskingEngine(seed: Int, private val clock: Clock) {
when (it.index) {
4 -> {
val birthDay = LocalDate.parse(split[6], DateTimeFormatter.ofPattern("yyyyMMdd"))
val today = LocalDate.ofInstant(clock.instant(), clock.zone)
"${maskName("患者名", it.value)}${ChronoUnit.YEARS.between(birthDay, today)}歳)"
val medicalTreatmentDay = split[3] // "yyyyMM"形式の診療年月
val base = LocalDate.of(
medicalTreatmentDay.substring(0..3).toInt(),
medicalTreatmentDay.substring(4..5).toInt(),
1,
)
maskPatientName(it.value, base, birthDay)
}
// 誕生日は75歳に到達した日の確認が必要となるため、そのままの値で使用
// 6 -> maskDate(it.value)
Expand All @@ -40,6 +44,39 @@ class MaskingEngine(seed: Int, private val clock: Clock) {
}
}

/**
* @return 月末の日付
*/
private fun LocalDate.atEndOfMonth(): LocalDate =
this.withDayOfMonth(this.month.length(this.isLeapYear))

fun maskPatientName(raw: String, medicalTreatmentDay: LocalDate, birthDay: LocalDate): String {
// 当月の1日時点での年齢を表示する
val base = medicalTreatmentDay.withDayOfMonth(1)
val age = computeAge(birthDay, base)
var termStartDate = LocalDate.of(medicalTreatmentDay.year, 4, 1)
if (!base.isBefore(termStartDate)) {
termStartDate = termStartDate.plusYears(1)
}

if (computeAge(birthDay, termStartDate) <= 6) {
return "${maskName("患者", raw)}(${age}歳,未就学児)"
}

val ageAtEndOfLastMonth = computeAge(birthDay, base.minusDays(1))
val ageAtEndOfThisMonth = computeAge(birthDay, base.atEndOfMonth())
return if (ageAtEndOfThisMonth == 75 && ageAtEndOfLastMonth == 74) {
if (birthDay.dayOfMonth != 1) {
// 75歳の誕生日当日から後期高齢に移行し、その月の自己負担額が半額となる制度のため、これを表示
"${maskName("患者", raw)}(${age}歳,75歳到達月)"
} else {
"${maskName("患者", raw)}(${age}歳,75歳到達月特例対象外)"
}
} else {
"${maskName("患者", raw)}(${age}歳)"
}
}

/**
* 保険者レコードをマスクする。[2]の被保険者証(手帳)等の記号、[3]の被保険者証(手帳)等の番号、
* [9]の証明書番号をマスクする。
Expand Down Expand Up @@ -78,7 +115,7 @@ class MaskingEngine(seed: Int, private val clock: Clock) {
line.split(",").withIndex().joinToString(",") {
when (it.index) {
4 -> maskNumber(it.value, 7)
6 -> maskName("医療機関名", it.value)
6 -> maskName("医療機関", it.value)
9 -> maskTelNum(it.value)
else -> it.value
}
Expand All @@ -87,7 +124,8 @@ class MaskingEngine(seed: Int, private val clock: Clock) {
fun maskName(prefix: String, name: String) = names.getOrPut(prefix) {
HashMap()
}.getOrPut(name) {
"$prefix${random.nextInt(Integer.MAX_VALUE)}"
// 長すぎると患者名が全角20文字を超えるため6桁に抑える
"$prefix${random.nextInt(999_999)}"
}

fun maskNumber(text: String, length: Int): String = numbers.getOrPut(length) {
Expand All @@ -99,4 +137,9 @@ class MaskingEngine(seed: Int, private val clock: Clock) {
}

private fun maskTelNum(text: String) = "000-0000-0000"

companion object {
fun computeAge(birthDay: LocalDate, today: LocalDate): Int =
Period.between(birthDay, today).years
}
}
26 changes: 0 additions & 26 deletions app/src/test/kotlin/jp/henry/uke/mask/MaskEngineSpec.kt

This file was deleted.

226 changes: 226 additions & 0 deletions app/src/test/kotlin/jp/henry/uke/mask/MaskingEngineSpec.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package jp.henry.uke.mask

import io.kotest.assertions.withClue
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual
import io.kotest.matchers.ints.shouldBeLessThanOrEqual
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.localDate
import io.kotest.property.arbitrary.string
import io.kotest.property.checkAll
import jp.henry.uke.mask.MaskingEngine.Companion.computeAge
import java.nio.charset.Charset
import java.time.LocalDate

class MaskingEngineSpec : DescribeSpec({
describe("computeAge") {
context("3月1日に生まれた子供") {
val birthDay = LocalDate.of(2014, 3, 1)
it("翌年の3月1日に1歳になる") {
computeAge(birthDay, LocalDate.of(2015, 2, 28)) shouldBe 0
computeAge(birthDay, LocalDate.of(2015, 3, 1)) shouldBe 1
}
}
context("2月29日に生まれた子供") {
val birthDay = LocalDate.of(2016, 2, 29)
it("翌年の2月27日はまだ0歳のまま") {
computeAge(birthDay, LocalDate.of(2017, 2, 27)) shouldBe 0
}
it("翌年の2月28日から3月1日になる瞬間に1歳になる") {
computeAge(birthDay, LocalDate.of(2017, 2, 28)) shouldBe 0
computeAge(birthDay, LocalDate.of(2017, 3, 1)) shouldBe 1
}
}
}
describe("maskNumber") {
it("generates number with given length") {
checkAll(Arb.int(), Arb.string(), Arb.int(1, 10)) { seed, text, length ->
MaskingEngine(seed).maskNumber(text, length).length shouldBe length
}
}
}
describe("maskName") {
it("同じ入力に対しては常に同じ結果を返す") {
checkAll<Int, String, String> { seed, prefix, text ->
MaskingEngine(seed).maskName(prefix, text) shouldBe MaskingEngine(seed).maskName(prefix, text)
}
}
}
describe("maskPatientName") {
it("満年齢を計算して匿名化された患者名に埋め込む") {
val today = LocalDate.of(2020, 3, 1)
val birthDay = LocalDate.of(2010, 2, 1)

MaskingEngine(0).maskPatientName("患者", today, birthDay) shouldContain "10歳"
}
context("誕生日の前日に年齢を加算する") {
// TODO うるう年の3月1日(誕生日の前日が2月29日)のケースをテスト
it("誕生日が1日の場合") {
val birthDay = LocalDate.of(2020, 2, 1)

val theDayBefore = LocalDate.of(2021, 1, 30)
val endOfTheLastMonth = LocalDate.of(2021, 1, 31)
val beginningOfThisMonth = LocalDate.of(2021, 2, 1)

MaskingEngine(0).maskPatientName("患者", theDayBefore, birthDay) shouldContain "(0歳"
MaskingEngine(0).maskPatientName("患者", endOfTheLastMonth, birthDay) shouldContain "(0歳"
MaskingEngine(0).maskPatientName("患者", beginningOfThisMonth, birthDay) shouldContain "(1歳"
}
it("誕生日がそれ以外の場合") {
checkAll(Arb.int(2, 31)) {
val birthDay = LocalDate.of(2019, 3, it)
val today = LocalDate.of(2020, 3, it)
val oneDayBefore = LocalDate.of(2020, 3, it - 1)
val endOfTheMonth = LocalDate.of(2020, 3, 31)
val beginningOfTheNextMonth = LocalDate.of(2020, 4, 1)

MaskingEngine(0).maskPatientName("患者", oneDayBefore, birthDay) shouldContain "(0歳"
MaskingEngine(0).maskPatientName("患者", today, birthDay) shouldContain "(0歳"
MaskingEngine(0).maskPatientName("患者", endOfTheMonth, birthDay) shouldContain "(0歳"

// 次の月から年齢が加算される
MaskingEngine(0).maskPatientName("患者", beginningOfTheNextMonth, birthDay) shouldContain "(1歳"
}
}
}
describe("未就学児判定") {
it("0-5歳ならば未就学児であることを表示") {
checkAll(Arb.int(0, 5)) {
val birthDay = LocalDate.of(2000, 1, 2)
val thisMonth = LocalDate.of(2000 + it, 1, 2)

MaskingEngine(0).maskPatientName("患者", thisMonth, birthDay) shouldContain "未就学児"
}
}
context("6歳の場合") {
context("4月1日生まれの場合") {
val birthDay = LocalDate.of(2000, 4, 1)
it("6歳になる前日の3月31日まで未就学児") {
checkAll(Arb.localDate(LocalDate.of(2000, 4, 1), LocalDate.of(2006, 3, 31))) { today ->
MaskingEngine(0).maskPatientName("患者", today, birthDay) shouldContain "未就学児"
}
}
it("6歳になる4月1日から就学児") {
MaskingEngine(0).maskPatientName("患者", LocalDate.of(2006, 4, 1), birthDay) shouldNotContain "未就学児"
}
}
context("その他の誕生日の場合") {
describe("4月2日~3月31日の間に6歳に到達してから、次の3月31日を迎えるまで") {
it("未就学児であることを表示") {
// うるう年をテストするために2016年2月29日を含むケースを用いる
checkAll(Arb.localDate(LocalDate.of(2015, 4, 2), LocalDate.of(2016, 3, 31))) { birthDay ->
// 6歳になる日から確認を開始
var today = if (birthDay == LocalDate.of(2016, 2, 29)) {
birthDay.plusYears(6).plusDays(1)
} else {
birthDay.plusYears(6)
}
withClue("$birthDay に生まれた子供は $today から6歳になる") {
computeAge(birthDay, today) shouldBe 6
}
val engine = MaskingEngine(0)
do {
engine.maskPatientName("患者", today, birthDay) shouldContain "未就学児"
today = today.plusDays(1)
} while (today <= LocalDate.of(2022, 3, 31))
}
// うるう年をテストするために2020年2月29日に6歳になるケースも用いる
checkAll(Arb.localDate(LocalDate.of(2013, 4, 2), LocalDate.of(2014, 3, 31))) { birthDay ->
// 6歳になる日から確認を開始
var today = birthDay.plusYears(6)
withClue("$birthDay に生まれた子供は $today には6歳になっている") {
computeAge(birthDay, today) shouldBe 6
}
val engine = MaskingEngine(0)
do {
engine.maskPatientName("患者", today, birthDay) shouldContain "未就学児"
today = today.plusDays(1)
} while (today <= LocalDate.of(2020, 3, 31))
}
}
}
describe("4月1日から7歳の誕生日を迎えるまで") {
it("未就学児であることを表示しない") {
checkAll(Arb.localDate(LocalDate.of(2015, 4, 2), LocalDate.of(2016, 4, 1))) { birthDay ->
var today = LocalDate.of(2022, 4, 1)
withClue("$birthDay に生まれた子供は $today にはまだ6歳である") {
computeAge(birthDay, today) shouldBe 6
}
val engine = MaskingEngine(0)
do {
engine.maskPatientName("患者", today, birthDay) shouldNotContain "未就学児"
today = today.plusDays(1)
} while (computeAge(birthDay, today) == 6)
}
}
}
}
context("7歳以上の場合") {
it("就学児だと判定する") {
checkAll(Arb.int(7, 120)) {
val birthDay = LocalDate.of(2000, 1, 1)
val today = LocalDate.of(2000 + it, 1, 1)
withClue("$birthDay に生まれた子供は $today にはもう7歳以上である") {
computeAge(birthDay, today) shouldBeGreaterThanOrEqual 7
}

MaskingEngine(0).maskPatientName("患者", today, birthDay) shouldNotContain "未就学児"
}
}
}
}
}
describe("75歳到達月判定") {
it("月末時点で75歳0ヶ月であれば「75歳到達月」と表示") {
checkAll(Arb.int(1, 31)) {
val birthDay = LocalDate.of(1955, 1, 2)
val thisMonth = LocalDate.of(2030, 1, it)

MaskingEngine(0).maskPatientName("患者", thisMonth, birthDay) shouldContain "75歳到達月"
}
}
it("月末時点で74歳11ヶ月あるいは75歳1ヶ月であれば表示しない") {
val birthDay = LocalDate.of(1955, 1, 2)
val before = LocalDate.of(2029, 12, 31)
val after = LocalDate.of(2030, 2, 1)

MaskingEngine(0).maskPatientName("患者", before, birthDay) shouldNotContain "75歳到達月"
MaskingEngine(0).maskPatientName("患者", after, birthDay) shouldNotContain "75歳到達月"
}
context("当月1日が誕生日の場合") {
it("75歳到達月特例にあてはまらないことを表示する") {
checkAll(Arb.int(1, 12)) { month ->
val birthDay = LocalDate.of(1955, month, 1)
checkAll(Arb.int(1, birthDay.lengthOfMonth())) { day ->
val thisMonth = LocalDate.of(2030, month, day)
MaskingEngine(0).maskPatientName("患者", thisMonth, birthDay) shouldContain "75歳到達月特例対象外"
}
}
}
}
}

/**
* Shift-JISにおける文字数を計算する。全角は2文字、半角は1文字として数える。
*/
fun String.countSjisChars(): Int {
val charset = Charset.forName("SJIS")
return this.toByteArray(charset).size
}
it("半角で40文字、全角で20文字を超えない文字列を生成する") {
checkAll(Arb.int(), Arb.string(), Arb.int(1, 80)) { seed, name, age ->
val birthDay = LocalDate.of(1930, 1, 1)
val today = LocalDate.of(1930 + age, 1, 1)
val maskedName = MaskingEngine(seed).maskPatientName(name, today, birthDay)
val sjisLength = maskedName.countSjisChars()
withClue("生成された文字列($maskedName)の長さが半角で40文字を超えるべきではないが $sjisLength 文字となった") {
sjisLength shouldBeLessThanOrEqual 40
}
}
}
}
})

0 comments on commit 1ccdf3f

Please sign in to comment.