부가정보 2차분 20종 — 165종 → 185종 (워크플로 조사 라운드 4·5)

병렬 조사(읽기 11 + 반박 검증 11)로 39종을 분석하고 그중 20종을 이식:

값 18종 — OCM_발병일(:8125, 발병시작일 최신 고정), OCM_당일식이
(:12620, PIVOT 원문 유지·"아침 : X " 결합), OCM_알러지+조영제/약물/
음식/기타(:14941-15140, LISTAGG CRLF — 카테고리는 한글 리터럴),
OCM_알러지_List(:15142 — " - " 구분자가 한 번 켜지면 안 꺼지는
스티키 결함 보존), OCM_외출예상시작/종료일시(:15197/:15231 — 같은
행의 양끝, 원문부터 결정적), ETC_건강보험증번호(:15269 — PisInsCod=1
숫자 리터럴 유지), ETC_재해발생일(:15323), ETC_산재지정번호(:15727 —
진료과→병원 HspWrkNum, 문맥 시점), OCM_감염분류_감염정보(:14084),
OCM_퇴원분석_수술처치명(:13920 — 후행 개행까지 원문), OCM_병실인실
(:7065, 최신 입실 고정), OCM_병실차액(:7103 — 원문부터 첫 입실
결정적), OCM_VITAL접수일시_LAST(:12996 — 시각만 정렬하는 원문 유지),
OCM_진료과_전화번호(:2773 — 진료과 행 재사용, 왕복 0), OCM_전과일시/
전과일자(:12985/:12990 — 문맥 진료 행).

사유 완결 2종 — OCM_감염정보(:14122 원문 SQL 의 바인드 콜론 누락
→ 상시 ORA-00904 → 빈 값. 고치면 없던 값이 생기므로 사유로 안내),
OCM_Cosign_의사명(:14812 — 저장 기록 키(EmrKey) 의존, 미리보기 부재).

검증이 잡은 레거시 결함 3건 기록: 감염정보 콜론 누락, 알러지_List
스티키 구분자, 진료기간_진료과 SQL B 의 sDepCod 미바인드(다음 배치).

- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 185종)
- --db-patient ①~㉟ 전건 통과 — ㉟ 신설: 새 조회 12종 실행 확인
  (O_OkdInf·O_ImlInf·O_OutAdmInf·S_AlgInf·S_IcnInf·S_DssOpn 전부 존재)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-19 19:09:38 +09:00
