Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: UKE匿名化ツールの年齢計算を修正 #21

Merged
merged 13 commits into from
Apr 13, 2023
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
}
}
}
}
})