나이 계열 7종·주민번호 마스킹 3종 — 215종 → 225종 (조사 라운드 7 시작)

PAT_나이(bzDataInterface.vb:373 Case Else — 통합 대표행의 AgeMonth
파생 컬럼과 같은 식을 SQL 로: UDF_GETAGE_FROMRESNUM(복호화, 적용일시),
dtCommonLib.vb:3725 확인. CGCH 분기·암호화 N 갈래 미이식),
PAT_나이_세(:417 — 산정코드 24* 는 UDF_GETAGE(PatBthDay), 그 밖은
AgeCheck), PAT_성별_나이(:359 — "성별/나이". HIMCHAN_BP·BSYD 분기
미이식), PAT_나이_개월수(:430 — 월 경계 -1, 기존 MonthsSince 재사용),
개월·일 4종(:436-508 — 30일 나눗셈, 새 DaysSince 로 분해).

AgeCheck(clsCommonLib.vb:665-974)를 ResidentNumber.AgeOf 로 이식 —
세기 접두 규칙(1·2→19, 3·4→20, 5·6→출생2자리<20 이면 20xx 아니면
19xx+월일 0101 강제, 7·8→서버 연도 2자리 비교, 그 외→18xx. REDCROSS·
BSYD·BSGH 특례 미이식), 만나이(생일 당일 차감 없음 — 2024-09-25 이후
전 병원 규칙), 1세 이하는 고정 365일. 테스트 5건 — 기대값 하나가
틀려 있었다(출생 2자리 "20"은 <20 이 아니라 1920년으로 강제 — 구현이
맞고 판정을 고침). 개월·일 4종의 기준일은 레거시가 PC 시계(Now)라
서버 오늘로 바꿈(의도적 이탈 — 단말마다 값이 갈리면 안 된다).

PAT_주민번호_Blind/SexTyp_Blind/SexTyp_BlindStar(:105-186 Case Else —
앞6 " - " 뒤 XXXXXXX / 성별1+XXXXXX / 성별1+******. SYBS 의 J-계열
서식 전체 노출 분기 미이식).

대조군 교체: PAT_나이가 이식되면서 ⑬·edit-smoke 의 "안 옮긴 태그"
대조군이 깨졌다(의도된 동작) — ETC_수술실간호사로 교체.
㊱ 신설: 나이 UDF 2종이 실값을 돌려준다(접두 없이 동작 확인).

