진료과 파생·입퇴원과 8종 — 144종 → 152종, OCM_진료과 개원일 분기 복원

진료과 파생 4종(bzDataInterface.vb:5556-6007): 그룹코드("한글명(그룹
코드)" 결합 — 행이 있으면 조각이 비어도 결합), 영어(DepEngNam),
대외명칭(DepOfcNam), 약어명칭(DepBrfNam). 전부 프리페치된 진료과
행(M_DepMst SELECT *)의 다른 컬럼이라 왕복이 늘지 않는다. 레거시의
시점 판정(퇴원→퇴원시점·재원→현재·외래→접수)은 문맥 적용일시와 같은
규칙이다 — 자격이 퇴원보다 먼저 끝난 희귀 케이스만 갈린다(주석).

입원과 2종(:6009-6110)은 접수 시점, 퇴원과 2종(:6162-6318)은
퇴원(ILV)/현재 시점의 진료과 — 문맥과 다른 시점이라
DepartmentCodeAt(GetCodInfDT 대응, 시작일시 최신 1행 고정)과
DischargeDepartment(P_ComInf 에 날짜 조건만으로 M_DepMst 를 조인하는
원문 모양 그대로)를 신설했다. 퇴원과 본체의 If/Else 동일 쿼리(낮병동
주석과 달리 복붙 결함 — 퇴원일시 비면 0행→빈)도 그대로 보존,
한글명칭만 접수일시 폴백이 실제로 있다.

OCM_진료과: 개원일(HspStrDte)이 요양기관 행에 생겨 레거시 분기
(2022-07-01 이후 개원 + 대외명칭 있음 → 대외명칭)를 그대로 복원 —
전에는 개원일이 없어 DepKorNam 으로 고정했었다.

- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 152종)
- --db-patient ①~㉝ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-19 17:04:26 +09:00
co-authored by Claude Fable 5
parent 975aff4211
commit c0ad782b6a
5 changed files with 198 additions and 4 deletions
@@ -94,6 +94,50 @@ public sealed class PatientContextStore
return new PatientContext(comNum, adpDtm, patInf, comInf, codInf, coiInf, cowInf); return new PatientContext(comNum, adpDtm, patInf, comInf, codInf, coiInf, cowInf);
} }
/// <summary>
/// 특정 시점의 진료과 코드 — 레거시 <c>GetCodInfDT(comNum, moment, 1)</c> 의 첫 행 대응.
/// 입원과(접수 시점)·퇴원과(퇴원/현재 시점) 태그가 문맥의 적용일시와 <b>다른 시점</b>을
/// 물을 때 쓴다. 원문 GetCodInfDT 는 ORDER BY 가 없어 임의 행이었다 — 문맥 로드와
/// 같은 규칙(시작일시 최신 1행)으로 고정한다. moment 가 비면 서버 현재시각이다.
/// </summary>
public string DepartmentCodeAt(decimal comNum, string? moment)
{
using var connection = new OracleConnection(connectionString);
connection.Open();
var at = (moment ?? string.Empty).Trim();
var row = Row(connection,
"SELECT CodDepCod FROM (SELECT CodDepCod FROM P_CodInf WHERE CodComNum = :k AND CodMtiSeq = 0"
+ (at.Length >= 12
? " AND :t BETWEEN CodStrDtm AND CodEndDtm"
: " AND TO_CHAR(SYSDATE, 'YYYYMMDDHH24MI') BETWEEN CodStrDtm AND CodEndDtm")
+ " ORDER BY CodStrDtm DESC) WHERE ROWNUM = 1",
at.Length >= 12
? new (string, object)[] { ("k", comNum), ("t", at[..12]) }
: new (string, object)[] { ("k", comNum) });
return PatientContext.Value(row, "CodDepCod");
}
/// <summary>
/// 퇴원과 조회(bzDataInterface.vb:6162-6231) — 내원의 퇴원일시(또는 접수일시)가
/// 유효기간 안인 진료과 행. 원문이 P_ComInf 에 M_DepMst 를 <b>날짜 조건만으로</b> 조인하는
/// 특이한 모양이라 그대로 옮겼다. anchorColumn 은 ComLevDtm 또는 ComAcpDtm.
/// </summary>
public IReadOnlyDictionary<string, string> DischargeDepartment(decimal comNum, string depCod, string anchorColumn)
{
if (anchorColumn is not ("ComLevDtm" or "ComAcpDtm"))
{
throw new ArgumentOutOfRangeException(nameof(anchorColumn));
}
using var connection = new OracleConnection(connectionString);
connection.Open();
return Row(connection,
"SELECT * FROM (SELECT DepOfcNam, DepKorNam FROM P_ComInf"
+ " INNER JOIN M_DepMst ON DepCod = :d"
+ $" AND {anchorColumn} BETWEEN DepStrDte AND DepEndDte || '2359'"
+ " WHERE ComNum = :k) WHERE ROWNUM <= 1",
("d", depCod), ("k", comNum));
}
/// <summary> /// <summary>
/// 한 행을 컬럼명 → 값 사전으로. 없으면 빈 사전. /// 한 행을 컬럼명 → 값 사전으로. 없으면 빈 사전.
/// 컬럼명은 <b>대문자</b>로 담는다 — 오라클이 대문자로 돌려주고, 호출부가 대소문자로 헷갈리면 /// 컬럼명은 <b>대문자</b>로 담는다 — 오라클이 대문자로 돌려주고, 호출부가 대소문자로 헷갈리면
@@ -185,6 +185,13 @@ public static class PatientIdentity
return () => connection.Length == 0 ? null : new PatientExtraStore(connection); return () => connection.Length == 0 ? null : new PatientExtraStore(connection);
} }
/// <summary>문맥 저장소 공급자 — 입원과·퇴원과의 시점별 진료과 조회용</summary>
public static Func<PatientContextStore?> ContextStoreSource()
{
var connection = ConfigService.Current.ConnectionString;
return () => connection.Length == 0 ? null : new PatientContextStore(connection);
}
/// <summary>수술 일정(S_OprInf) 공급자 — 최신순 여부별 1회</summary> /// <summary>수술 일정(S_OprInf) 공급자 — 최신순 여부별 1회</summary>
public static Func<bool, IReadOnlyDictionary<string, string>> ScheduledSurgerySourceFor(PatientContext context) public static Func<bool, IReadOnlyDictionary<string, string>> ScheduledSurgerySourceFor(PatientContext context)
{ {
@@ -75,7 +75,7 @@ public static class PatientSession
PatientIdentity.VitalSourceFor(picked), PatientIdentity.VitalSourceFor(picked),
PatientIdentity.ScheduledSurgerySourceFor(picked), PatientIdentity.ScheduledSurgerySourceFor(picked),
PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(), PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(),
PatientIdentity.ExtraStoreSource()) PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource())
: session; : session;
return (tags, new MDataTableRunner(document, Variables())); return (tags, new MDataTableRunner(document, Variables()));
} }
@@ -231,6 +231,14 @@ public sealed class PatientTagResolver : ITagValueResolver
private readonly Dictionary<string, TagValue> extraCache = new(StringComparer.Ordinal); private readonly Dictionary<string, TagValue> extraCache = new(StringComparer.Ordinal);
/// <summary>
/// 문맥 저장소 공급자 — 입원과·퇴원과처럼 문맥의 적용일시와 <b>다른 시점</b>을
/// 물어야 하는 태그가 쓴다(접수 시점·퇴원 시점의 진료과).
/// </summary>
private readonly Func<PatientContextStore?>? contextStoreSource;
private readonly Dictionary<string, TagValue> deptAtCache = new(StringComparer.Ordinal);
private readonly Dictionary<DiagnosisStore.Scope, IReadOnlyList<Dictionary<string, string>>> reviewCache = new(); private readonly Dictionary<DiagnosisStore.Scope, IReadOnlyList<Dictionary<string, string>>> reviewCache = new();
public PatientTagResolver(PatientContext context, ITagValueResolver next, public PatientTagResolver(PatientContext context, ITagValueResolver next,
@@ -246,7 +254,8 @@ public sealed class PatientTagResolver : ITagValueResolver
Func<bool, IReadOnlyDictionary<string, string>>? scheduledSurgerySource = null, Func<bool, IReadOnlyDictionary<string, string>>? scheduledSurgerySource = null,
Func<IReadOnlyDictionary<string, string>>? pathologyDoctorSource = null, Func<IReadOnlyDictionary<string, string>>? pathologyDoctorSource = null,
Func<IReadOnlyDictionary<string, string>>? labDoctorSource = null, Func<IReadOnlyDictionary<string, string>>? labDoctorSource = null,
Func<PatientExtraStore?>? extraStoreSource = null) Func<PatientExtraStore?>? extraStoreSource = null,
Func<PatientContextStore?>? contextStoreSource = null)
{ {
this.context = context; this.context = context;
this.next = next; this.next = next;
@@ -264,6 +273,7 @@ public sealed class PatientTagResolver : ITagValueResolver
this.pathologyDoctorSource = pathologyDoctorSource; this.pathologyDoctorSource = pathologyDoctorSource;
this.labDoctorSource = labDoctorSource; this.labDoctorSource = labDoctorSource;
this.extraStoreSource = extraStoreSource; this.extraStoreSource = extraStoreSource;
this.contextStoreSource = contextStoreSource;
} }
#endregion #endregion
@@ -319,6 +329,10 @@ public sealed class PatientTagResolver : ITagValueResolver
{ {
return stayValue; return stayValue;
} }
if (FromDeptAt(tag) is { } deptAtValue)
{
return deptAtValue;
}
if (Computed.TryGetValue(tag, out var compute)) if (Computed.TryGetValue(tag, out var compute))
{ {
var made = compute(context); var made = compute(context);
@@ -514,14 +528,28 @@ public sealed class PatientTagResolver : ITagValueResolver
/// </summary> /// </summary>
private TagValue? FromFacility(string tag) private TagValue? FromFacility(string tag)
{ {
string Dep(string column) => department.TryGetValue(column, out var v) ? v.Trim() : string.Empty;
TagValue DeptColumn(string column, string reasonWhenEmpty)
{
if (department.Count == 0)
{
return new TagValue(false, "이 내원의 진료과를 찾지 못했습니다");
}
var value = Dep(column);
return value.Length > 0 ? new TagValue(true, value) : new TagValue(false, reasonWhenEmpty);
}
switch (tag) switch (tag)
{ {
case "OCM_진료과": case "OCM_진료과":
{ {
string Dep(string column) => department.TryGetValue(column, out var v) ? v.Trim() : string.Empty; // 이름 선택(bzDataInterface.vb:5474-5484): NH 는 무조건 DepOfcNam,
// 그 밖은 개원일 2022-07-01 이후 + DepOfcNam 있음 → DepOfcNam, 아니면 DepKorNam.
// 개원일은 요양기관 행의 HspStrDte 다 — 처음엔 없어서 DepKorNam 으로 고정했었다.
var office = Dep("DepOfcNam"); var office = Dep("DepOfcNam");
var value = string.Equals(UserSession.Current.HspCod, "NH", StringComparison.OrdinalIgnoreCase) var value = string.Equals(UserSession.Current.HspCod, "NH", StringComparison.OrdinalIgnoreCase)
? office ? office
: string.CompareOrdinal(PatientContext.Value(hospital, "HspStrDte"), "20220701") >= 0
&& office.Length > 0 ? office
: Dep("DepKorNam"); : Dep("DepKorNam");
return value.Length > 0 return value.Length > 0
? new TagValue(true, value) ? new TagValue(true, value)
@@ -529,6 +557,20 @@ public sealed class PatientTagResolver : ITagValueResolver
? "이 내원의 진료과를 찾지 못했습니다" ? "이 내원의 진료과를 찾지 못했습니다"
: "진료과 마스터에 이름이 비어 있습니다"); : "진료과 마스터에 이름이 비어 있습니다");
} }
// ── 진료과 파생 4종 — 같은 행(M_DepMst SELECT *)의 다른 컬럼(:5556-6007) ──
// 레거시의 시점(퇴원했으면 퇴원 시점, 재원 중이면 현재, 외래면 접수)은 문맥
// 적용일시와 같은 판정이다 — 자격이 퇴원보다 먼저 끝난 희귀 케이스에서만 갈린다.
case "OCM_진료과_그룹코드":
// 행이 있으면 무조건 "이름(그룹코드)" 결합이다(:5593) — 조각이 비어도 그대로
return department.Count == 0
? new TagValue(false, "이 내원의 진료과를 찾지 못했습니다")
: new TagValue(true, $"{Dep("DepKorNam")}({Dep("DepGrpCod")})");
case "OCM_진료과_영어":
return DeptColumn("DepEngNam", "진료과 마스터에 영문 이름이 비어 있습니다");
case "OCM_진료과대외명칭":
return DeptColumn("DepOfcNam", "진료과 마스터에 대외 명칭이 비어 있습니다");
case "OCM_진료과약어명칭":
return DeptColumn("DepBrfNam", "진료과 마스터에 약어 명칭이 비어 있습니다");
// ── 요양기관 12종 — 같은 조인의 다른 컬럼(bzDataInterface.vb:15656-16709) ── // ── 요양기관 12종 — 같은 조인의 다른 컬럼(bzDataInterface.vb:15656-16709) ──
// 접미·결합도 본문 그대로: 병원장 = 병원명+"장", 귀하 = +"장 귀하", // 접미·결합도 본문 그대로: 병원장 = 병원명+"장", 귀하 = +"장 귀하",
// 전화_팩스 = "Tel : … FAX : …", 영문판은 "+82 - " 접두가 붙는다. // 전화_팩스 = "Tel : … FAX : …", 영문판은 "+82 - " 접두가 붙는다.
@@ -895,6 +937,105 @@ public sealed class PatientTagResolver : ITagValueResolver
: new TagValue(false, "수술일자 값이 형식에 맞지 않습니다"); : new TagValue(false, "수술일자 값이 형식에 맞지 않습니다");
} }
/// <summary>
/// 입원과·퇴원과 4종(bzDataInterface.vb:6009-6318) — 문맥의 적용일시와 <b>다른 시점</b>의
/// 진료과를 묻는다. 입원과는 <b>접수 시점</b>(전과했으면 처음 과), 퇴원과는
/// 퇴원(ILV → ComLevDtm) 또는 현재 시점의 과다.
///
/// 퇴원과 본체(:6194-6210)는 "낮병동은 퇴원일시가 빈다"는 주석과 달리 If/Else 가
/// <b>똑같은 쿼리</b>다(복붙 결함으로 보인다 — ComLevDtm 이 비면 0행 → 빈 값).
/// 한글명칭(:6265-6281)만 빈 퇴원일시에서 접수일시로 대신한다. 둘 다 그대로 옮겼다.
/// </summary>
private TagValue? FromDeptAt(string tag)
{
var admission = tag is "OCM_입원과" or "OCM_입원과_한글명칭";
var discharge = tag is "OCM_퇴원과" or "OCM_퇴원과_한글명칭";
if (!admission && !discharge)
{
return null;
}
if (deptAtCache.TryGetValue(tag, out var cached))
{
return cached;
}
var value = Compute();
deptAtCache[tag] = value;
return value;
TagValue Compute()
{
if (contextStoreSource is null)
{
return new TagValue(false, "진료과 시점 조회가 연결되지 않았습니다");
}
var comNum = PatientContext.Value(context.ComInf, "ComNum");
if (!decimal.TryParse(comNum, out var m))
{
return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
}
try
{
var store = contextStoreSource();
if (store is null)
{
return new TagValue(false, "진료과 시점 조회가 연결되지 않았습니다");
}
var open = PatientContext.Value(hospital, "HspStrDte");
string PickName(IReadOnlyDictionary<string, string> row)
{
// 개원일 2022-07-01 이후 + 대외명칭 있음 → 대외명칭, 아니면 한글명(:6044-6048)
var office = PatientContext.Value(row, "DepOfcNam").Trim();
return string.CompareOrdinal(open, "20220701") >= 0 && office.Length > 0
? office
: PatientContext.Value(row, "DepKorNam").Trim();
}
if (admission)
{
var acp = PatientContext.Value(context.ComInf, "ComAcpDtm");
if (acp.Length < 12)
{
return new TagValue(false, "이 내원의 접수일시를 읽지 못했습니다");
}
var depCod = store.DepartmentCodeAt(m, acp[..12]);
if (depCod.Length == 0)
{
return new TagValue(false, "접수 시점의 진료 행(P_CodInf)이 없습니다");
}
var row = store.Department(depCod, acp[..8]);
if (row.Count == 0)
{
return new TagValue(true, string.Empty); // 레거시도 마스터 0행이면 "" 다
}
return new TagValue(true, tag == "OCM_입원과"
? PickName(row)
: PatientContext.Value(row, "DepKorNam").Trim());
}
var prg = PatientContext.Value(context.ComInf, "ComPrgStt");
var lev = PatientContext.Value(context.ComInf, "ComLevDtm");
var depAtLeave = store.DepartmentCodeAt(m,
prg == "ILV" && lev.Length >= 12 ? lev[..12] : null);
if (depAtLeave.Length == 0)
{
return new TagValue(true, string.Empty); // 레거시가 명시적으로 "" 를 돌려준다(:6189)
}
var anchor = tag == "OCM_퇴원과_한글명칭" && lev.Trim().Length == 0
? "ComAcpDtm" : "ComLevDtm";
var deptRow = store.DischargeDepartment(m, depAtLeave, anchor);
if (deptRow.Count == 0)
{
return new TagValue(true, string.Empty);
}
return new TagValue(true, tag == "OCM_퇴원과"
? PickName(deptRow)
: PatientContext.Value(deptRow, "DepKorNam").Trim());
}
catch (Exception ex)
{
return new TagValue(false, $"진료과 시점 조회에 실패했습니다: {ex.GetType().Name}");
}
}
}
/// <summary> /// <summary>
/// 입·퇴원 일시와 재원일수 12종(bzDataInterface.vb:7152-7940, 전부 Case Else 갈래). /// 입·퇴원 일시와 재원일수 12종(bzDataInterface.vb:7152-7940, 전부 Case Else 갈래).
/// ///
@@ -1275,6 +1416,8 @@ public sealed class PatientTagResolver : ITagValueResolver
"PAT_건보_급여_세대주명", "PAT_협력업체", "PAT_보호자연락처", "PAT_건보_급여_세대주명", "PAT_협력업체", "PAT_보호자연락처",
"OCM_FollowUp", "OCM_외출외박신청일", "OCM_외출외박종료일", "OCM_FollowUp", "OCM_외출외박신청일", "OCM_외출외박종료일",
"OCM_최초내원일", "OCM_초진일_발병일", "OCM_신환_초진일", "OCM_최초내원일", "OCM_초진일_발병일", "OCM_신환_초진일",
"OCM_진료과_그룹코드", "OCM_진료과_영어", "OCM_진료과대외명칭", "OCM_진료과약어명칭",
"OCM_입원과", "OCM_입원과_한글명칭", "OCM_퇴원과", "OCM_퇴원과_한글명칭",
"OCM_입원시간", "OCM_입원일시", "OCM_입원일시_영문", "OCM_입실시간", "OCM_입원시간", "OCM_입원일시", "OCM_입원일시_영문", "OCM_입실시간",
"OCM_의사퇴원예고일시", "OCM_퇴원예정일시", "OCM_퇴원시간", "OCM_퇴원일시", "OCM_의사퇴원예고일시", "OCM_퇴원예정일시", "OCM_퇴원시간", "OCM_퇴원일시",
"OCM_퇴원예고일시", "OCM_재원일수", "OCM_퇴원예고재원일수", "OCM_입원일자_낮병동", "OCM_퇴원예고일시", "OCM_재원일수", "OCM_퇴원예고재원일수", "OCM_입원일자_낮병동",
@@ -232,7 +232,7 @@ public partial class PreviewWindow : Window
PatientIdentity.VitalSourceFor(picked), PatientIdentity.VitalSourceFor(picked),
PatientIdentity.ScheduledSurgerySourceFor(picked), PatientIdentity.ScheduledSurgerySourceFor(picked),
PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(), PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(),
PatientIdentity.ExtraStoreSource()); PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource());
// 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아 // 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다. // 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
fields = new MDataTableRunner(designer.Document, fields = new MDataTableRunner(designer.Document,