병리판독의·진단검사의 4종 — 106종 → 110종

ETC_병리판독의사명/전문의번호(bzDataInterface.vb:17318/:17515 — M_UidMst
UidDtrYon='Y' AND UidDepCod='TLAB' + 유효기간), ETC_진단검사의사명/
전문의번호(:17550/:17655 — M_DepMst DepGrpCod='LAB' 조인 + UidLicNum
IS NOT NULL). 환자와 무관한 원내 의사 조회지만 레거시가 환자 태그로
노출하므로 PatientTagResolver 에 둔다(lazy 소스 + 캐시 — 이 태그가
없는 서식이 대부분이라 프리페치하지 않는다).

의도적 이탈 하나: 원문엔 ORDER BY 가 없어 TLAB 의사가 둘이면
이름과 전문의번호가 서로 다른 사람이 될 수 있었다(태그마다 따로
조회하므로). ORDER BY UidCod + 1행 고정으로 두 태그가 반드시 같은
사람을 가리키게 했다 — 담당의 때 정한 결정 규칙의 세 번째 적용.

- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 110종)
- --db-patient ①~㉚ 전건 통과 — ㉚ 신설: 두 조회가 예외 없이 돈다
  (이 시험 DB 는 TLAB·LAB 의사 0행 — 태그는 사유로 완결되는 정상 경로)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-19 16:18:37 +09:00