- dotnet test 342/342 (AgeOf 5건 추가) · --edit-smoke 실패 0
- --db-patient ①~㊱ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-20 08:41:22 +09:00
co-authored by Claude Fable 5
parent abb08d0c6b
commit 8256b79800
6 changed files with 352 additions and 13 deletions
@@ -44,13 +44,10 @@ public sealed class PatientTagResolver : ITagValueResolver
// PatBthDay 를 YYYY-MM-DD 로(:188-198). 형식이 붙으므로 컬럼만으로는 안 된다
["PAT_생년월일"] = ("PatInf", "PatBthDay", Hyphenate),
// ── 넣지 않은 것과 그 이유 ──
// PAT_주민번호 : 레거시는 PatResNum_DEC(복호화 컬럼)를 읽는다(:87).
// P_PatInf 의 PatResNum 을 그대로 쓰면 암호화 병원에서 <b>암호문이 종이에 찍힌다</b>.
// 처음에 그렇게 넣어 뒀다 — 빈칸보다 나쁜 값이었다.
// PAT_성별 / PAT_한글성별 : CoiCalCod 가 "24" 로 시작하면 PatSexTyp, 아니면
// GetSexAge(PatResNum_DEC) 로 주민번호에서 뽑는다(:283-316). 복호화가 먼저다.
// PAT_나이 계열 : 병원 코드로 갈라지고(CGCH 는 자기 SQL) 계산식이 제각각이다(:373-).
// ── 이 표에 없는 주민번호·성별·나이 계열은 전용 갈래에 있다 ──
// 주민번호(복호화·마스킹)·성별은 FromResidentNumber, 나이 7종은 FromAge.
// 처음에 PAT_주민번호를 PatResNum 컬럼으로 넣었다가 암호화 병원에서
// 암호문이 종이에 찍힐 뻔했다 — 컬럼 하나짜리로 보여도 여기 넣지 않는다.
};
#endregion
@@ -356,6 +353,10 @@ public sealed class PatientTagResolver : ITagValueResolver
{
return certValue;
}
if (FromAge(tag) is { } ageValue)
{
return ageValue;
}
if (Computed.TryGetValue(tag, out var compute))
{
var made = compute(context);
@@ -433,6 +434,27 @@ public sealed class PatientTagResolver : ITagValueResolver
? "주민번호를 읽지 못해 성별을 만들 수 없습니다"
: "이 환자의 성별을 판정할 수 없습니다");
}
case "PAT_주민번호_Blind":
case "PAT_주민번호_SexTyp_Blind":
case "PAT_주민번호_SexTyp_BlindStar":
{
// 마스킹 3종(bzDataInterface.vb:105-186) — Case Else 갈래.
// SYBS 의 J-계열 서식 전체 노출 분기는 미이식(그 병원 배포 시).
// 레거시는 13자 미만이면 Substring 예외→노란색이었다 — 여기는 사유.
if (residentNumber.Length < 13)
{
return new TagValue(false, residentNumber.Length == 0
? "주민번호를 읽지 못해 마스킹을 만들 수 없습니다"
: "주민번호가 13자리가 아닙니다");
}
var masked = tag switch
{
"PAT_주민번호_Blind" => $"{residentNumber[..6]} - XXXXXXX",
"PAT_주민번호_SexTyp_Blind" => $"{residentNumber[..6]} - {residentNumber[6]}XXXXXX",
_ => $"{residentNumber[..6]} - {residentNumber[6]}******",
};
return new TagValue(true, masked);
}
case "PAT_성별_남":
case "PAT_성별_여":
{
@@ -963,6 +985,153 @@ public sealed class PatientTagResolver : ITagValueResolver
: new TagValue(false, "수술일자 값이 형식에 맞지 않습니다");
}
/// <summary>
/// 나이 계열 7종(bzDataInterface.vb:359-508 + AgeCheck clsCommonLib.vb:665-974).
///
/// <b>출처가 셋으로 갈린다</b>: PAT_나이는 통합 대표행의 AgeMonth 파생 컬럼
/// (= DB UDF_GETAGE_FROMRESNUM — 같은 식을 SQL 로 쏜다, CGCH 분기 미이식),
/// PAT_나이_세·성별_나이의 계산 갈래는 AgeCheck(C# 이식, 테스트 5건),
/// 개월·일 4종은 생년월일에서 30일 나눗셈이다.
///
/// <b>의도적 이탈 하나</b>: 개월·일 4종의 기준일을 레거시는 <b>PC 시계(Now)</b>로
/// 잡는다 — 단말마다 값이 달라질 수 있어 서버 오늘로 바꿨다(다른 날짜 태그와 동일).
/// </summary>
private TagValue? FromAge(string tag)
{
if (tag is not ("PAT_나이" or "PAT_나이_세" or "PAT_성별_나이" or "PAT_나이_개월수"
or "PAT_나이_00개월_00일" or "PAT_나이_00Months_00Days"
or "PAT_나이_개월_00일" or "PAT_나이_00개월_일"))
{
return null;
}
if (extraCache.TryGetValue(tag, out var cached))
{
return cached;
}
var value = Compute();
extraCache[tag] = value;
return value;
TagValue Compute()
{
var today = PatientContext.Value(hospital, "ServerToday");
var birth = PatientContext.Value(context.PatInf, "PatBthDay");
switch (tag)
{
case "PAT_나이":
{
// Case Else 갈래 = AgeMonth 파생 컬럼(:414) — UDF 를 그대로 쏜다
var chtNum = PatientContext.Value(context.ComInf, "ComChtNum");
if (chtNum.Length == 0)
{
return new TagValue(false, "이 내원에 차트번호가 없습니다");
}
if (extraStoreSource?.Invoke() is not { } store)
{
return new TagValue(false, "부가정보 조회가 연결되지 않았습니다");
}
try
{
return store.AgeMonthText(chtNum, context.AdpDtm) is { Length: > 0 } age
? new TagValue(true, age)
: new TagValue(false, "나이를 만들 주민번호가 비어 있습니다");
}
catch (Exception ex)
{
return new TagValue(false, $"나이 조회에 실패했습니다: {ex.GetType().Name}");
}
}
case "PAT_나이_세":
{
// 산정코드 24* 는 AGE 파생 컬럼(UDF), 그 밖은 AgeCheck(:417-428)
if (PatientContext.Value(context.CoiInf, "CoiCalCod")
.StartsWith("24", StringComparison.Ordinal))
{
var chtNum = PatientContext.Value(context.ComInf, "ComChtNum");
if (extraStoreSource?.Invoke() is not { } store)
{
return new TagValue(false, "부가정보 조회가 연결되지 않았습니다");
}
try
{
var day8 = context.AdpDtm.Length >= 8 ? context.AdpDtm[..8] : today;
return Wrap2(store.AgeYearsUdf(chtNum, day8), "생년월일이 비어 나이를 만들 수 없습니다");
}
catch (Exception ex)
{
return new TagValue(false, $"나이 조회에 실패했습니다: {ex.GetType().Name}");
}
}
if (residentNumber.Length == 0)
{
return new TagValue(false, "주민번호를 읽지 못해 나이를 만들 수 없습니다");
}
if (today.Length < 8)
{
return new TagValue(false, "요양기관 행(서버 오늘)을 읽지 못했습니다");
}
return new TagValue(true,
ResidentNumber.AgeOf(residentNumber, today, today).ToString());
}
case "PAT_성별_나이":
{
// HIMCHAN_BP·BSYD 분기 미이식 — SRCH 는 산정코드 24* 조건만 남는다(:359-371)
if (PatientContext.Value(context.CoiInf, "CoiCalCod")
.StartsWith("24", StringComparison.Ordinal))
{
var sex = PatientContext.Value(context.PatInf, "PatSexTyp");
var chtNum = PatientContext.Value(context.ComInf, "ComChtNum");
if (extraStoreSource?.Invoke() is not { } store)
{
return new TagValue(false, "부가정보 조회가 연결되지 않았습니다");
}
try
{
return new TagValue(true, $"{sex}/{store.AgeMonthText(chtNum, context.AdpDtm)}");
}
catch (Exception ex)
{
return new TagValue(false, $"나이 조회에 실패했습니다: {ex.GetType().Name}");
}
}
if (residentNumber.Length == 0)
{
return new TagValue(false, "주민번호를 읽지 못해 성별·나이를 만들 수 없습니다");
}
if (today.Length < 8)
{
return new TagValue(false, "요양기관 행(서버 오늘)을 읽지 못했습니다");
}
return new TagValue(true,
$"{ResidentNumber.SexOf(residentNumber)}/{ResidentNumber.AgeOf(residentNumber, today, today)}");
}
case "PAT_나이_개월수":
// VB DateDiff(Month) - 1 — 월 경계만 세고 1을 뺀다(:430-434, 레거시 결함 보존)
return Wrap2(ResidentNumber.MonthsSince(birth, today),
"생년월일 또는 서버 오늘을 읽지 못했습니다");
default:
{
// 30일 나눗셈 4종(:436-508) — 총 일수를 30으로 나눠 개월·일로 가른다
var days = ResidentNumber.DaysSince(birth, today);
if (days is null)
{
return new TagValue(false, "생년월일 또는 서버 오늘을 읽지 못했습니다");
}
return new TagValue(true, tag switch
{
"PAT_나이_00개월_00일" => $"{days / 30}개월 {days % 30}일",
"PAT_나이_00Months_00Days" => $"{days / 30}Months {days % 30}Days",
"PAT_나이_개월_00일" => (days % 30).ToString()!,
_ => (days / 30).ToString()!, // PAT_나이_00개월_일 — 개월 숫자만
});
}
}
}
static TagValue Wrap2(string value, string reasonWhenEmpty)
=> value.Length > 0 ? new TagValue(true, value) : new TagValue(false, reasonWhenEmpty);
}
/// <summary>
/// 진단서 의사 계열 8종(bzDataInterface.vb:4380-4845).
///
@@ -2254,6 +2423,9 @@ public sealed class PatientTagResolver : ITagValueResolver
{
"PAT_주민번호", "PAT_주민번호_Dash", "PAT_성별", "PAT_한글성별",
"PAT_성별_남", "PAT_성별_여",
"PAT_주민번호_Blind", "PAT_주민번호_SexTyp_Blind", "PAT_주민번호_SexTyp_BlindStar",
"PAT_나이", "PAT_나이_세", "PAT_성별_나이", "PAT_나이_개월수",
"PAT_나이_00개월_00일", "PAT_나이_00Months_00Days", "PAT_나이_개월_00일", "PAT_나이_00개월_일",
"PAT_실제생년월일", "PAT_국적", "PAT_장애등급", "PAT_건보세대주명",
"PAT_건보_급여_세대주명", "PAT_협력업체", "PAT_보호자연락처",
"OCM_FollowUp", "OCM_외출외박신청일", "OCM_외출외박종료일",