유형·수납·진료기간·응급증상·전과내역 8종 — 185종 → 193종

OCM_계산유형(bzDataInterface.vb:5133 — 문맥 자격의 보험·계산 코드로
M_CalMst.CalNam), OCM_보험유형(:5186 — M_InsMst.InsNam),
OCM_수납일시(:5287 — 자격의 수납청구→수납 갱신일시. 원문 두 쿼리 다
비결정 Rows(0) — 최신 청구의 최신 수납으로 고정, 한 왕복으로 접음),
OCM_진료기간(:2236 — SRCH 갈래: 퇴원이면 접수~퇴원, 재원이면 최신
퇴원약 오더→퇴원예정→오늘 순. 외래: 최초 접수~최신 접수/오늘.
HANKOOK·SRH·GUPOSS 분기 미이식), OCM_진료기간_진료과(:2575 — 입원만
값. 외래는 두 번째 쿼리의 진료과 바인드 미등록(레거시 결함, :2644)으로
상시 ORA-01008→빈 값 — 사유로 완결), OCM_응급증상여부_YES/NO
(:12748/:12830 — 응급의료관리료 오더의 산정 여부. 코드 목록은
GetDtlMst("EMGEMGCOD") 그대로 + 0건이면 하드코딩 6종 폴백.
행이 없으면 레거시 $NoCheck$ — 체크 상태를 정하지 않는다.
SJRCH 분기 미이식), OCM_전과내역(:1103 — 과명+공백7+구간, CRLF 결합.
과 조인이 의사 조인에 종속되는 원문 모양 유지).

체크류 렌더 보완: 값을 못 만든 태그(사유)는 체크박스·라디오의 컨트롤을
그대로 둔다 — 레거시도 SetValue 를 건너뛴다. 라벨에 사유를 쓰면
"남 ☑" 이 "[태그 — 사유] ☑" 가 되어 서식이 깨진다.

- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 193종)
- --db-patient ①~㉟ 전건 통과 (㉟ 에 7종 추가 — 보험유형 실값 4자,
  응급 쿼리의 ORDER BY ROWID 모호성은 OdrKey 타이브레이크로 수정)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-19 19:16:37 +09:00
