diff --git a/src/SheetMe.Data/Stores/SurgeryStore.cs b/src/SheetMe.Data/Stores/SurgeryStore.cs
new file mode 100644
index 0000000..cff38b6
--- /dev/null
+++ b/src/SheetMe.Data/Stores/SurgeryStore.cs
@@ -0,0 +1,192 @@
+using Oracle.ManagedDataAccess.Client;
+
+namespace SheetMe.Data.Stores;
+
+///
+/// 수술 정보 조회 — 레거시 GetOprInfDT(bzDataInterface.vb:13203-13363)의 이식.
+///
+/// 상병과 달리 병원 분기가 없다(Select Case 0회). 바인드는 내원번호 하나뿐이고
+/// 접수일자 조건은 원문에서 이미 주석 처리되어 있다(:sAcpDte — 죽은 코드).
+///
+/// SQL 은 원문에서 기계 추출했다(133줄 그대로). O_OprInf 를 축으로 수술명·부위·상병·
+/// 집도의·마취의를 LISTAGG 로 접는 큰 조회이고, 소비 태그 8종이 전부
+/// 첫 행에서 컬럼 하나를 집는다 — 그래서 행 목록만 돌려주고 선택은 해석기가 한다.
+///
+/// 원문의 Catch 는 ErrorMessageBox 를 띄운다 — 여기서는 던진다(해석기가 사유로 바꾼다).
+/// 조회 함수가 대화상자를 띄우는 부작용은 옮기지 않는다.
+///
+public sealed class SurgeryStore
+{
+ #region Member Fields
+ private readonly string connectionString;
+
+ /// 원문 그대로의 조회 — 바인드는 :iComNum 하나다
+ private const string Query = @"
+With
+ OprInfRec
+ AS
+ (
+ SELECT Opr.*
+ FROM (
+
+ SELECT /* 수술 정보 */
+ OprKey
+ , OprDte
+ , OprChtNum
+ , OprComNum, OprOrgCom, OprGbnCod, OprStt
+ , OprRomCod
+ , OprUseTms, OprStrTms, OprDayNum
+ , OprActStt
+ , OprNam
+ , OprDtrCod, OprDepCod, OprSplCmt
+ , CASE OprRegion WHEN '1' THEN '오른쪽'
+ WHEN '2' THEN '왼쪽'
+ WHEN '3' THEN '양쪽'
+ ELSE '없음'
+ END AS OprRegionName
+ , OprPatEtc
+ , OPR.OprUpdDtf
+
+ /* 인적 정보 */
+ , PatNam
+ , PatSexTyp
+ , ComPatTyp
+ , SUBSTR(ComAcpDtm, 1, 8) AS ComAcpDte
+
+ /* 관련 정보 */
+ , PRGSTSDTL.DtlCodNam AS OprActFlg
+ , UIDOPRDTR.UidNam AS OprDtrNam /* 집도의 명 */
+ , UIDCODDTR.UidNam AS CodDtrNam /* 주치의 명 */
+
+ , ROW_NUMBER() OVER (PARTITION BY OprKey ORDER BY CodStrDtm DESC ) AS RN
+
+ FROM S_OprInf OPR
+ LEFT JOIN P_PatInf
+ ON PatChtNum = OprChtNum
+
+ /* 수술정보의 내원정보 */
+ LEFT JOIN P_ComInf
+ ON OprComNum = ComNum
+ LEFT JOIN P_CodInf
+ ON CodComNum = ComNum
+ AND CASE WHEN ComPatTyp = 'I' THEN OprDte ||'2359'
+ ELSE CodStrDtm
+ END BETWEEN CodStrDtm AND CodEndDtm
+
+ LEFT JOIN M_DtlMst PRGSTSDTL
+ ON PRGSTSDTL.DtlTblCod = 'OPRPRGSTS'
+ AND PRGSTSDTL.DtlCod = OprActStt
+ /* 집도의 */
+ LEFT JOIN M_UidMst UIDOPRDTR
+ ON UIDOPRDTR.UidCod = OprDtrCod
+ AND OprDte BETWEEN UIDOPRDTR.UidStrDte AND UIDOPRDTR.UidEndDte
+ /* 주치의 */
+ LEFT JOIN M_UidMst UIDCODDTR
+ ON UIDCODDTR.UidCod = CodDtrCod
+ AND OprDte BETWEEN UIDCODDTR.UidStrDte AND UIDCODDTR.UidEndDte
+
+ WHERE 1 = 1
+ AND OprStt = 'E'
+-- AND OprDte = :sAcpDte
+ AND OprComNum = :iComNum
+-- AND OprDepCod IN ('0100','0101','0200','0201','0300','1601','1900','2100','2301','7001','0400','0500','0501','0550','0600','0601','0900','0901','1000','1100','1300','2601','0800','1400','1500','1600','5500')
+
+ ) OPR
+
+ WHERE RN = 1
+ )
+ ,
+ OprTypRec
+ AS
+ (
+ SELECT OprTypKey, DX, OPNAME
+ , SUBSTR(ANECOD, 1, LENGTH(ANECOD)-1) AS ANECOD
+ , SUBSTR(ANECODNAM, 1, LENGTH(ANECODNAM)-1) AS ANECODNAM
+ , SUBSTR(ANEUIDCOD, 1, LENGTH(ANEUIDCOD)-1) AS ANEUIDCOD
+ , SUBSTR(OKDNAM, 1, LENGTH(OKDNAM)) AS OKDNAM
+ , SUBSTR(OPRCODNAM, 1, LENGTH(OPRCODNAM)) AS OPRCODNAM
+ FROM (
+ SELECT OprKey AS OprTypKey
+ , MAX(DX.OprCodNam) AS DX
+ , MAX(OPNAME.OprCodNam) AS OPNAME
+ , regexp_replace(LISTAGG(ANECOD.OprCod || ',') WITHIN GROUP ( ORDER BY ANECOD.OprTypKey ), '([^,]+)(,\1)+', '\1') AS ANECOD
+ , regexp_replace(LISTAGG(ANECOD.OprCodNam || ',') WITHIN GROUP ( ORDER BY ANECOD.OprTypKey ), '([^,]+)(,\1)+', '\1') AS ANECODNAM
+ , regexp_replace(LISTAGG(ANEUID.OprCodNam || ',') WITHIN GROUP ( ORDER BY ANEUID.OprTypKey ), '([^,]+)(,\1)+', '\1') AS ANEUIDCOD
+ , MIN(OKDNAM.OprCodNam) AS OKDNAM
+ , MIN(OPRCODNAM.OprCodNam) AS OPRCODNAM
+
+ FROM OprInfRec
+ LEFT JOIN S_OprTyp DX
+ ON OprKey = DX.OprKeyNum
+ AND DX.OprTyp = 'DX'
+ LEFT JOIN S_OprTyp OPNAME
+ ON OprKey = OPNAME.OprKeyNum
+ AND OPNAME.OprTyp = 'OPNAME'
+ LEFT JOIN S_OprTyp ANECOD
+ ON OprKey = ANECOD.OprKeyNum
+ AND ANECOD.OprTyp = 'ANECOD'
+ LEFT JOIN S_OprTyp ANEUID
+ ON OprKey = ANEUID.OprKeyNum
+ AND ANEUID.OprTyp = 'ANEUIDCOD'
+ LEFT JOIN S_OprTyp OKDNAM
+ ON OprKey = OKDNAM.OprKeyNum
+ AND OKDNAM.OprTyp = 'OKD'
+ LEFT JOIN S_OprTyp OPRCODNAM
+ ON OprKey = OPRCODNAM.OprKeyNum
+ AND OPRCODNAM.OprTyp = 'OPRCOD'
+ AND ROWNUM = 1
+
+ WHERE 1 = 1
+ GROUP BY OprKey
+ )
+
+ )
+ ,
+ OprLstRec
+ AS
+ (
+ SELECT *
+ FROM OprInfRec
+ LEFT JOIN OprTypRec
+ ON OprKey = OprTypKey
+ )
+
+--SELECT * FROM OprInfRec
+--SELECT * FROM OprTypRec
+SELECT * FROM OprLstRec
+ORDER BY OprComNum, OprDte DESC, OprDayNum ASC
+";
+ #endregion
+
+ #region Constructors
+ public SurgeryStore(string connectionString) => this.connectionString = connectionString;
+ #endregion
+
+ #region Methods
+ /// 이 내원의 수술 행들 — 없으면 빈 목록
+ public List> Surgeries(decimal comNum)
+ {
+ using var connection = new OracleConnection(connectionString);
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.BindByName = true;
+ command.CommandText = Query;
+ command.Parameters.Add(new OracleParameter("iComNum", 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;
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
index b889bc3..a0f8919 100644
--- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
@@ -2237,6 +2237,32 @@ public static class DbSmoke
"M_KcdMst 조인이 빈 이름을 냈다");
}
}
+
+ // ㉘ 수술 — 원문 기계 추출 SQL 이 실제로 돈다.
+ var oprOwner = FirstComNum(conn, "O_OprInf", "OprComNum", "OprComNum");
+ if (oprOwner is null)
+ {
+ // "행이 없다"와 "표가 없다/조회가 죽었다"를 가른다 —
+ // FirstComNum 은 예외를 삼키므로 이 줄 없이는 못 가른다.
+ foreach (var line in ExplainPeriod(conn, 0,
+ "SELECT COUNT(*) OPRCNT FROM O_OprInf WHERE :k = 0"))
+ {
+ lines.Add($" [진단] O_OprInf: {line}");
+ }
+ lines.Add("SKIP ㉘ 수술 — 확인 불가. 위 [진단] 줄이 원인이다:"
+ + " ORA-00942 면 이 시험 DB 에 O_OprInf 가 아예 없는 것이고,"
+ + " 수술 태그는 운영 DB 에서만 검증할 수 있다");
+ }
+ else
+ {
+ var surgeries = new SurgeryStore(conn).Surgeries(oprOwner.Value);
+ var opName = surgeries.Count > 0
+ && surgeries[0].TryGetValue("OPNAME", out var on) ? on : "";
+ lines.Add($" 수술: 내원 {oprOwner.Value:F0} → {surgeries.Count}행,"
+ + $" OPNAME {opName.Length}자 (값은 기록하지 않는다)");
+ Check("㉘ 수술 쿼리가 행을 돌려준다", surgeries.Count > 0,
+ "OprStt='E' 조건이나 조인을 의심할 것");
+ }
}
}
}
diff --git a/src/SheetMe.Designer/Services/PatientIdentity.cs b/src/SheetMe.Designer/Services/PatientIdentity.cs
index 11f9c88..f89fe5a 100644
--- a/src/SheetMe.Designer/Services/PatientIdentity.cs
+++ b/src/SheetMe.Designer/Services/PatientIdentity.cs
@@ -109,6 +109,15 @@ public static class PatientIdentity
: new DiagnosisStore(connection).Diagnoses(context.ComNum, scope);
}
+ /// 수술 행 공급자 — 상병과 같은 정책(필요할 때 1회, 실패는 던져 사유로)
+ public static Func>> SurgerySourceFor(PatientContext context)
+ {
+ var connection = ConfigService.Current.ConnectionString;
+ return () => connection.Length == 0
+ ? new List>()
+ : new SurgeryStore(connection).Surgeries(context.ComNum);
+ }
+
///
/// 이 내원의 진료과 마스터 행 — 못 읽으면 빈 사전.
/// 진료과 코드는 문맥의 CodInf(적용일시로 고른 행)에서, 유효기간 일자는
diff --git a/src/SheetMe.Designer/Services/PatientSession.cs b/src/SheetMe.Designer/Services/PatientSession.cs
index 0291ae7..1de2040 100644
--- a/src/SheetMe.Designer/Services/PatientSession.cs
+++ b/src/SheetMe.Designer/Services/PatientSession.cs
@@ -68,7 +68,8 @@ public static class PatientSession
PatientIdentity.ResidentNumberOf(picked), PatientIdentity.AddressOf(picked),
PatientIdentity.DoctorOf(picked), PatientIdentity.DepartmentOf(picked),
PatientIdentity.HospitalNameOf(picked),
- PatientIdentity.DiagnosisSourceFor(picked))
+ PatientIdentity.DiagnosisSourceFor(picked),
+ PatientIdentity.SurgerySourceFor(picked))
: session;
return (tags, new MDataTableRunner(document, Variables()));
}
diff --git a/src/SheetMe.Designer/Services/PatientTagResolver.cs b/src/SheetMe.Designer/Services/PatientTagResolver.cs
index df91122..8638c8a 100644
--- a/src/SheetMe.Designer/Services/PatientTagResolver.cs
+++ b/src/SheetMe.Designer/Services/PatientTagResolver.cs
@@ -174,11 +174,17 @@ public sealed class PatientTagResolver : ITagValueResolver
private readonly Dictionary>> diagnosisCache = new();
+ /// 수술 행 공급자 — 상병과 같은 이유로 물을 때 한 번만 읽는다
+ private readonly Func>>? surgerySource;
+
+ private IReadOnlyList>? surgeryCache;
+
public PatientTagResolver(PatientContext context, ITagValueResolver next,
string residentNumber = "", IReadOnlyDictionary? address = null,
IReadOnlyDictionary? doctor = null,
IReadOnlyDictionary? department = null, string hospitalName = "",
- Func>>? diagnosisSource = null)
+ Func>>? diagnosisSource = null,
+ Func>>? surgerySource = null)
{
this.context = context;
this.next = next;
@@ -188,6 +194,7 @@ public sealed class PatientTagResolver : ITagValueResolver
this.department = department ?? new Dictionary(StringComparer.OrdinalIgnoreCase);
this.hospitalName = hospitalName;
this.diagnosisSource = diagnosisSource;
+ this.surgerySource = surgerySource;
}
#endregion
@@ -215,6 +222,10 @@ public sealed class PatientTagResolver : ITagValueResolver
{
return diagnosisValue;
}
+ if (FromSurgery(tag) is { } surgeryValue)
+ {
+ return surgeryValue;
+ }
if (Computed.TryGetValue(tag, out var compute))
{
var made = compute(context);
@@ -503,6 +514,54 @@ public sealed class PatientTagResolver : ITagValueResolver
: new TagValue(false, "상병 행은 있는데 그 값이 비어 있습니다");
}
+ ///
+ /// 수술 8종 — 해당 태그가 아니면 null. 전부 첫 행에서 컬럼 하나다
+ /// (bzDataInterface.vb:13099-13202 및 :3109 — 각 함수가 Rows(0).Item(...) 을 집는다).
+ /// GetOprInfDT 는 병원 분기가 없어 미이식 병원 안내가 필요 없다.
+ ///
+ private TagValue? FromSurgery(string tag)
+ {
+ var column = tag switch
+ {
+ "OCM_OPNAME" => "OPNAME",
+ "OCM_OPRCODNAM" => "OPRCODNAM",
+ "OCM_DX" => "DX",
+ "OCM_수술상병" => "OkdNam",
+ "OCM_수술부위" => "OprRegionName",
+ "OCM_수술_OprPatETC" => "OprPatEtc",
+ "OCM_수술집도의" => "OprDtrNam",
+ "OCM_수술마취의사" => "AneUidCod",
+ _ => null,
+ };
+ if (column is null)
+ {
+ return null;
+ }
+ if (surgerySource is null)
+ {
+ return new TagValue(false, "수술 조회가 연결되지 않았습니다");
+ }
+ if (surgeryCache is null)
+ {
+ try
+ {
+ surgeryCache = surgerySource();
+ }
+ catch (Exception ex)
+ {
+ return new TagValue(false, $"수술 조회에 실패했습니다: {ex.GetType().Name}");
+ }
+ }
+ if (surgeryCache.Count == 0)
+ {
+ return new TagValue(false, "이 내원에 등록된 수술이 없습니다");
+ }
+ var value = surgeryCache[0].TryGetValue(column, out var v) ? v.Trim() : string.Empty;
+ return value.Length > 0
+ ? new TagValue(true, value)
+ : new TagValue(false, $"수술 행은 있는데 {column} 값이 비어 있습니다");
+ }
+
/// 주민번호에서 나온 값 — 비면 왜 비었는지 말한다
private TagValue Wrap(string value) => value.Length > 0
? new TagValue(true, value)
@@ -523,6 +582,8 @@ public sealed class PatientTagResolver : ITagValueResolver
"OCM_상병명_가로", "OCM_상병명_세로", "OCM_상병명_영어_가로", "OCM_상병명_영어_세로",
"OCM_상병코드_가로", "OCM_상병코드_세로", "OCM_부상병코드", "OCM_부상병POA",
"OCM_주상병명", "OCM_주상병코드", "OCM_주상병POA",
+ "OCM_OPNAME", "OCM_OPRCODNAM", "OCM_DX", "OCM_수술상병", "OCM_수술부위",
+ "OCM_수술_OprPatETC", "OCM_수술집도의", "OCM_수술마취의사",
};
///
diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
index 6f28570..663bd1f 100644
--- a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
+++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
@@ -225,7 +225,8 @@ public partial class PreviewWindow : Window
PatientIdentity.ResidentNumberOf(picked), PatientIdentity.AddressOf(picked),
PatientIdentity.DoctorOf(picked), PatientIdentity.DepartmentOf(picked),
PatientIdentity.HospitalNameOf(picked),
- PatientIdentity.DiagnosisSourceFor(picked));
+ PatientIdentity.DiagnosisSourceFor(picked),
+ PatientIdentity.SurgerySourceFor(picked));
// 러너를 새로 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
fields = new MDataTableRunner(designer.Document,