diff --git a/src/SheetMe.Data/Stores/PatientContextStore.cs b/src/SheetMe.Data/Stores/PatientContextStore.cs
index cf080f1..85203ba 100644
--- a/src/SheetMe.Data/Stores/PatientContextStore.cs
+++ b/src/SheetMe.Data/Stores/PatientContextStore.cs
@@ -101,12 +101,17 @@ public sealed class PatientContextStore
/// 같은 규칙(시작일시 최신 1행)으로 고정한다. moment 가 비면 서버 현재시각이다.
///
public string DepartmentCodeAt(decimal comNum, string? moment)
+ => PatientContext.Value(CodInfAt(comNum, moment), "CodDepCod");
+
+ /// 특정 시점의 진료 행(진료과 코드 + 담당의 코드) — 의 원형
+ public IReadOnlyDictionary CodInfAt(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"
+ return Row(connection,
+ "SELECT CodDepCod, CodDtrCod FROM (SELECT CodDepCod, CodDtrCod 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")
@@ -114,7 +119,59 @@ public sealed class PatientContextStore
at.Length >= 12
? new (string, object)[] { ("k", comNum), ("t", at[..12]) }
: new (string, object)[] { ("k", comNum) });
- return PatientContext.Value(row, "CodDepCod");
+ }
+
+ ///
+ /// 특정 일자에 유효한 사용자 이름(M_UidMst.UidNam) — 입원의사·퇴원의사가 쓴다
+ /// (bzDataInterface.vb:6520-6527). 유효기간이 겹치는 이중 행은 정상 데이터가 아니지만
+ /// 원문의 Rows(0) 대신 한 행으로 고정한다.
+ ///
+ public string UserNameAt(string uidCod, string day)
+ {
+ if (uidCod.Trim().Length == 0 || day.Trim().Length < 8)
+ {
+ return string.Empty;
+ }
+ using var connection = new OracleConnection(connectionString);
+ connection.Open();
+ var row = Row(connection,
+ "SELECT UidNam FROM (SELECT UidNam FROM M_UidMst WHERE UidCod = :u"
+ + " AND :d BETWEEN UidStrDte AND UidEndDte ORDER BY UidStrDte DESC) WHERE ROWNUM = 1",
+ ("u", uidCod.Trim()), ("d", day.Trim()[..8]));
+ return PatientContext.Value(row, "UidNam").Trim();
+ }
+
+ ///
+ /// 협진 목록(bzDataInterface.vb:6360-6494) — 회신 완료(CstSttFlg='G')된 협진의
+ /// 회신 과(+의사). 원문 그대로: DISTINCT + 시작일 순 + 앞에서 과는 2건, 과·의사는 3건.
+ ///
+ public IReadOnlyList> Consults(decimal comNum, bool withDoctor)
+ {
+ using var connection = new OracleConnection(connectionString);
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.BindByName = true;
+ command.CommandText = withDoctor
+ ? "SELECT * FROM (SELECT DISTINCT UidCod, UidNam, DepCod, DepKorNam, CstStrDte FROM O_CstInf"
+ + " INNER JOIN M_DepMst ON CstRplDep = DepCod AND CstStrDte BETWEEN DepStrDte AND DepEndDte"
+ + " INNER JOIN M_UidMst ON CstRplDtr = UidCod AND CstStrDte BETWEEN UidStrDte AND UidEndDte"
+ + " WHERE CstComNum = :m AND CstSttFlg = 'G' ORDER BY CstStrDte) WHERE ROWNUM <= 3"
+ : "SELECT * FROM (SELECT DISTINCT DepCod, DepKorNam, CstStrDte FROM O_CstInf"
+ + " INNER JOIN M_DepMst ON CstRplDep = DepCod AND CstStrDte BETWEEN DepStrDte AND DepEndDte"
+ + " WHERE CstComNum = :m AND CstSttFlg = 'G' ORDER BY CstStrDte) WHERE ROWNUM <= 2";
+ command.Parameters.Add(new OracleParameter("m", comNum));
+ var rows = new List>();
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ var row = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (var i = 0; i < reader.FieldCount; i++)
+ {
+ row[reader.GetName(i)] = reader.IsDBNull(i) ? string.Empty : reader.GetValue(i).ToString()!.Trim();
+ }
+ rows.Add(row);
+ }
+ return rows;
}
///
diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
index b21e16a..91a09e9 100644
--- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
@@ -2331,6 +2331,12 @@ public static class DbSmoke
+ " GROUP BY PatEtcChtNum)");
lines.Add($" [조사] P_pdsoInf 패딩 행={padded},"
+ $" P_PatEtcInf 차트당 최대 행수={multiEtc}");
+ // 협진(O_CstInf)은 O_ 계열이라 이 시험 DB 에 없을 수 있다 —
+ // 빈 값이면 ORA-00942(운영 DB 전용 검증 대상)라는 뜻이다
+ var cstCount = FirstText(conn, "SELECT COUNT(*) FROM O_CstInf");
+ lines.Add(cstCount.Length == 0
+ ? " [조사] O_CstInf 없음 — 협진과 태그는 운영 DB 에서만 검증 가능"
+ : $" [조사] O_CstInf {cstCount}행 — 협진과 조회 검증 가능");
if (extraOwner.Length == 0)
{
lines.Add("SKIP ㉛ 부가정보 — P_PatEtcInf 가 비어 있어 확인 불가");
@@ -2389,6 +2395,25 @@ public static class DbSmoke
extra.FirstVisit(revisitOwner, "999912312359").Length >= 8,
"RPAD 매칭이나 P_CodInf 조인을 의심할 것");
}
+
+ // 협진도 같은 방식 — 회신 완료 협진이 있는 내원으로 물으면
+ // 과 이름이 반드시 나와야 한다(조인 회귀를 잡는다).
+ var consultOwner = FirstText(conn,
+ "SELECT CstComNum FROM (SELECT CstComNum FROM O_CstInf"
+ + " INNER JOIN M_DepMst ON CstRplDep = DepCod"
+ + " AND CstStrDte BETWEEN DepStrDte AND DepEndDte"
+ + " WHERE CstSttFlg = 'G') WHERE ROWNUM = 1");
+ if (consultOwner.Length == 0 || !decimal.TryParse(consultOwner, out var cstCom))
+ {
+ lines.Add("SKIP ㉞ 협진과 값 — 회신 완료 협진이 없어 판정 불가");
+ }
+ else
+ {
+ var consults = new PatientContextStore(conn).Consults(cstCom, false);
+ lines.Add($" 협진: 내원 {cstCom:F0} → {consults.Count}건 (값은 기록하지 않는다)");
+ Check("㉞ 회신 완료 협진이 있는 내원에서는 협진과가 나온다",
+ consults.Count > 0, "O_CstInf 조인이나 CstSttFlg 를 의심할 것");
+ }
}
}
catch (Exception ex)
diff --git a/src/SheetMe.Designer/Services/PatientTagResolver.cs b/src/SheetMe.Designer/Services/PatientTagResolver.cs
index 5472038..b7741da 100644
--- a/src/SheetMe.Designer/Services/PatientTagResolver.cs
+++ b/src/SheetMe.Designer/Services/PatientTagResolver.cs
@@ -948,9 +948,10 @@ public sealed class PatientTagResolver : ITagValueResolver
///
private TagValue? FromDeptAt(string tag)
{
- var admission = tag is "OCM_입원과" or "OCM_입원과_한글명칭";
- var discharge = tag is "OCM_퇴원과" or "OCM_퇴원과_한글명칭";
- if (!admission && !discharge)
+ var admission = tag is "OCM_입원과" or "OCM_입원과_한글명칭" or "OCM_입원의사";
+ var discharge = tag is "OCM_퇴원과" or "OCM_퇴원과_한글명칭" or "OCM_퇴원의사";
+ var consult = tag is "OCM_협진과" or "OCM_협진과_협진의";
+ if (!admission && !discharge && !consult)
{
return null;
}
@@ -989,6 +990,19 @@ public sealed class PatientTagResolver : ITagValueResolver
? office
: PatientContext.Value(row, "DepKorNam").Trim();
}
+ if (consult)
+ {
+ // 회신 완료된 협진의 과(2건)·과[의사](3건)를 ", " 로 잇는다(:6410-6480)
+ var rows = store.Consults(m, withDoctor: tag == "OCM_협진과_협진의");
+ if (rows.Count == 0)
+ {
+ return new TagValue(false, "회신 완료(CstSttFlg='G')된 협진이 없습니다");
+ }
+ var joined = string.Join(", ", rows.Select(r => tag == "OCM_협진과"
+ ? PatientContext.Value(r, "DepKorNam").Trim()
+ : $"{PatientContext.Value(r, "DepKorNam").Trim()}[{PatientContext.Value(r, "UidNam").Trim()}]"));
+ return new TagValue(true, joined);
+ }
if (admission)
{
var acp = PatientContext.Value(context.ComInf, "ComAcpDtm");
@@ -996,12 +1010,20 @@ public sealed class PatientTagResolver : ITagValueResolver
{
return new TagValue(false, "이 내원의 접수일시를 읽지 못했습니다");
}
- var depCod = store.DepartmentCodeAt(m, acp[..12]);
- if (depCod.Length == 0)
+ var codRow = store.CodInfAt(m, acp[..12]);
+ if (codRow.Count == 0)
{
return new TagValue(false, "접수 시점의 진료 행(P_CodInf)이 없습니다");
}
- var row = store.Department(depCod, acp[..8]);
+ if (tag == "OCM_입원의사")
+ {
+ // 접수 시점 담당의 코드 → 접수일자 기준 이름(:6499-6527)
+ var name = store.UserNameAt(PatientContext.Value(codRow, "CodDtrCod"), acp[..8]);
+ return name.Length > 0
+ ? new TagValue(true, name)
+ : new TagValue(false, "접수 시점 담당의가 사용자 마스터에 없습니다");
+ }
+ var row = store.Department(PatientContext.Value(codRow, "CodDepCod"), acp[..8]);
if (row.Count == 0)
{
return new TagValue(true, string.Empty); // 레거시도 마스터 0행이면 "" 다
@@ -1012,15 +1034,27 @@ public sealed class PatientTagResolver : ITagValueResolver
}
var prg = PatientContext.Value(context.ComInf, "ComPrgStt");
var lev = PatientContext.Value(context.ComInf, "ComLevDtm");
- var depAtLeave = store.DepartmentCodeAt(m,
+ if (tag == "OCM_퇴원의사" && lev.Trim().Length == 0)
+ {
+ return new TagValue(true, string.Empty); // 퇴원일시가 없으면 레거시가 바로 "" 다(:6592)
+ }
+ var leaveRow = store.CodInfAt(m,
prg == "ILV" && lev.Length >= 12 ? lev[..12] : null);
- if (depAtLeave.Length == 0)
+ if (leaveRow.Count == 0)
{
return new TagValue(true, string.Empty); // 레거시가 명시적으로 "" 를 돌려준다(:6189)
}
+ if (tag == "OCM_퇴원의사")
+ {
+ var name = store.UserNameAt(PatientContext.Value(leaveRow, "CodDtrCod"), lev[..8]);
+ return name.Length > 0
+ ? new TagValue(true, name)
+ : new TagValue(false, "퇴원 시점 담당의가 사용자 마스터에 없습니다");
+ }
var anchor = tag == "OCM_퇴원과_한글명칭" && lev.Trim().Length == 0
? "ComAcpDtm" : "ComLevDtm";
- var deptRow = store.DischargeDepartment(m, depAtLeave, anchor);
+ var deptRow = store.DischargeDepartment(m,
+ PatientContext.Value(leaveRow, "CodDepCod"), anchor);
if (deptRow.Count == 0)
{
return new TagValue(true, string.Empty);
@@ -1418,6 +1452,7 @@ public sealed class PatientTagResolver : ITagValueResolver
"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_입원일자_낮병동",