diff --git a/src/SheetMe.Data/Stores/PatientExtraStore.cs b/src/SheetMe.Data/Stores/PatientExtraStore.cs
index dcaec54..2d2a516 100644
--- a/src/SheetMe.Data/Stores/PatientExtraStore.cs
+++ b/src/SheetMe.Data/Stores/PatientExtraStore.cs
@@ -741,6 +741,28 @@ public sealed class PatientExtraStore
.Select(r => r.TryGetValue("OprCodNam", out var v) ? v : string.Empty)
.ToArray();
+ ///
+ /// 퇴원약 목록(퇴원약DT :10482 — 파생 6종·OCM_퇴원약·_New 의 공통 조회).
+ /// 퇴원의약(OdrDscMed) 오더를 일시·코드·이름 순으로. 1회 투여량(QtyTmsQty)은
+ /// SQL 계산 그대로(수량형 '*' 이면 원량, 아니면 총량/횟수 반올림 3자리).
+ /// OdrRefCmt 는 OCM_퇴원약(SATCH 분기)용 컬럼 — 파생 6종은 쓰지 않는다.
+ ///
+ public IReadOnlyList> DischargeMedications(decimal comNum)
+ => Rows(
+ "SELECT OdrCod, OdrNam, OdrTms, OdrDay, UsgKorNam, OdrRefCmt,"
+ + " CASE WHEN OdrQtyTyp = '*' THEN OdrDtrQty ELSE ROUND(OdrDtrQty / OdrTms, 3) END AS QtyTmsQty"
+ + " FROM O_OdrInf"
+ + " LEFT JOIN M_UsgMst ON OdrUsgCod = UsgCod"
+ + " AND OdrDtm BETWEEN UsgStrDte || '0000' AND UsgEndDte || '2359'"
+ + " 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, OdrCod, OdrNam",
+ ("m", comNum));
+
/// 스칼라 1칸 — 0행이거나 NULL 이면 빈 문자열. 잔여 공백은 걷는다(문맥 행과 같은 규칙)
private string Scalar(string sql, params (string Name, object Value)[] binds)
{
diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
index 25185a1..22b38e6 100644
--- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
@@ -2473,6 +2473,9 @@ public static class DbSmoke
("수술일자", () => owner2 is null ? "-" : extra.SurgeryDate(owner2.Value, false).Length.ToString()),
("외래실진료일", () => extra.OutpatientVisitDays(extraOwner, "999912312359", "05", false).Count.ToString()),
("청구과목", () => extra.BillingDepartmentName("0506").Length.ToString()),
+ ("퇴원약목록", () => owner2 is null ? "-" : extra.DischargeMedications(owner2.Value).Count.ToString()),
+ ("수술집도의과", () => owner2 is null ? "-" : extra.SurgeonDepartment(owner2.Value, false).Length.ToString()),
+ ("수술명칭", () => owner2 is null ? "-" : extra.SurgeryNames(owner2.Value).Count.ToString()),
("적용진료과", () => extra.AppliedDepartmentName("0506", "202601011200").Length.ToString()),
("전과목록", () => owner2 is null ? "-" : extra.TransferList(owner2.Value).Count.ToString()),
("간호과거력", () => owner2 is null ? "-" : extra.NurseHistoryRow(owner2.Value).Count.ToString()),
diff --git a/src/SheetMe.Designer/Services/PatientTagResolver.cs b/src/SheetMe.Designer/Services/PatientTagResolver.cs
index cad9c66..2952274 100644
--- a/src/SheetMe.Designer/Services/PatientTagResolver.cs
+++ b/src/SheetMe.Designer/Services/PatientTagResolver.cs
@@ -1718,6 +1718,8 @@ public sealed class PatientTagResolver : ITagValueResolver
"OCM_영상촬영이력",
"OCM_수술주상병", "OCM_수술집도의_진료과", "OCM_수술집도의_그룹진료과",
"OCM_수술명칭", "OCM_수술진단명", "OCM_수술처치",
+ "OCM_퇴원약_코드", "OCM_퇴원약_명칭", "OCM_퇴원약_용량", "OCM_퇴원약_횟수",
+ "OCM_퇴원약_일수", "OCM_퇴원약_용법", "OCM_퇴원약", "OCM_퇴원약_New",
};
///
@@ -2300,6 +2302,62 @@ public sealed class PatientTagResolver : ITagValueResolver
// 원문 결합(:3546-3556): 각 행 뒤 공백 1칸, 행 사이 CRLF(마지막에도 공백)
return new TagValue(true, string.Join("\r\n", names.Select(n => $"{n} ")));
}
+ case "OCM_퇴원약_코드":
+ case "OCM_퇴원약_명칭":
+ case "OCM_퇴원약_용량":
+ case "OCM_퇴원약_횟수":
+ case "OCM_퇴원약_일수":
+ case "OCM_퇴원약_용법":
+ {
+ var comNum = PatientContext.Value(context.ComInf, "ComNum");
+ if (!decimal.TryParse(comNum, out var m))
+ {
+ return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
+ }
+ var meds = store.DischargeMedications(m);
+ if (meds.Count == 0)
+ {
+ return new TagValue(false, "퇴원의약 오더가 없습니다");
+ }
+ // 열 하나를 줄단위로(:10516-10615) — 마지막 뒤에도 개행(AppendLine 원문).
+ // 용법은 LEFT JOIN 미매칭이면 빈 줄이 들어간다(레거시 그대로).
+ var column = tag switch
+ {
+ "OCM_퇴원약_코드" => "OdrCod",
+ "OCM_퇴원약_명칭" => "OdrNam",
+ "OCM_퇴원약_용량" => "QtyTmsQty",
+ "OCM_퇴원약_횟수" => "OdrTms",
+ "OCM_퇴원약_일수" => "OdrDay",
+ _ => "UsgKorNam",
+ };
+ return new TagValue(true, string.Concat(
+ meds.Select(r => $"{PatientContext.Value(r, column)}\r\n")));
+ }
+ case "OCM_퇴원약":
+ case "OCM_퇴원약_New":
+ {
+ var comNum = PatientContext.Value(context.ComInf, "ComNum");
+ if (!decimal.TryParse(comNum, out var m))
+ {
+ return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
+ }
+ var meds = store.DischargeMedications(m);
+ if (meds.Count == 0)
+ {
+ // 행이 없으면 SRCH 만 "해당없음." 을 찍는다(:10704/:10864) —
+ // 우리 병원 분기라 그대로 값이 된다
+ return new TagValue(true, "해당없음.");
+ }
+ var text = new System.Text.StringBuilder();
+ foreach (var row in meds)
+ {
+ text.Append(tag == "OCM_퇴원약"
+ ? MedicationLineLegacy(row)
+ : MedicationLineNew(row));
+ text.Append("\r\n");
+ }
+ return new TagValue(true, text.ToString());
+ }
case "OCM_수술진단명":
// PURME 전용(:3066) — 그 밖의 병원은 Case 미매칭으로 항상 빈 값이다
return new TagValue(false,
@@ -2434,6 +2492,72 @@ public sealed class PatientTagResolver : ITagValueResolver
=> value.Length > 0 ? new TagValue(true, value) : new TagValue(false, reasonWhenEmpty);
}
+ /// CP949 바이트 길이 근사 — ASCII 1, 그 밖(한글 등) 2. 레거시 LenK 대응
+ private static int ByteLen(string value) => value.Sum(c => c > 127 ? 2 : 1);
+
+ /// 바이트 기준 절단 — 문자 경계에서 자르므로 레거시의 반각 깨짐("?") 재절단 분기는 필요 없다
+ private static string CutBytes(string value, int maxBytes)
+ {
+ var used = 0;
+ for (var i = 0; i < value.Length; i++)
+ {
+ used += value[i] > 127 ? 2 : 1;
+ if (used > maxBytes)
+ {
+ return value[..i];
+ }
+ }
+ return value;
+ }
+
+ ///
+ /// 퇴원약 한 줄(OCM_퇴원약, :10640-10700) — 원문 결함까지 그대로다:
+ /// PadRight 를 "채울 개수"로 오용해(실제 의미는 총폭) 고정폭 정렬이 어긋나고,
+ /// 코드가 20바이트를 넘으면 음수 인자 예외 → 레거시는 오류창 후 빈 값이었다
+ /// (여기서는 위 catch 가 사유로 바꾼다). SATCH 의 비고 괄호 분기는 미이식.
+ ///
+ private static string MedicationLineLegacy(IReadOnlyDictionary row)
+ {
+ static string Get(IReadOnlyDictionary r, string c)
+ => r.TryGetValue(c, out var v) ? v.Trim() : string.Empty;
+ var cod = Get(row, "OdrCod");
+ var nam = Get(row, "OdrNam");
+ var qty = Get(row, "QtyTmsQty");
+ var tms = Get(row, "OdrTms");
+ var day = Get(row, "OdrDay");
+ var codText = cod.PadRight(20 - ByteLen(cod));
+ var namText = ByteLen(nam) < 50 ? nam.PadRight(50 - ByteLen(nam)) : CutBytes(nam, 50);
+ var qtyText = qty.Length < 6 ? qty.PadRight(5 - qty.Length) : qty;
+ var tmsText = tms.Length < 6 ? tms.PadRight(5 - tms.Length) : tms;
+ var dayText = day.Length < 6 ? day.PadRight(5 - day.Length) : day;
+ return $"{codText}{namText}{qtyText} {tmsText} {dayText} {Get(row, "UsgKorNam")}";
+ }
+
+ ///
+ /// 퇴원약 한 줄(OCM_퇴원약_New, :10760-10860) — 바이트 고정폭(코드 10·명칭 37).
+ /// 용량·횟수·일수의 패딩이 잘라낸 값이 아니라 원본 전체 바이트로 계산되는
+ /// 원문 결함 그대로 — 5자 이상이면 음수 예외 → 사유가 된다(레거시는 오류창+빈 값).
+ ///
+ private static string MedicationLineNew(IReadOnlyDictionary row)
+ {
+ static string Get(IReadOnlyDictionary r, string c)
+ => r.TryGetValue(c, out var v) ? v.Trim() : string.Empty;
+ static string PadTail(string value, int count) => value + string.Empty.PadLeft(count);
+ var cod = Get(row, "OdrCod");
+ var codCut = cod.Length < 10 ? cod : cod[..10];
+ var codText = PadTail(codCut, 10 - ByteLen(codCut));
+ var nam = Get(row, "OdrNam");
+ var namCut = ByteLen(nam) < 36 ? nam : CutBytes(nam, 36);
+ var namText = PadTail(namCut, 37 - ByteLen(namCut));
+ var qty = Get(row, "QtyTmsQty");
+ var qtyText = PadTail(qty.Length < 4 ? qty : qty[..4], 4 - ByteLen(qty));
+ var tms = Get(row, "OdrTms");
+ var tmsText = PadTail(tms.Length < 4 ? tms : tms[..4], 4 - ByteLen(tms));
+ var day = Get(row, "OdrDay");
+ var dayText = PadTail(day.Length < 4 ? day : day[..4], 4 - ByteLen(day));
+ return $"{codText} {namText} {qtyText}{tmsText}{dayText}{Get(row, "UsgKorNam")}";
+ }
+
///
/// 병리판독의·진단검사의 4종 — 환자와 무관한 원내 의사 조회지만 레거시가 환자 태그로 노출한다.
/// 이름과 번호가 같은 행에서 나온다(레거시는 태그마다 따로 조회해 다른 사람이 될 수 있었다).
@@ -2555,6 +2679,8 @@ public sealed class PatientTagResolver : ITagValueResolver
"OCM_영상촬영이력",
"OCM_수술주상병", "OCM_수술집도의_진료과", "OCM_수술집도의_그룹진료과",
"OCM_수술명칭", "OCM_수술진단명", "OCM_수술처치",
+ "OCM_퇴원약_코드", "OCM_퇴원약_명칭", "OCM_퇴원약_용량", "OCM_퇴원약_횟수",
+ "OCM_퇴원약_일수", "OCM_퇴원약_용법", "OCM_퇴원약", "OCM_퇴원약_New",
"OCM_입원시간", "OCM_입원일시", "OCM_입원일시_영문", "OCM_입실시간",
"OCM_의사퇴원예고일시", "OCM_퇴원예정일시", "OCM_퇴원시간", "OCM_퇴원일시",
"OCM_퇴원예고일시", "OCM_재원일수", "OCM_퇴원예고재원일수", "OCM_입원일자_낮병동",