co-authored by Claude Fable 5
parent a33229b5e1
commit 7bae9b9057
3 changed files with 459 additions and 7 deletions
@@ -218,6 +218,173 @@ public sealed class PatientExtraStore
+ " WHERE PrgComNum = :m ORDER BY PrgUpdDtf DESC) WHERE ROWNUM = 1", + " WHERE PrgComNum = :m ORDER BY PrgUpdDtf DESC) WHERE ROWNUM = 1",
("m", comNum)); ("m", comNum));
/// <summary>
/// 발병일(OCM_발병일, :8125) — 대표 주상병(OkdSeq=1, OkdStt='E')과 오늘 유효한
/// 발병 기록(O_OnsInf)의 시작일. 원문 Rows(0) 에 ORDER BY 가 없다 —
/// 발병 시작일 최신 행으로 고정한다.
/// </summary>
public string OnsetDate(string chtNum, decimal comNum)
=> Scalar(
"SELECT v FROM (SELECT OnsStrDte v FROM O_OkdInf"
+ " INNER JOIN O_OnsInf ON OkdKcdCod = OnsKcdCod"
+ " AND OnsChtNum = RPAD(:c, 10)"
+ " AND (OnsDelDtf IS NULL OR OnsDelDtf = ' ')"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN OnsStrDte AND OnsEndDte"
+ " WHERE OkdComNum = :m AND OkdSeq = 1 AND OkdStt = 'E'"
+ " ORDER BY OnsStrDte DESC) WHERE ROWNUM = 1",
("c", chtNum), ("m", comNum));
/// <summary>
/// 당일 식이(OCM_당일식이, :12620) — 오늘(SYSDATE) 입원 식이의 아침/점심/저녁 PIVOT 행.
/// PIVOT 이 내원 단위로 접으므로 결과는 최대 1행 — 원문 그대로 결정적이다.
/// </summary>
public IReadOnlyDictionary<string, string> TodayMeals(decimal comNum)
=> Row(
"WITH ComIml AS (SELECT A01.ImlMgdTyp MgdTyp, B01.MgdKorNam MgdNam, A01.ImlComNum"
+ " FROM O_ImlInf A01 INNER JOIN M_MgdMst B01 ON MgdCod = ImlMgdCod"
+ " WHERE ImlDte = TO_CHAR(SYSDATE, 'YYYYMMDD') AND ImlPatTyp = 'I' AND ImlComNum = :m)"
+ " SELECT * FROM ComIml"
+ " PIVOT (MAX(MgdNam) AS MgdTyp FOR MgdTyp IN ('B' 아침, 'L' 점심, 'D' 저녁))",
("m", comNum));
/// <summary>
/// 알러지(OCM_알러지·_조영제·_약물·_음식·_기타, :14941-15140) — 오늘 유효한 코드마스터
/// (ALLERGY)에 매칭되는 행을 SQL LISTAGG 로 CRLF 결합. category 가 null 이면 전체.
/// 집계 쿼리라 항상 1행 — 매칭 0건이면 NULL → 빈 문자열이다(원문과 같은 귀결).
/// </summary>
public string Allergy(string chtNum, string? category)
{
var filter = category is null ? string.Empty : " AND AlgAlgCtg = :g";
var binds = category is null
? new (string, object)[] { ("c", chtNum) }
: new (string, object)[] { ("c", chtNum), ("g", category) };
return Scalar(
"SELECT LISTAGG(AlgAlgCtg || '-' || CASE WHEN NVL(AlgAlgSpm, ' ') <> ' '"
+ " THEN AlgAlgTyp || ' (증상 : ' || AlgAlgSpm || ')' ELSE AlgAlgTyp END,"
+ " CHR(13) || CHR(10)) WITHIN GROUP (ORDER BY DtsDtlCod, DtsDspSeq)"
+ " FROM S_AlgInf INNER JOIN M_DtsMst ON DtsTblCod = 'ALLERGY'"
+ " AND AlgAlgTyp = DtsCodNam"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtsStrDte AND DtsExpDte"
+ " WHERE AlgChtNum IN (:c, RPAD(:c, 10))"
+ " AND (AlgDelFlg IS NULL OR AlgDelFlg <> 'D')" + filter,
binds);
}
/// <summary>
/// 알러지 원장 행들(OCM_알러지_List, :15142) — 코드마스터 조인 없이 전부,
/// 갱신일시 오름차순(원문 ORDER BY 그대로). 결합은 해석기가 원문 결함까지 흉내낸다.
/// </summary>
public IReadOnlyList<IReadOnlyDictionary<string, string>> AllergyRows(string chtNum)
=> Rows(
"SELECT AlgAlgCtg, AlgAlgTyp, AlgAlgSpm FROM S_AlgInf"
+ " WHERE AlgChtNum IN (:c, RPAD(:c, 10))"
+ " AND (AlgDelFlg IS NULL OR AlgDelFlg <> 'D') ORDER BY AlgUpdDtf ASC",
("c", chtNum));
/// <summary>
/// 외출 예상 일시(OCM_외출예상시작/종료일시, :15197/:15231) — 오늘 0시 이후 시작하는
/// 외출·외박 신청 중 가장 이른 것. 원문부터 인라인뷰 정렬 + ROWNUM 이라 결정적이다.
/// 두 태그가 같은 행의 양 끝을 읽는다(정렬 키는 시작 쪽).
/// </summary>
public string ExpectedOuting(string chtNum, bool endSide)
=> Scalar(
"SELECT " + (endSide ? "NVL(OutAdmEndDtm, OutAdmExpEndDtm)" : "NVL(OutAdmStrDtm, OutAdmExpStrDtm)")
+ " FROM (SELECT * FROM O_OutAdmInf"
+ " WHERE OutAdmChtNum IN (:c, RPAD(:c, 10))"
+ " AND OutAdmStrDtm >= TO_CHAR(SYSDATE, 'YYYYMMDD') || '0000'"
+ " ORDER BY NVL(OutAdmStrDtm, OutAdmExpStrDtm)) WHERE ROWNUM = 1",
("c", chtNum));
/// <summary>
/// 건강보험증번호(ETC_건강보험증번호, :15269) — 건보(PisInsCod=1) 자격의 최신 순번 행.
/// 원문의 숫자 리터럴 비교(PisInsCod = 1)와 TO_NUMBER 정렬을 그대로 옮겼다.
/// </summary>
public string InsuranceCertNumber(string chtNum)
=> Scalar(
"SELECT v FROM (SELECT Pis1AsylmSym v FROM P_PisInf"
+ " WHERE PisInsCod = 1 AND PisChtNum = RPAD(:c, 10)"
+ " ORDER BY TO_NUMBER(PisInsSeq) DESC) WHERE ROWNUM = 1",
("c", chtNum));
/// <summary>재해발생일(ETC_재해발생일, :15323) — 산재(PisInsCod=4) 자격의 최신 순번 행. YYYYMMDD 원문 그대로</summary>
public string AccidentDate(string chtNum)
=> Scalar(
"SELECT v FROM (SELECT Pis4AcdDte v FROM P_PisInf"
+ " WHERE PisInsCod = 4 AND PisChtNum = RPAD(:c, 10)"
+ " ORDER BY TO_NUMBER(PisInsSeq) DESC) WHERE ROWNUM = 1",
("c", chtNum));
/// <summary>
/// 산재지정번호(ETC_산재지정번호, :15727) — 진료과로 병원을 찾아 HspWrkNum.
/// 요양기관 행과 같은 조인이라 다중이면 한 행으로 고정한다.
/// </summary>
public string IndustrialAccidentNumber(string depCod, string day)
=> depCod.Trim().Length == 0 || day.Trim().Length < 8 ? string.Empty
: Scalar(
"SELECT v FROM (SELECT HspWrkNum v FROM M_DepMst"
+ " INNER JOIN M_HspMst ON DepHspCod = HspCod AND :y BETWEEN HspStrDte AND HspEndDte"
+ " WHERE TRIM(DepCod) = :d AND :y BETWEEN DepStrDte AND DepEndDte) WHERE ROWNUM = 1",
("d", depCod.Trim()), ("y", day.Trim()[..8]));
/// <summary>
/// 감염 분류·정보(OCM_감염분류_감염정보, :14084) — 오늘 유효한 감염 기록을
/// "분류 앞 2자-상세명" 으로 LISTAGG CRLF 결합. 집계라 항상 1행이다.
/// (OCM_감염정보 :14122 는 원문 SQL 의 바인드 콜론이 빠져 항상 오류→빈 값 —
/// 해석기가 사유로 말하고 여기서는 구현하지 않는다.)
/// </summary>
public string InfectionInfo(string chtNum)
=> Scalar(
"SELECT LISTAGG(SUBSTR(DtlCodNam, 1, 2) || '-' || DtsCodNam, CHR(13) || CHR(10))"
+ " WITHIN GROUP (ORDER BY DtsDspSeq)"
+ " FROM S_IcnInf"
+ " INNER JOIN M_DtlMst ON DtlCod = IcnIcnCod AND DtlTblCod = 'INFECTION'"
+ " INNER JOIN M_DtsMst ON DtsCod = IcnDtlCod"
+ " WHERE IcnChtNum IN (:c, RPAD(:c, 10))"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN IcnStrDte AND IcnEndDte",
("c", chtNum));
/// <summary>
/// 퇴원분석 수술·처치명 목록(OCM_퇴원분석_수술처치명, :13920) — "코드 - 이름" 을
/// 순번 순으로. 결합은 해석기가 한다(원문은 AppendLine 이라 마지막 뒤에도 개행이 남는다).
/// </summary>
public IReadOnlyList<IReadOnlyDictionary<string, string>> DischargeAnalysisOperations(decimal comNum)
=> Rows(
"SELECT DssOpnCod || ' - ' || DssOpnNam AS OpnNam FROM S_DssOpn"
+ " WHERE DssComNum = :m"
+ " AND (DssOpnCod IS NOT NULL AND DssOpnCod <> ' ')"
+ " AND (DssOpnNam IS NOT NULL AND DssOpnNam <> ' ')"
+ " ORDER BY DssOpnSeq",
("m", comNum));
/// <summary>
/// 병실 인실(OCM_병실인실, :7065) — 입실 이력과 병실 마스터의 배정 침상 수.
/// 원문 Rows(0) 에 ORDER BY 가 없다 — 전실했으면 임의 병실이었다. 최신 입실로 고정한다.
/// </summary>
public string RoomBedCount(decimal comNum)
=> Scalar(
"SELECT v FROM (SELECT RomAsgBed v FROM P_CowInf"
+ " INNER JOIN M_RomMst ON RomCod = CowRomCod"
+ " AND CowStrDtm BETWEEN RomStrDte AND RomEndDte || '2359'"
+ " WHERE CowComNum = :m ORDER BY CowStrDtm DESC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 병실 차액 구분(OCM_병실차액, :7103) — 첫 입실 병실 등급의 건보 산정 구분(SgaHthIns).
/// 원문이 CowStrDtm 오름차순 첫 행이라 결정적이다 — 그대로 옮겼다.
/// </summary>
public string RoomExtraCharge(decimal comNum)
=> Scalar(
"SELECT v FROM (SELECT SgaHthIns v FROM P_ComInf"
+ " INNER JOIN P_CowInf ON CowComNum = ComNum"
+ " INNER JOIN M_RomMst ON CowRomCod = RomCod"
+ " AND CowStrDtm BETWEEN RomStrDte AND RomEndDte || '2359'"
+ " INNER JOIN M_RgdMst ON CowGrdCod = RgdCod"
+ " AND CowStrDtm BETWEEN RgdStrDte AND RgdEndDte || '2359'"
+ " INNER JOIN M_SgaMst ON RgdAmtCod = SgaCod"
+ " AND CowStrDtm BETWEEN SgaStrDte AND SgaEndDte || '2359'"
+ " WHERE ComNum = :m ORDER BY CowStrDtm ASC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>스칼라 1칸 — 0행이거나 NULL 이면 빈 문자열. 잔여 공백은 걷는다(문맥 행과 같은 규칙)</summary> /// <summary>스칼라 1칸 — 0행이거나 NULL 이면 빈 문자열. 잔여 공백은 걷는다(문맥 행과 같은 규칙)</summary>
private string Scalar(string sql, params (string Name, object Value)[] binds) private string Scalar(string sql, params (string Name, object Value)[] binds)
{ {
@@ -233,5 +400,39 @@ public sealed class PatientExtraStore
var result = command.ExecuteScalar(); var result = command.ExecuteScalar();
return result is null or DBNull ? string.Empty : result.ToString()!.Trim(); return result is null or DBNull ? string.Empty : result.ToString()!.Trim();
} }
/// <summary>행 하나를 컬럼명(대문자) → 값 사전으로 — 없으면 빈 사전</summary>
private IReadOnlyDictionary<string, string> Row(string sql, params (string Name, object Value)[] binds)
{
var rows = Rows(sql, binds);
return rows.Count > 0 ? rows[0] : new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
/// <summary>여러 행 — 각 행은 컬럼명 → Trim 값 사전</summary>
private IReadOnlyList<IReadOnlyDictionary<string, string>> Rows(
string sql, params (string Name, object Value)[] binds)
{
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = sql;
foreach (var (name, value) in binds)
{
command.Parameters.Add(new OracleParameter(name, value));
}
var rows = new List<IReadOnlyDictionary<string, string>>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var row = new Dictionary<string, string>(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 #endregion
} }
@@ -2417,6 +2417,62 @@ public static class DbSmoke
Check("㉞ 회신 완료 협진이 있는 내원에서는 협진과가 나온다", Check("㉞ 회신 완료 협진이 있는 내원에서는 협진과가 나온다",
consults.Count > 0, "O_CstInf 조인이나 CstSttFlg 를 의심할 것"); consults.Count > 0, "O_CstInf 조인이나 CstSttFlg 를 의심할 것");
} }
// ㉟ 부가정보 2차분 — 새 자기 SQL 12종이 예외 없이 돈다.
// O_ 계열 표(O_OkdInf·O_ImlInf·O_OutAdmInf)가 이 시험 DB 에
// 없으면 개별 [진단] 줄로 남기고 판정은 실행 가능한 것만 본다.
try
{
var probes = new (string Probe, Func<string> Run)[]
{
("알러지", () => extra.Allergy(extraOwner, null).Length.ToString()),
("알러지목록", () => extra.AllergyRows(extraOwner).Count.ToString()),
("보험증번호", () => extra.InsuranceCertNumber(extraOwner).Length.ToString()),
("재해발생일", () => extra.AccidentDate(extraOwner).Length.ToString()),
("감염정보", () => extra.InfectionInfo(extraOwner).Length.ToString()),
("발병일", () => owner2 is null ? "-" : extra.OnsetDate(extraOwner, owner2.Value).Length.ToString()),
("당일식이", () => owner2 is null ? "-" : extra.TodayMeals(owner2.Value).Count.ToString()),
("외출예상", () => extra.ExpectedOuting(extraOwner, false).Length.ToString()),
("퇴원분석", () => owner2 is null ? "-" : extra.DischargeAnalysisOperations(owner2.Value).Count.ToString()),
("병실인실", () => owner2 is null ? "-" : extra.RoomBedCount(owner2.Value).Length.ToString()),
("병실차액", () => owner2 is null ? "-" : extra.RoomExtraCharge(owner2.Value).Length.ToString()),
("산재지정번호", () => extra.IndustrialAccidentNumber("0506", "20260819").Length.ToString()),
};
var shapes2 = new List<string>();
var failed = new List<string>();
foreach (var (probe, run) in probes)
{
try
{
shapes2.Add($"{probe} {run()}");
}
catch (Exception ex)
{
var first = ex.Message.Split('\n')[0];
// 표 자체가 없는 것(ORA-00942)은 시험 DB 한계다
if (first.Contains("ORA-00942"))
{
shapes2.Add($"{probe} [표없음]");
}
else
{
failed.Add($"{probe}: {first}");
}
}
}
lines.Add($" 부가정보2: {string.Join(" · ", shapes2)}");
foreach (var f in failed)
{
lines.Add($" [진단] {f}");
}
Check("㉟ 부가정보 2차분 조회가 예외 없이 돈다", failed.Count == 0,
"위 [진단] 줄의 컬럼·조인 이름을 의심할 것");
}
catch (Exception ex)
{
Check("㉟ 부가정보 2차분 조회가 예외 없이 돈다", false,
$"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}");
}
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -114,6 +114,11 @@ public sealed class PatientTagResolver : ITagValueResolver
["OCM_입원내원일시"] = c => Stamp(Com(c, "ComAcpDtm")), ["OCM_입원내원일시"] = c => Stamp(Com(c, "ComAcpDtm")),
["OCM_입원내원일자"] = c => Hyphenate(Head(Com(c, "ComAcpDtm"), 8)), ["OCM_입원내원일자"] = c => Hyphenate(Head(Com(c, "ComAcpDtm"), 8)),
// 전과 일시·일자 — 문맥 진료 행의 시작일시(bzDataInterface.vb:12985/12990).
// 레거시는 행 유무 검사 없이 Substring 해 없으면 예외→노란색이다 — 여기는 빈 값.
["OCM_전과일시"] = c => Stamp(PatientContext.Value(c.CodInf, "CodStrDtm")),
["OCM_전과일자"] = c => Hyphenate(Head(PatientContext.Value(c.CodInf, "CodStrDtm"), 8)),
// 내원유형 — 입원/응급실/외래(bzDataInterface.vb:2896-2918). // 내원유형 — 입원/응급실/외래(bzDataInterface.vb:2896-2918).
// 응급 여부는 내원행의 ComEmgYon 이다. 그 밖의 환자구분은 레거시도 "" 다. // 응급 여부는 내원행의 ComEmgYon 이다. 그 밖의 환자구분은 레거시도 "" 다.
["OCM_내원유형"] = c => Com(c, "ComPatTyp") switch ["OCM_내원유형"] = c => Com(c, "ComPatTyp") switch
@@ -585,6 +590,9 @@ public sealed class PatientTagResolver : ITagValueResolver
return DeptColumn("DepOfcNam", "진료과 마스터에 대외 명칭이 비어 있습니다"); return DeptColumn("DepOfcNam", "진료과 마스터에 대외 명칭이 비어 있습니다");
case "OCM_진료과약어명칭": case "OCM_진료과약어명칭":
return DeptColumn("DepBrfNam", "진료과 마스터에 약어 명칭이 비어 있습니다"); return DeptColumn("DepBrfNam", "진료과 마스터에 약어 명칭이 비어 있습니다");
case "OCM_진료과_전화번호":
// 같은 행의 전화번호 컬럼(:2773-2822) — 시점 판정도 파생 4종과 동일
return DeptColumn("DepTelNum", "진료과 마스터에 전화번호가 비어 있습니다");
// ── 요양기관 12종 — 같은 조인의 다른 컬럼(bzDataInterface.vb:15656-16709) ── // ── 요양기관 12종 — 같은 조인의 다른 컬럼(bzDataInterface.vb:15656-16709) ──
// 접미·결합도 본문 그대로: 병원장 = 병원명+"장", 귀하 = +"장 귀하", // 접미·결합도 본문 그대로: 병원장 = 병원명+"장", 귀하 = +"장 귀하",
// 전화_팩스 = "Tel : … FAX : …", 영문판은 "+82 - " 접두가 붙는다. // 전화_팩스 = "Tel : … FAX : …", 영문판은 "+82 - " 접두가 붙는다.
@@ -965,7 +973,8 @@ public sealed class PatientTagResolver : ITagValueResolver
private TagValue? FromBodyDerived(string tag) private TagValue? FromBodyDerived(string tag)
{ {
if (tag is not ("OCM_BST" or "OCM_BST_LAST" or "OCM_BMI" if (tag is not ("OCM_BST" or "OCM_BST_LAST" or "OCM_BMI"
or "OCM_표준체중_LAST" or "OCM_조정체중_LAST" or "OCM_비만도")) or "OCM_표준체중_LAST" or "OCM_조정체중_LAST" or "OCM_비만도"
or "OCM_VITAL접수일시_LAST"))
{ {
return null; return null;
} }
@@ -996,6 +1005,22 @@ public sealed class PatientTagResolver : ITagValueResolver
? new TagValue(true, bst) ? new TagValue(true, bst)
: new TagValue(false, "이 내원에 BST 기록(E_EmdInf_BST)이 없습니다"); : new TagValue(false, "이 내원에 BST 기록(E_EmdInf_BST)이 없습니다");
} }
case "OCM_VITAL접수일시_LAST":
{
// 가장 늦은 접수 '시각'(EmrAdpTim, 시분 4자리)을 HH:MM 으로(:12996-13028).
// 정렬 키가 시각뿐이라 날짜와 무관하게 하루 중 가장 늦은 시각이 나온다 —
// 원문이 그렇다(SQL 의 SUBSTR 은 1-기반).
if (vitalSource is null)
{
return new TagValue(false, "바이탈 조회가 연결되지 않았습니다");
}
var time = vitalSource(
"SUBSTR(EmrAdpTim, 1, 2) || ':' || SUBSTR(EmrAdpTim, 3, 2) AS ADPTIM",
"ADPTIM", new[] { "EmrAdpTim" }, "EmrAdpTim DESC");
return time.Length > 0
? new TagValue(true, time)
: new TagValue(false, "이 내원에 접수 시각이 있는 바이탈 기록이 없습니다");
}
case "OCM_비만도": case "OCM_비만도":
{ {
// 내원 기준(EmrComNum) — 키·체중을 따로 최신으로 집는다(:12333-12420) // 내원 기준(EmrComNum) — 키·체중을 따로 최신으로 집는다(:12333-12420)
@@ -1339,18 +1364,32 @@ public sealed class PatientTagResolver : ITagValueResolver
return new TagValue(true, ((end - start).Days + 1).ToString()); return new TagValue(true, ((end - start).Days + 1).ToString());
} }
/// <summary><see cref="FromExtra"/> 가 담당하는 태그들 — 전부 자기 SQL 이거나 그 조합</summary>
private static readonly HashSet<string> ExtraTags = new(StringComparer.Ordinal)
{
"PAT_실제생년월일", "PAT_국적", "PAT_장애등급", "PAT_건보세대주명",
"PAT_건보_급여_세대주명", "PAT_협력업체", "PAT_보호자연락처",
"OCM_FollowUp", "OCM_외출외박신청일", "OCM_외출외박종료일",
"OCM_최초내원일", "OCM_초진일_발병일", "OCM_신환_초진일",
"OCM_입원일자_낮병동", "OCM_혈액형", "OCM_LMP",
"OCM_발병일", "OCM_당일식이",
"OCM_알러지", "OCM_알러지_조영제", "OCM_알러지_약물", "OCM_알러지_음식", "OCM_알러지_기타",
"OCM_알러지_List",
"OCM_외출예상시작일시", "OCM_외출예상종료일시",
"ETC_건강보험증번호", "ETC_재해발생일", "ETC_산재지정번호",
"OCM_감염분류_감염정보", "OCM_감염정보",
"OCM_퇴원분석_수술처치명", "OCM_Cosign_의사명",
"OCM_병실인실", "OCM_병실차액",
};
/// <summary> /// <summary>
/// 환자 부가정보 7종 — 태그별 자기 SQL(<see cref="PatientExtraStore"/>). /// 환자 부가정보 — 태그별 자기 SQL(<see cref="PatientExtraStore"/>).
/// 전부 지연 + 태그별 캐시다: 이 태그가 없는 서식이 대부분이라 미리 당기지 않고, /// 전부 지연 + 태그별 캐시다: 이 태그가 없는 서식이 대부분이라 미리 당기지 않고,
/// 같은 태그가 한 장에 여러 번 있어도 왕복은 한 번이다. /// 같은 태그가 한 장에 여러 번 있어도 왕복은 한 번이다.
/// </summary> /// </summary>
private TagValue? FromExtra(string tag) private TagValue? FromExtra(string tag)
{ {
if (tag is not ("PAT_실제생년월일" or "PAT_국적" or "PAT_장애등급" or "PAT_건보세대주명" if (!ExtraTags.Contains(tag))
or "PAT_건보_급여_세대주명" or "PAT_협력업체" or "PAT_보호자연락처"
or "OCM_FollowUp" or "OCM_외출외박신청일" or "OCM_외출외박종료일"
or "OCM_최초내원일" or "OCM_초진일_발병일" or "OCM_신환_초진일"
or "OCM_입원일자_낮병동" or "OCM_혈액형" or "OCM_LMP"))
{ {
return null; return null;
} }
@@ -1449,6 +1488,156 @@ public sealed class PatientTagResolver : ITagValueResolver
} }
case "OCM_혈액형": case "OCM_혈액형":
return Wrap(store.BloodType(chtNum), "혈액형 판정(S_BlpInf) 행이 없습니다"); return Wrap(store.BloodType(chtNum), "혈액형 판정(S_BlpInf) 행이 없습니다");
case "OCM_발병일":
{
var comNum = PatientContext.Value(context.ComInf, "ComNum");
if (!decimal.TryParse(comNum, out var m))
{
return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
}
var onset = store.OnsetDate(chtNum, m);
return onset.Length >= 8
? new TagValue(true, Hyphenate(onset[..8]))
: new TagValue(false, "주상병과 짝이 되는 발병 기록(O_OnsInf)이 없습니다");
}
case "OCM_당일식이":
{
var comNum = PatientContext.Value(context.ComInf, "ComNum");
if (!decimal.TryParse(comNum, out var m))
{
return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
}
// 끼니별로 "아침 : X " 를 이어붙인다 — 뒤 공백 1칸까지 원문 그대로(:12646-12656)
var meals = store.TodayMeals(m);
if (meals.Count == 0)
{
return new TagValue(false, "오늘 등록된 입원 식이(O_ImlInf)가 없습니다");
}
var text = string.Empty;
foreach (var (label, column) in new[]
{ ("아침", "아침_MGDTYP"), ("점심", "점심_MGDTYP"), ("저녁", "저녁_MGDTYP") })
{
var meal = PatientContext.Value(meals, column);
if (meal.Length > 0)
{
text += $"{label} : {meal} ";
}
}
return text.Length > 0
? new TagValue(true, text)
: new TagValue(false, "오늘 식이 행은 있는데 세 끼니가 전부 비어 있습니다");
}
case "OCM_알러지":
case "OCM_알러지_조영제":
case "OCM_알러지_약물":
case "OCM_알러지_음식":
case "OCM_알러지_기타":
{
// 카테고리 리터럴은 레거시가 한글로 하드코딩했다(:15018 등)
var category = tag switch
{
"OCM_알러지_조영제" => "조영제",
"OCM_알러지_약물" => "약물",
"OCM_알러지_음식" => "음식",
"OCM_알러지_기타" => "기타",
_ => (string?)null,
};
return Wrap(store.Allergy(chtNum, category),
"코드마스터(ALLERGY)에 매칭되는 알러지 기록이 없습니다");
}
case "OCM_알러지_List":
{
var rows = store.AllergyRows(chtNum);
if (rows.Count == 0)
{
return new TagValue(false, "알러지 기록(S_AlgInf)이 없습니다");
}
// 원문 결합(:15160-15178): "(분류) 종류[ - 증상]" 을 CRLF 로.
// 구분자 " - " 는 한 번 켜지면 꺼지지 않는다(레거시 스티키 결함) —
// 뒤 행의 증상이 비어도 " - " 가 붙는다. 그대로 옮겼다.
var separator = string.Empty;
var list = new System.Text.StringBuilder();
foreach (var row in rows)
{
var kind = PatientContext.Value(row, "AlgAlgTyp");
var symptom = PatientContext.Value(row, "AlgAlgSpm");
if (kind.Length > 0 && symptom.Length > 0)
{
separator = " - ";
}
if (list.Length > 0)
{
list.Append("\r\n");
}
list.Append($"({PatientContext.Value(row, "AlgAlgCtg")}) {kind}{separator}{symptom}");
}
return new TagValue(true, list.ToString());
}
case "OCM_외출예상시작일시":
case "OCM_외출예상종료일시":
return Wrap(store.ExpectedOuting(chtNum, tag == "OCM_외출예상종료일시"),
"오늘 이후 시작하는 외출·외박 신청(O_OutAdmInf)이 없습니다");
case "ETC_건강보험증번호":
return Wrap(store.InsuranceCertNumber(chtNum), "건보 자격(P_PisInf) 행이 없습니다");
case "ETC_재해발생일":
return Wrap(store.AccidentDate(chtNum), "산재 자격(P_PisInf, PisInsCod=4) 행이 없습니다");
case "ETC_산재지정번호":
{
// 진료과·시점은 문맥의 것 — 레거시 시점 분기(퇴원/현재/접수)와 같은 판정이다
var depCod = PatientContext.Value(context.CodInf, "CodDepCod");
if (depCod.Length == 0)
{
return new TagValue(false, "이 내원의 진료과 코드를 읽지 못했습니다");
}
return Wrap(store.IndustrialAccidentNumber(depCod, context.AdpDtm),
"요양기관 행에서 산재지정번호(HspWrkNum)를 찾지 못했습니다");
}
case "OCM_감염분류_감염정보":
return Wrap(store.InfectionInfo(chtNum), "오늘 유효한 감염 기록(S_IcnInf)이 없습니다");
case "OCM_감염정보":
// 원문 SQL(:14146)의 차트번호 바인드에 콜론이 빠져 있어(IcnChtNum = sChtNum)
// 실행마다 ORA-00904 → 오류창 → 빈 값이다 — 고치면 없던 값이 갑자기 생긴다
return new TagValue(false,
"레거시 결함(바인드 콜론 누락)으로 항상 빈 값인 태그입니다 — OCM_감염분류_감염정보를 쓰세요");
case "OCM_퇴원분석_수술처치명":
{
var comNum = PatientContext.Value(context.ComInf, "ComNum");
if (!decimal.TryParse(comNum, out var m))
{
return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
}
var operations = store.DischargeAnalysisOperations(m);
if (operations.Count == 0)
{
return new TagValue(false, "퇴원분석 수술·처치(S_DssOpn) 행이 없습니다");
}
// 원문은 AppendLine 결합이라 마지막 줄 뒤에도 개행이 남는다(:13950) — 그대로
var text = new System.Text.StringBuilder();
foreach (var row in operations)
{
text.Append(PatientContext.Value(row, "OpnNam")).Append("\r\n");
}
return new TagValue(true, text.ToString());
}
case "OCM_Cosign_의사명":
// 레거시는 저장된 기록 키(moShtInfoBiz.EmrKey)로 확인자(E_RcdInf)를 찾는다(:14834).
// 미리보기는 저장된 기록이 아니라 그 키가 없다 — _Refer 계열과 같은 이유로 완결.
return new TagValue(false,
"저장된 기록에서만 값이 됩니다 — 미리보기에는 기록 키(EmrKey)가 없습니다");
case "OCM_병실인실":
{
var comNum = PatientContext.Value(context.ComInf, "ComNum");
return decimal.TryParse(comNum, out var m)
? Wrap(store.RoomBedCount(m), "입실 이력과 병실 마스터가 짝이 되는 행이 없습니다")
: new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
}
case "OCM_병실차액":
{
var comNum = PatientContext.Value(context.ComInf, "ComNum");
return decimal.TryParse(comNum, out var m)
? Wrap(store.RoomExtraCharge(m), "병실 등급·산정 마스터가 짝이 되는 행이 없습니다")
: new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
}
case "OCM_LMP": case "OCM_LMP":
{ {
var comNum = PatientContext.Value(context.ComInf, "ComNum"); var comNum = PatientContext.Value(context.ComInf, "ComNum");
@@ -1619,6 +1808,12 @@ public sealed class PatientTagResolver : ITagValueResolver
"OCM_입원의사", "OCM_퇴원의사", "OCM_협진과", "OCM_협진과_협진의", "OCM_입원의사", "OCM_퇴원의사", "OCM_협진과", "OCM_협진과_협진의",
"OCM_혈액형", "OCM_LMP", "OCM_BST", "OCM_BST_LAST", "OCM_혈액형", "OCM_LMP", "OCM_BST", "OCM_BST_LAST",
"OCM_BMI", "OCM_표준체중_LAST", "OCM_조정체중_LAST", "OCM_비만도", "OCM_BMI", "OCM_표준체중_LAST", "OCM_조정체중_LAST", "OCM_비만도",
"OCM_VITAL접수일시_LAST", "OCM_발병일", "OCM_당일식이",
"OCM_알러지", "OCM_알러지_조영제", "OCM_알러지_약물", "OCM_알러지_음식", "OCM_알러지_기타",
"OCM_알러지_List", "OCM_외출예상시작일시", "OCM_외출예상종료일시",
"ETC_건강보험증번호", "ETC_재해발생일", "ETC_산재지정번호",
"OCM_감염분류_감염정보", "OCM_감염정보", "OCM_퇴원분석_수술처치명", "OCM_Cosign_의사명",
"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_입원일자_낮병동",