주민번호 복호화를 붙인다 — 이 DB 는 실제로 암호화되어 있었다
문서(docs/RESNUM-DECRYPT.md)의 1→2→3 을 실행. 태그 7종 → 11종. ## 이 DB 가 암호화 병원이다 진단이 읽은 설정: 전체=Y 주민번호=Y. 앞 커밋에서 PAT_주민번호 를 뺀 판단이 맞았다 — PatResNum 을 그대로 썼으면 <b>암호문이 종이에 찍혔다</b>. ## 앱은 키를 갖지 않는다 복호화는 오라클 패키지가 한다. SELECT SUBSTR(:e,1,7) || CRYPTO_AES256.DEC_AES(SUBSTR(:e,8,256)) FROM DUAL 바인드 변수이고 접근 통제는 DB 권한이다. SCHILDREN 갈래(UDF_GETDECRESNUM_damo)도 함께 옮겼다. 앞 7자리가 평문이라 SUBSTR(...,1,7) 을 이어 붙이는 것을 빠뜨리면 앞자리가 사라진다. ## 설정을 못 읽으면 값을 만들지 않는다 "암호화 아님"으로 가정하면 PatResNum 을 평문처럼 써서 암호문을 찍는다. PatientIdentity 는 예외 시 빈 문자열을 돌려주고 사유만 로그에 남긴다 — 값은 남기지 않는다. 주민번호를 만드는 자리를 한 곳으로 좁혀 뒀다(만드는 경로가 여럿이면 어디서 새는지 추적할 수 없다). ## 순수 계산은 표로 고정했다 (테스트 13건) 보정 ①(기관기호, SRCH·JINJU): 앞 6자리를 PatBthDay 로 갈아 끼운다. 원본은 If Not DateValidCheck(Substring(1,6)) 으로 감싸 있지만 <b>그 조건은 항상 참이다</b> — DateValidCheck 는 길이가 8·12·14·17 이 아니면 무조건 False 인데 6자리를 넘긴다. 여기서 "고쳐서" 진짜 날짜 검사를 넣으면 레거시와 다른 번호가 나온다(이 DB 가 SRCH 다). 이식은 동작을 옮기는 일이지 바로잡는 일이 아니다 — 그 사실을 테스트로 못 박았다. 보정 ②(외국인): 7번째 자리 0/9 를 PatBthDay 세기로 6/8·5/7 로. 국적코드가 있어야 걸린다. 성별: 7번째 자리 mod 2. PAT_성별 은 CoiCalCod 가 "24" 로 시작하면 PatSexTyp 을 먼저 쓴다. ## 추측하면 걸린다 M_DtsMst 컬럼을 DtsTabCod·DtlCod 로 추측해 ORA-00904 를 맞았다. 실제는 DtsTblCod·DtsDtlCod·DtsCod 다(dtCommonLib.vb:1136). ## 확인한 것과 못 한 것을 갈라 적는다 진단은 HIS 사용자 문맥 없이 도므로 병원코드가 비고, SRCH 기관기호 보정 갈래는 이 경로로 확인되지 않는다. 통과로 세지 않고 [미확인] 줄로 적는다 — 그 갈래는 순수 함수 테스트가 덮는다. 진단 리포트에 <b>값을 쓰지 않는다</b> — 길이와 "전부 숫자인가"만 적는다. 리포트는 파일로 남는다. ## 아직 안 한 것 나이(AgeCheck)는 병원별 규칙과 의료급여 전산관리번호 규약이 붙어 별도 작업이다. 개인정보 접근 로그(C_PdrInf)에 쓸지는 운영 DB 쓰기라 승인 전까지 하지 않는다. ## 게이트 - dotnet test 336/336 (주민번호 판정 13건 신규) - --db-patient ①~㉑ 전건 통과 (⑲ 설정 · ⑳ 13자리 · ㉑ 성별) - --edit-smoke 실패 0 (이름 검사가 파생 태그 4종까지 본다) - --dialog-shots FAIL 0 - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
34c53e3d69
commit
4bdd583adb
@@ -0,0 +1,109 @@
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// 주민번호 보정과 그로부터 나오는 값들 — 복호화 <b>뒤</b>에 붙는 순수 계산.
|
||||
///
|
||||
/// 복호화 자체는 DB 가 한다(<c>CRYPTO_AES256.DEC_AES</c>). 앱은 키를 갖지 않는다 —
|
||||
/// 자세한 것은 <c>docs/RESNUM-DECRYPT.md</c>.
|
||||
///
|
||||
/// <b>순수 함수로 두는 이유.</b> 갈래가 병원·국적·생년월일에 걸쳐 있고 실DB 로는
|
||||
/// 한 갈래씩 확인할 수 없다. 그리고 이 값이 틀리면 종이에 <b>다른 사람의 번호</b>가 찍힌다.
|
||||
/// </summary>
|
||||
public static class ResidentNumber
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>주민번호에 기관기호가 들어가는 병원 — bzPatientInfo.vb:556</summary>
|
||||
private static readonly string[] InstitutionPrefixHospitals = { "SRCH", "JINJU" };
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 복호화된 주민번호를 실제로 쓸 형태로 — 레거시 <c>bzPatientInfo.vb:546-580</c>.
|
||||
///
|
||||
/// <b>보정 ①(기관기호).</b> SRCH·JINJU 는 앞 6자리를 <paramref name="birthDay"/> 의
|
||||
/// YYMMDD 로 갈아 끼운다.
|
||||
///
|
||||
/// 원본은 <c>If Not DateValidCheck(sResNum.Substring(1, 6))</c> 로 감싸 있지만
|
||||
/// <b>그 조건은 항상 참이다</b> — <c>DateValidCheck</c> 는 길이가 8·12·14·17 이 아니면
|
||||
/// 무조건 False 를 돌려주는데(clsCommonLib.vb DateValidCheck) 호출부는 6자리를 넘긴다.
|
||||
/// 그래서 조건문이 아무것도 걸러 내지 않는다.
|
||||
///
|
||||
/// 여기서 "고쳐서" 진짜 날짜 검사를 넣으면 <b>레거시와 다른 번호</b>가 나온다 —
|
||||
/// 앞자리가 유효한 날짜인 환자에서 갈린다. 이식은 동작을 옮기는 일이지 바로잡는 일이 아니다.
|
||||
/// 바로잡을지는 별도 결정 사항이고, 그때 이 주석이 근거가 된다.
|
||||
///
|
||||
/// <b>보정 ②(외국인).</b> 7번째 자리가 <c>0</c> 또는 <c>9</c> 이고 국적코드가 있으면
|
||||
/// 생년월일의 세기로 그 자리를 바꾼다 — 0 → 19세기대면 6, 아니면 8 / 9 → 5 또는 7.
|
||||
/// </summary>
|
||||
/// <param name="decrypted">복호화(또는 평문) 주민번호 13자리</param>
|
||||
/// <param name="birthDay">P_PatInf.PatBthDay — YYYYMMDD</param>
|
||||
/// <param name="nationCode">P_PatInf.PatNatCod — 비어 있으면 외국인 보정을 하지 않는다</param>
|
||||
/// <param name="hospitalCode">HspCfg.HspCod</param>
|
||||
public static string Normalize(string? decrypted, string? birthDay, string? nationCode,
|
||||
string? hospitalCode)
|
||||
{
|
||||
var value = Clean(decrypted);
|
||||
var birth = Clean(birthDay);
|
||||
if (value.Length < 13)
|
||||
{
|
||||
// 원본은 Substring 에서 예외가 나고 Try 가 삼킨다 — 손대지 않는 것이 같은 결과다
|
||||
return value;
|
||||
}
|
||||
|
||||
// ① 기관기호 — 조건이 항상 참이므로 조건 자체를 옮기지 않는다(위 설명)
|
||||
if (Array.Exists(InstitutionPrefixHospitals,
|
||||
h => string.Equals(h, Clean(hospitalCode), StringComparison.OrdinalIgnoreCase))
|
||||
&& birth.Length >= 8)
|
||||
{
|
||||
value = birth[2..8] + value[6..13];
|
||||
}
|
||||
|
||||
// ② 외국인 — 국적코드가 있어야 하고, 세기 판정에 생년월일이 필요하다
|
||||
var seventh = value[6];
|
||||
if ((seventh == '0' || seventh == '9') && Clean(nationCode).Length > 0 && birth.Length >= 2)
|
||||
{
|
||||
var nineteenth = birth[..2] == "19";
|
||||
var digit = seventh == '0'
|
||||
? (nineteenth ? '6' : '8')
|
||||
: (nineteenth ? '5' : '7');
|
||||
value = value[..6] + digit + value[7..13];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 성별 — 7번째 자리 mod 2 (<c>clsCommonLib.vb:613-656</c> GetSexAge).
|
||||
/// 짝수면 F, 홀수면 M. 숫자가 아니면 빈 문자열 —
|
||||
/// 원본은 <c>CLong</c> 에서 예외가 나고 삼켜져 값이 비는 것과 같다.
|
||||
/// </summary>
|
||||
public static string SexOf(string? residentNumber)
|
||||
{
|
||||
var value = Clean(residentNumber);
|
||||
if (value.Length < 7 || !char.IsAsciiDigit(value[6]))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
return (value[6] - '0') % 2 == 0 ? "F" : "M";
|
||||
}
|
||||
|
||||
/// <summary>한글 성별 — F 는 여, M 은 남(<c>bzDataInterface.vb:305-316</c>)</summary>
|
||||
public static string SexKoreanOf(string? residentNumber) => SexOf(residentNumber) switch
|
||||
{
|
||||
"F" => "여",
|
||||
"M" => "남",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 하이픈 형태 — <c>bzDataInterface.vb:91-102</c>.
|
||||
/// 구분자가 <b>" - "</b>(양쪽 공백)이다. 공백을 빼면 레거시 서식과 폭이 달라진다.
|
||||
/// </summary>
|
||||
public static string WithDash(string? residentNumber)
|
||||
{
|
||||
var value = Clean(residentNumber);
|
||||
return value.Length >= 13 ? $"{value[..6]} - {value[6..13]}" : value;
|
||||
}
|
||||
|
||||
private static string Clean(string? value) => (value ?? string.Empty).Trim();
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user