co-authored by Claude Fable 5
parent 4dad271e9c
commit ba6da2252f
6 changed files with 137 additions and 3 deletions
@@ -226,6 +226,38 @@ public sealed class PatientContextStore
("d", code), ("y", date[..8]));
}
/// <summary>
/// 병리판독의(TLAB) 한 명 — <c>ETC_병리판독의사명/전문의번호</c>(bzDataInterface.vb:17318, 17515).
/// 원문에 ORDER BY 가 없어 TLAB 의사가 둘이면 이름과 번호가 <b>서로 다른 사람</b>이 될 수 있었다
/// (태그마다 따로 조회했으므로). 한 행을 UidCod 순으로 고정해 두 태그가 같은 사람을 가리키게 한다.
/// </summary>
public IReadOnlyDictionary<string, string> PathologyDoctor()
{
using var connection = new OracleConnection(connectionString);
connection.Open();
return Row(connection,
"SELECT * FROM (SELECT * FROM M_UidMst WHERE UidDtrYon = 'Y' AND TRIM(UidDepCod) = 'TLAB'"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
+ " ORDER BY UidCod) WHERE ROWNUM = 1");
}
/// <summary>
/// 진단검사의(부서그룹 LAB) 한 명 — <c>ETC_진단검사의사명/전문의번호</c>(:17550, 17655).
/// 조건은 원문 그대로(의사 + 면허 보유 + 유효기간 + LAB 그룹 부서), 선택 규칙만 고정한다.
/// </summary>
public IReadOnlyDictionary<string, string> LabDoctor()
{
using var connection = new OracleConnection(connectionString);
connection.Open();
return Row(connection,
"SELECT * FROM (SELECT u.* FROM M_UidMst u"
+ " INNER JOIN M_DepMst d ON u.UidDepCod = d.DepCod AND d.DepGrpCod = 'LAB'"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN d.DepStrDte AND d.DepEndDte"
+ " WHERE u.UidDtrYon = 'Y' AND u.UidLicNum IS NOT NULL"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN u.UidStrDte AND u.UidEndDte"
+ " ORDER BY u.UidCod) WHERE ROWNUM = 1");
}
/// <summary>
/// 수술 일정(S_OprInf) 한 행 — <c>OCM_수술일자</c>·<c>ETC_수술일자_*</c> 계열이 쓴다.
/// <c>WHERE OprComNum = :c AND OprStt = 'E' ORDER BY OprKey</c> —
@@ -2291,6 +2291,29 @@ public static class DbSmoke
Check("㉙ 바이탈 조회가 값을 돌려준다", weight.Length > 0,
"EmdWeight 널 필터나 조인을 의심할 것");
}
// ㉚ 병리판독의·진단검사의 — 환자와 무관한 원내 의사 조회.
// 이 시험 DB 에 TLAB 소속·LAB 그룹 의사가 없을 수 있다 —
// 0행은 실패가 아니라 태그가 사유로 완결되는 정상 경로다.
// 여기서 지키는 것은 "예외 없이 돈다"(컬럼·조인 이름이 맞다)이다.
try
{
var contextStore = new PatientContextStore(conn);
var pathologyRow = contextStore.PathologyDoctor();
var labRow = contextStore.LabDoctor();
static string Shape(IReadOnlyDictionary<string, string> row)
=> row.Count == 0 ? "0행"
: $"이름 {(row.TryGetValue("UidNam", out var n) ? n.Trim().Length : 0)}자"
+ $"·전문의번호 {(row.TryGetValue("UidSpcLic", out var s) ? s.Trim().Length : 0)}자";
lines.Add($" 병리판독의(TLAB): {Shape(pathologyRow)}"
+ $" / 진단검사의(LAB그룹): {Shape(labRow)} (값은 기록하지 않는다)");
Check("㉚ 병리·진단검사 의사 조회가 예외 없이 돈다", true, "");
}
catch (Exception ex)
{
Check("㉚ 병리·진단검사 의사 조회가 예외 없이 돈다", false,
$"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}");
}
}
}
}
@@ -161,6 +161,23 @@ public static class PatientIdentity
: new VitalStore(connection).One(context.ComNum, expr, pick, notNull, order);
}
/// <summary>병리판독의·진단검사의 공급자 — 환자와 무관하지만 환자 태그로 노출된다</summary>
public static Func<IReadOnlyDictionary<string, string>> PathologyDoctorSource()
{
var connection = ConfigService.Current.ConnectionString;
return () => connection.Length == 0
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
: new PatientContextStore(connection).PathologyDoctor();
}
public static Func<IReadOnlyDictionary<string, string>> LabDoctorSource()
{
var connection = ConfigService.Current.ConnectionString;
return () => connection.Length == 0
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
: new PatientContextStore(connection).LabDoctor();
}
/// <summary>수술 일정(S_OprInf) 공급자 — 최신순 여부별 1회</summary>
public static Func<bool, IReadOnlyDictionary<string, string>> ScheduledSurgerySourceFor(PatientContext context)
{
@@ -73,7 +73,8 @@ public static class PatientSession
PatientIdentity.SurgerySourceFor(picked),
PatientIdentity.ReviewDiagnosisSourceFor(picked),
PatientIdentity.VitalSourceFor(picked),
PatientIdentity.ScheduledSurgerySourceFor(picked))
PatientIdentity.ScheduledSurgerySourceFor(picked),
PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource())
: session;
return (tags, new MDataTableRunner(document, Variables()));
}
@@ -195,6 +195,13 @@ public sealed class PatientTagResolver : ITagValueResolver
private readonly Dictionary<bool, IReadOnlyDictionary<string, string>> scheduledSurgeryCache = new();
/// <summary>병리판독의(TLAB)·진단검사의(LAB) 공급자 — 각 1회 캐시</summary>
private readonly Func<IReadOnlyDictionary<string, string>>? pathologyDoctorSource;
private readonly Func<IReadOnlyDictionary<string, string>>? labDoctorSource;
private readonly Dictionary<string, IReadOnlyDictionary<string, string>> specialDoctorCache = new(StringComparer.Ordinal);
private readonly Dictionary<DiagnosisStore.Scope, IReadOnlyList<Dictionary<string, string>>> reviewCache = new();
public PatientTagResolver(PatientContext context, ITagValueResolver next,
@@ -207,7 +214,9 @@ public sealed class PatientTagResolver : ITagValueResolver
Func<IReadOnlyList<Dictionary<string, string>>>? surgerySource = null,
Func<DiagnosisStore.Scope, IReadOnlyList<Dictionary<string, string>>>? reviewSource = null,
Func<string, string, string[], string, string>? vitalSource = null,
Func<bool, IReadOnlyDictionary<string, string>>? scheduledSurgerySource = null)
Func<bool, IReadOnlyDictionary<string, string>>? scheduledSurgerySource = null,
Func<IReadOnlyDictionary<string, string>>? pathologyDoctorSource = null,
Func<IReadOnlyDictionary<string, string>>? labDoctorSource = null)
{
this.context = context;
this.next = next;
@@ -222,6 +231,8 @@ public sealed class PatientTagResolver : ITagValueResolver
this.reviewSource = reviewSource;
this.vitalSource = vitalSource;
this.scheduledSurgerySource = scheduledSurgerySource;
this.pathologyDoctorSource = pathologyDoctorSource;
this.labDoctorSource = labDoctorSource;
}
#endregion
@@ -265,6 +276,10 @@ public sealed class PatientTagResolver : ITagValueResolver
{
return scheduleValue;
}
if (FromSpecialDoctor(tag) is { } specialValue)
{
return specialValue;
}
if (Computed.TryGetValue(tag, out var compute))
{
var made = compute(context);
@@ -816,6 +831,49 @@ public sealed class PatientTagResolver : ITagValueResolver
: new TagValue(false, "수술일자 값이 형식에 맞지 않습니다");
}
/// <summary>
/// 병리판독의·진단검사의 4종 — 환자와 무관한 원내 의사 조회지만 레거시가 환자 태그로 노출한다.
/// 이름과 번호가 <b>같은 행</b>에서 나온다(레거시는 태그마다 따로 조회해 다른 사람이 될 수 있었다).
/// </summary>
private TagValue? FromSpecialDoctor(string tag)
{
var pathology = tag is "ETC_병리판독의사명" or "ETC_병리판독의사전문의번호";
var lab = tag is "ETC_진단검사의사명" or "ETC_진단검사의사전문의번호";
if (!pathology && !lab)
{
return null;
}
var source = pathology ? pathologyDoctorSource : labDoctorSource;
var cacheKey = pathology ? "P" : "L";
if (source is null)
{
return new TagValue(false, "원내 의사 조회가 연결되지 않았습니다");
}
if (!specialDoctorCache.TryGetValue(cacheKey, out var row))
{
try
{
row = source();
}
catch (Exception ex)
{
return new TagValue(false, $"원내 의사 조회에 실패했습니다: {ex.GetType().Name}");
}
specialDoctorCache[cacheKey] = row;
}
if (row.Count == 0)
{
return new TagValue(false, pathology
? "TLAB(병리) 소속의 유효한 의사가 없습니다"
: "LAB 그룹 부서의 유효한 의사가 없습니다");
}
var column = tag.EndsWith("전문의번호", StringComparison.Ordinal) ? "UidSpcLic" : "UidNam";
var value = row.TryGetValue(column, out var v) ? v.Trim() : string.Empty;
return value.Length > 0
? new TagValue(true, value)
: new TagValue(false, $"의사 행은 있는데 {column} 값이 비어 있습니다");
}
private TagValue? FromVital(string tag)
{
// 머리둘레 두 종은 레거시 결함이다 — SELECT 는 EmdHc AS HC 인데 Item("BP") 를 읽어
@@ -886,6 +944,8 @@ public sealed class PatientTagResolver : ITagValueResolver
"OCM_머리둘레", "OCM_머리둘레_LAST",
"OCM_수술일자", "OCM_수술일자_마지막수술", "ETC_수술일자_1_몇년",
"ETC_수술일자_1_몇년2자리", "ETC_수술일자_2_몇월", "ETC_수술일자_3_몇일",
"ETC_병리판독의사명", "ETC_병리판독의사전문의번호",
"ETC_진단검사의사명", "ETC_진단검사의사전문의번호",
};
/// <summary>
@@ -230,7 +230,8 @@ public partial class PreviewWindow : Window
PatientIdentity.SurgerySourceFor(picked),
PatientIdentity.ReviewDiagnosisSourceFor(picked),
PatientIdentity.VitalSourceFor(picked),
PatientIdentity.ScheduledSurgerySourceFor(picked));
PatientIdentity.ScheduledSurgerySourceFor(picked),
PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource());
// 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
fields = new MDataTableRunner(designer.Document,