co-authored by Claude Fable 5
parent 7bae9b9057
commit 4e6e086ece
4 changed files with 314 additions and 0 deletions
@@ -385,6 +385,147 @@ public sealed class PatientExtraStore
+ " WHERE ComNum = :m ORDER BY CowStrDtm ASC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 계산유형 이름(OCM_계산유형, :5133) — 자격의 보험·계산 코드로 M_CalMst.CalNam.
/// 원문 Rows(0) 에 ORDER BY 가 없다 — 시작일 최신 행으로 고정한다.
/// </summary>
public string CalculationTypeName(string insCod, string calCod, string strDte)
=> Scalar(
"SELECT v FROM (SELECT CalNam v FROM M_CalMst"
+ " WHERE CalInsCod = :i AND CalCod = :l AND :d BETWEEN CalStrDte AND CalEndDte"
+ " ORDER BY CalStrDte DESC) WHERE ROWNUM = 1",
("i", insCod), ("l", calCod), ("d", strDte));
/// <summary>보험유형 이름(OCM_보험유형, :5186) — M_InsMst.InsNam. 같은 규칙으로 고정</summary>
public string InsuranceTypeName(string insCod, string strDte)
=> Scalar(
"SELECT v FROM (SELECT InsNam v FROM M_InsMst"
+ " WHERE InsCod = :i AND :d BETWEEN InsStrDte AND InsEndDte"
+ " ORDER BY InsStrDte DESC) WHERE ROWNUM = 1",
("i", insCod), ("d", strDte));
/// <summary>
/// 수납일시(OCM_수납일시, :5287) — 자격에 걸린 수납청구(P_RblInf, RblCalNxt=0)의
/// 수납(P_RcpInf) 갱신일시 12자리. 원문은 두 쿼리 모두 ORDER BY 없는 Rows(0) —
/// 청구·수납 다건이면 임의였다. 최신 청구의 최신 수납으로 고정하고 한 왕복으로 접었다.
/// </summary>
public string ReceiptStamp(decimal comNum, string mtiSeq, string strDte)
=> Scalar(
"SELECT v FROM (SELECT RcpUpdDtf v FROM P_RcpInf WHERE RcpRblNum IN ("
+ " SELECT n FROM (SELECT RblNum n FROM P_RblInf"
+ " WHERE RblComNum = :m AND RblMtiSeq = :s AND RblStrDte = :d AND RblCalNxt = 0"
+ " ORDER BY RblNum DESC) WHERE ROWNUM = 1)"
+ " ORDER BY RcpUpdDtf DESC) WHERE ROWNUM = 1",
("m", comNum), ("s", mtiSeq), ("d", strDte));
/// <summary>
/// 최신 퇴원약 오더 일시(OCM_진료기간 SRCH 갈래, :2299-2318) — 인라인뷰 정렬이
/// 원문부터 있어 결정적이다. 필터(처방의약·숨김 제외 등)는 원문 그대로.
/// </summary>
public string DischargeMedicationOrderDate(decimal comNum)
=> Scalar(
"SELECT v FROM (SELECT OdrDtm v FROM O_OdrInf"
+ " WHERE OdrComNum = :m AND OdrStt = 'OE'"
+ " AND (OdrDscMed IS NOT NULL AND OdrDscMed <> ' ')"
+ " AND (OdrHidden IS NULL OR OdrHidden <> 1)"
+ " AND OdrGrpSub = 0"
+ " AND (OdrAutCod IS NULL OR OdrAutCod = '-')"
+ " AND (OdrSimEdt IS NULL OR OdrSimEdt NOT IN ('I','E'))"
+ " ORDER BY OdrDtm DESC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 외래 진료기간 시작(OCM_진료기간 외래 갈래, :2325-2345) — 이 내원 이하의 취소 아닌
/// 최초 접수일시. OCM_최초내원일과 달리 진료행 조인·재진 필터가 없다(원문 그대로 —
/// ComNfsTyp='N' 조건은 주석 처리돼 있어 옮기지 않는다).
/// </summary>
public string TreatmentPeriodStart(string chtNum, string acpDtm)
=> Scalar(
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm <= :a AND ComAcpStt <> 'OC'"
+ " ORDER BY ComAcpDtm) WHERE ROWNUM = 1",
("c", chtNum), ("a", acpDtm));
/// <summary>외래 진료기간 끝(:2352-2372) — 시작 이후 오늘(2359)까지의 최신 접수일시</summary>
public string TreatmentPeriodEnd(string chtNum, string strDte)
=> Scalar(
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm >= :s"
+ " AND ComAcpDtm <= TO_CHAR(SYSDATE, 'YYYYMMDD') || '2359' AND ComAcpStt <> 'OC'"
+ " ORDER BY ComAcpDtm DESC) WHERE ROWNUM = 1",
("c", chtNum), ("s", strDte));
/// <summary>
/// 응급증상 오더의 산정 여부(OCM_응급증상여부_YES/NO, :12748/:12830) — 응급 내원에
/// 걸린 응급의료관리료 오더 중 한 행의 OdrInsYon. 행이 없으면 null(레거시 $NoCheck$ —
/// 체크 상태를 정하지 않는다). 원문 Rows(0) 에 ORDER BY 가 없다 — 오더일시 최신으로
/// 고정한다. 코드 목록은 레거시 GetDtlMst("EMGEMGCOD") 그대로: 코드표에 있으면 그것,
/// 없으면 하드코딩 6종(레거시 폴백).
/// </summary>
public string? EmergencySymptomInsYon(decimal comNum)
{
var codes = Rows("SELECT DtlCod FROM M_DtlMst WHERE DtlTblCod = 'EMGEMGCOD' ORDER BY DtlCod")
.Select(r => r.TryGetValue("DtlCod", out var v) ? v : string.Empty)
.Where(v => v.Length > 0)
.ToArray();
if (codes.Length == 0)
{
codes = new[] { "V1200", "V1300", "V1400", "V1500", "AC103", "AC105" };
}
var placeholders = string.Join(", ", codes.Select((_, i) => $":g{i}"));
var binds = new List<(string, object)> { ("m", comNum) };
for (var i = 0; i < codes.Length; i++)
{
binds.Add(($"g{i}", codes[i]));
}
var value = Scalar(
"SELECT v FROM (SELECT OdrInsYon v FROM S_EmgInf OCMEMG"
+ " LEFT JOIN S_EmgIcmInf ICMEMG ON OCMEMG.EmgComNum = ICMEMG.EmgOcmNum"
+ " INNER JOIN O_OdrInf ON CASE WHEN NVL(ICMEMG.EmgComNum, 0) > 0"
+ " THEN ICMEMG.EmgComNum ELSE OCMEMG.EmgComNum END = OdrComNum"
+ " INNER JOIN O_OdsInf ON OdrKey = OdsOdrKey AND OdsStt = 'OE'"
+ " INNER JOIN M_SgaMst ON OdrCod = SgaCod"
+ " AND OdrDtm BETWEEN SgaStrDte AND SgaEndDte || '2359'"
+ " WHERE OCMEMG.EmgComNum = :m"
+ " AND OdrStt = 'OE' AND (OdrHidden IS NULL OR OdrHidden <> 1)"
+ $" AND OdrElcCod IN ({placeholders})"
+ " ORDER BY OdrDtm DESC, OdrKey DESC) WHERE ROWNUM = 1",
binds.ToArray());
// 빈 문자열은 "행은 있는데 값이 NULL"과 "행이 없다"를 못 가른다 — COUNT 로 가른다
if (value.Length > 0)
{
return value;
}
var count = Scalar(
"SELECT COUNT(*) FROM S_EmgInf OCMEMG"
+ " LEFT JOIN S_EmgIcmInf ICMEMG ON OCMEMG.EmgComNum = ICMEMG.EmgOcmNum"
+ " INNER JOIN O_OdrInf ON CASE WHEN NVL(ICMEMG.EmgComNum, 0) > 0"
+ " THEN ICMEMG.EmgComNum ELSE OCMEMG.EmgComNum END = OdrComNum"
+ " INNER JOIN O_OdsInf ON OdrKey = OdsOdrKey AND OdsStt = 'OE'"
+ " INNER JOIN M_SgaMst ON OdrCod = SgaCod"
+ " AND OdrDtm BETWEEN SgaStrDte AND SgaEndDte || '2359'"
+ " WHERE OCMEMG.EmgComNum = :m"
+ " AND OdrStt = 'OE' AND (OdrHidden IS NULL OR OdrHidden <> 1)"
+ $" AND OdrElcCod IN ({placeholders})",
binds.ToArray());
return count == "0" ? null : string.Empty;
}
/// <summary>
/// 전과 내역(OCM_전과내역, :1103) — 진료 행 전부를 과명·구간과 함께.
/// 원문 조인 그대로: 과 조인이 담당의 조인의 병원 코드에 종속돼(DepHspCod=Usr.UidHspCod)
/// 의사 매칭이 빠지면 과명도 빈다. 정렬(CodStrDtm)이 원문부터 있어 결정적이다.
/// </summary>
public IReadOnlyList<IReadOnlyDictionary<string, string>> TransferHistory(decimal comNum)
=> Rows(
"SELECT DepKorNam AS DepNam, Cod.CodStrDtm, Cod.CodEndDtm FROM P_CodInf Cod"
+ " LEFT OUTER JOIN M_UidMst Usr ON Usr.UidCod = CodDtrCod AND Usr.UidDtrYon = 'Y'"
+ " AND Cod.CodEndDtm BETWEEN Usr.UidStrDte AND Usr.UidEndDte || '2359'"
+ " LEFT OUTER JOIN M_DepMst Dep ON DepCod = CodDepCod AND DepHspCod = Usr.UidHspCod"
+ " AND DepWrkTyp = 'D' AND Cod.CodEndDtm BETWEEN DepStrDte AND DepEndDte || '2359'"
+ " WHERE CodComNum = :m AND CodMtiSeq = '0' ORDER BY CodStrDtm",
("m", comNum));
/// <summary>스칼라 1칸 — 0행이거나 NULL 이면 빈 문자열. 잔여 공백은 걷는다(문맥 행과 같은 규칙)</summary>
private string Scalar(string sql, params (string Name, object Value)[] binds)
{
@@ -2437,6 +2437,13 @@ public static class DbSmoke
("병실인실", () => owner2 is null ? "-" : extra.RoomBedCount(owner2.Value).Length.ToString()),
("병실차액", () => owner2 is null ? "-" : extra.RoomExtraCharge(owner2.Value).Length.ToString()),
("산재지정번호", () => extra.IndustrialAccidentNumber("0506", "20260819").Length.ToString()),
("계산유형", () => extra.CalculationTypeName("1", "01", "20260101").Length.ToString()),
("보험유형", () => extra.InsuranceTypeName("1", "20260101").Length.ToString()),
("수납일시", () => owner2 is null ? "-" : extra.ReceiptStamp(owner2.Value, "0", "20260101").Length.ToString()),
("진료기간시작", () => extra.TreatmentPeriodStart(extraOwner, "999912312359").Length.ToString()),
("퇴원약오더", () => owner2 is null ? "-" : extra.DischargeMedicationOrderDate(owner2.Value).Length.ToString()),
("응급증상", () => owner2 is null ? "-" : (extra.EmergencySymptomInsYon(owner2.Value) ?? "[없음]")),
("전과내역", () => owner2 is null ? "-" : extra.TransferHistory(owner2.Value).Count.ToString()),
};
var shapes2 = new List<string>();
var failed = new List<string>();
@@ -1380,6 +1380,9 @@ public sealed class PatientTagResolver : ITagValueResolver
"OCM_감염분류_감염정보", "OCM_감염정보",
"OCM_퇴원분석_수술처치명", "OCM_Cosign_의사명",
"OCM_병실인실", "OCM_병실차액",
"OCM_계산유형", "OCM_보험유형", "OCM_수납일시",
"OCM_진료기간", "OCM_진료기간_진료과",
"OCM_응급증상여부_YES", "OCM_응급증상여부_NO", "OCM_전과내역",
};
/// <summary>
@@ -1638,6 +1641,160 @@ public sealed class PatientTagResolver : ITagValueResolver
? Wrap(store.RoomExtraCharge(m), "병실 등급·산정 마스터가 짝이 되는 행이 없습니다")
: new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
}
case "OCM_계산유형":
case "OCM_보험유형":
case "OCM_수납일시":
{
// 자격의 코드·시작일은 문맥 CoiInf 행 — 레거시의 시점 분기
// (퇴원/현재/접수, :5148-5156)와 같은 판정이다.
// 레거시는 자격 0행이면 Rows(0) 예외 → 오류창 → "" 였다.
if (context.CoiInf.Count == 0)
{
return new TagValue(false, "이 내원에 보험 자격(P_CoiInf) 행이 없습니다");
}
var insCod = PatientContext.Value(context.CoiInf, "CoiInsCod");
var strDte = PatientContext.Value(context.CoiInf, "CoiStrDte");
if (tag == "OCM_계산유형")
{
return Wrap(store.CalculationTypeName(insCod,
PatientContext.Value(context.CoiInf, "CoiCalCod"), strDte),
"계산 마스터(M_CalMst)에 짝이 되는 행이 없습니다");
}
if (tag == "OCM_보험유형")
{
return Wrap(store.InsuranceTypeName(insCod, strDte),
"보험 마스터(M_InsMst)에 짝이 되는 행이 없습니다");
}
var comNum = PatientContext.Value(context.ComInf, "ComNum");
if (!decimal.TryParse(comNum, out var m))
{
return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
}
var stamp = store.ReceiptStamp(m,
PatientContext.Value(context.CoiInf, "CoiMtiSeq"), strDte);
return stamp.Length >= 12
? new TagValue(true, Stamp(stamp))
: new TagValue(false, "이 자격의 수납(P_RcpInf) 기록이 없습니다");
}
case "OCM_진료기간":
{
// SRCH 갈래(:2295-2323)와 외래 공통 갈래(:2325-2380)를 옮겼다.
// HANKOOK·SRH·GUPOSS·Case Else 분기는 우리 병원이 아니라 미이식.
var acp = PatientContext.Value(context.ComInf, "ComAcpDtm");
if (acp.Length < 8)
{
return new TagValue(false, "이 내원의 접수일시를 읽지 못했습니다");
}
var start = Hyphenate(acp[..8]);
var today = PatientContext.Value(hospital, "ServerToday");
if (Com(context, "ComPatTyp") == "I")
{
if (Com(context, "ComPrgStt") == "ILV")
{
var lev = Com(context, "ComLevDtm");
return new TagValue(true, $"{start} ~ {Hyphenate(Head(lev, 8))}");
}
var comNum = PatientContext.Value(context.ComInf, "ComNum");
if (!decimal.TryParse(comNum, out var m))
{
return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
}
// 재원 중 — 끝은 최신 퇴원약 오더일 → 퇴원예정일 → 오늘 순(:2299-2321)
var order = store.DischargeMedicationOrderDate(m);
var end = order.Length >= 8 ? order[..8]
: Com(context, "ComLevSch").Length >= 8 ? Com(context, "ComLevSch")[..8]
: today;
return end.Length >= 8
? new TagValue(true, $"{start} ~ {Hyphenate(end[..8])}")
: new TagValue(false, "요양기관 행(서버 오늘)을 읽지 못했습니다");
}
// 외래 — 최초 접수 ~ (그 이후 최신 접수 또는 오늘)
var first = store.TreatmentPeriodStart(chtNum, acp);
if (first.Length < 8)
{
return new TagValue(false, "취소 아닌 이전 내원이 없습니다");
}
var last = store.TreatmentPeriodEnd(chtNum, first);
var tail = last.Length >= 8 ? last[..8] : today;
return tail.Length >= 8
? new TagValue(true, $"{Hyphenate(first[..8])} ~ {Hyphenate(tail[..8])}")
: new TagValue(false, "요양기관 행(서버 오늘)을 읽지 못했습니다");
}
case "OCM_진료기간_진료과":
{
// 입원 갈래(:2600-2610)만 값이 된다. 외래 갈래는 두 번째 쿼리의
// 진료과 바인드(sDepCod)가 파라미터에 등록되지 않아(레거시 결함,
// :2644-2647) 항상 ORA-01008 → 오류창 → 빈 값이었다 — 사유로 말한다.
if (Com(context, "ComPatTyp") != "I")
{
return new TagValue(false,
"레거시 결함(진료과 바인드 미등록)으로 외래에서는 항상 빈 값인 태그입니다");
}
var acp = PatientContext.Value(context.ComInf, "ComAcpDtm");
if (acp.Length < 8)
{
return new TagValue(false, "이 내원의 접수일시를 읽지 못했습니다");
}
var start = Hyphenate(acp[..8]);
if (Com(context, "ComPrgStt") == "ILV")
{
return new TagValue(true,
$"{start} ~ {Hyphenate(Head(Com(context, "ComLevDtm"), 8))}");
}
var today = PatientContext.Value(hospital, "ServerToday");
return today.Length >= 8
? new TagValue(true, $"{start} ~ {Hyphenate(today)}")
: new TagValue(false, "요양기관 행(서버 오늘)을 읽지 못했습니다");
}
case "OCM_응급증상여부_YES":
case "OCM_응급증상여부_NO":
{
// 체크박스 태그다: "Y"=체크, ""=언체크, 행 없음=상태를 정하지 않음
// (레거시 $NoCheck$ — 사유로 돌려주면 체크류 렌더가 컨트롤을 그대로 둔다).
// SJRCH 분기(:12878-12886)는 미이식 — Case Else 갈래만 옮겼다.
var comNum = PatientContext.Value(context.ComInf, "ComNum");
if (!decimal.TryParse(comNum, out var m))
{
return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
}
var insYon = store.EmergencySymptomInsYon(m);
if (insYon is null)
{
return new TagValue(false,
"응급의료관리료 오더가 없어 체크 여부를 정하지 않습니다");
}
var yes = insYon == "0";
return new TagValue(true,
(tag == "OCM_응급증상여부_YES") == yes ? "Y" : string.Empty);
}
case "OCM_전과내역":
{
var comNum = PatientContext.Value(context.ComInf, "ComNum");
if (!decimal.TryParse(comNum, out var m))
{
return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
}
var history = store.TransferHistory(m);
if (history.Count == 0)
{
return new TagValue(false, "진료 행(P_CodInf) 이력이 없습니다");
}
// 원문 결합(:1140-1150): 과명 + 공백 7 + 시작 ~ 끝, 행 간 CRLF(끝에는 없음)
var text = new System.Text.StringBuilder();
foreach (var row in history)
{
if (text.Length > 0)
{
text.Append("\r\n");
}
text.Append(PatientContext.Value(row, "DepNam"))
.Append(" ")
.Append(Stamp(PatientContext.Value(row, "CodStrDtm")))
.Append(" ~ ")
.Append(Stamp(PatientContext.Value(row, "CodEndDtm")));
}
return new TagValue(true, text.ToString());
}
case "OCM_LMP":
{
var comNum = PatientContext.Value(context.ComInf, "ComNum");
@@ -1814,6 +1971,8 @@ public sealed class PatientTagResolver : ITagValueResolver
"ETC_건강보험증번호", "ETC_재해발생일", "ETC_산재지정번호",
"OCM_감염분류_감염정보", "OCM_감염정보", "OCM_퇴원분석_수술처치명", "OCM_Cosign_의사명",
"OCM_병실인실", "OCM_병실차액", "OCM_진료과_전화번호", "OCM_전과일시", "OCM_전과일자",
"OCM_계산유형", "OCM_보험유형", "OCM_수납일시", "OCM_진료기간", "OCM_진료기간_진료과",
"OCM_응급증상여부_YES", "OCM_응급증상여부_NO", "OCM_전과내역",
"OCM_입원시간", "OCM_입원일시", "OCM_입원일시_영문", "OCM_입실시간",
"OCM_의사퇴원예고일시", "OCM_퇴원예정일시", "OCM_퇴원시간", "OCM_퇴원일시",
"OCM_퇴원예고일시", "OCM_재원일수", "OCM_퇴원예고재원일수", "OCM_입원일자_낮병동",
@@ -196,6 +196,13 @@ public static class PrintService
{
return control;
}
// 체크류에서 값을 못 만들면 컨트롤을 그대로 둔다 — 레거시도 SetValue 를 건너뛰고
// ($NoCheck$ · 오류) 라벨과 기본 체크 상태를 유지한다. 라벨에 사유를 쓰면
// "남 ☑" 이 "[태그 — 사유] ☑" 가 되어 서식이 깨진다.
if (!value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
{
return control;
}
var model = control.Model.Clone();
if (value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
{