퇴원약 8종 — 242종 → 250종

공통 조회(퇴원약DT, bzDataInterface.vb:10482 — 퇴원의약 오더를
일시·코드·이름 순, 1회 투여량은 SQL 계산) 하나에 파생 6종
(코드/명칭/용량/횟수/일수/용법 — 열 하나를 줄단위로, 마지막 뒤에도
개행, 용법은 LEFT JOIN 미매칭이면 빈 줄) + 고정폭 조립 2종:

OCM_퇴원약(:10617) — PadRight 를 "채울 개수"로 오용한 원문 결함
그대로(실제 의미는 총폭이라 정렬이 어긋난다), 코드가 20바이트를
넘으면 음수 인자 예외 → 사유(레거시는 오류창+빈 값). SATCH 비고
괄호 분기 미이식. OCM_퇴원약_New(:10733) — 바이트 고정폭(코드 10·
명칭 37), 용량·횟수·일수 패딩이 잘라낸 값이 아닌 원본 전체 바이트로
계산되는 결함 보존(5자 이상이면 음수 예외 → 사유).

행이 없으면 SRCH 만 "해당없음." 을 찍는다(:10704/:10864) — 우리
병원 분기라 그대로 값이 된다. CP949 바이트 길이는 ASCII 1·그 외 2
근사(레거시 LenK 대응), 바이트 절단은 문자 경계라 레거시의 반각
깨짐("?") 재절단 분기가 필요 없다(폭 최대 1바이트 차이 — 주석 기록).

- dotnet test 342/342 · --edit-smoke 실패 0 (이름 검사 250종)
- --db-patient ①~㊱ 전건 통과 (㉟ 에 퇴원약·수술집도의과·수술명칭 실행 확인 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-20 08:55:08 +09:00
co-authored by Claude Fable 5
parent 03fff0a3a4
commit 199a4142ee
3 changed files with 151 additions and 0 deletions
@@ -741,6 +741,28 @@ public sealed class PatientExtraStore
.Select(r => r.TryGetValue("OprCodNam", out var v) ? v : string.Empty) .Select(r => r.TryGetValue("OprCodNam", out var v) ? v : string.Empty)
.ToArray(); .ToArray();
/// <summary>
/// 퇴원약 목록(퇴원약DT :10482 — 파생 6종·OCM_퇴원약·_New 의 공통 조회).
/// 퇴원의약(OdrDscMed) 오더를 일시·코드·이름 순으로. 1회 투여량(QtyTmsQty)은
/// SQL 계산 그대로(수량형 '*' 이면 원량, 아니면 총량/횟수 반올림 3자리).
/// OdrRefCmt 는 OCM_퇴원약(SATCH 분기)용 컬럼 — 파생 6종은 쓰지 않는다.
/// </summary>
public IReadOnlyList<IReadOnlyDictionary<string, string>> 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));
/// <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)
{ {
@@ -2473,6 +2473,9 @@ public static class DbSmoke
("수술일자", () => owner2 is null ? "-" : extra.SurgeryDate(owner2.Value, false).Length.ToString()), ("수술일자", () => owner2 is null ? "-" : extra.SurgeryDate(owner2.Value, false).Length.ToString()),
("외래실진료일", () => extra.OutpatientVisitDays(extraOwner, "999912312359", "05", false).Count.ToString()), ("외래실진료일", () => extra.OutpatientVisitDays(extraOwner, "999912312359", "05", false).Count.ToString()),
("청구과목", () => extra.BillingDepartmentName("0506").Length.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()), ("적용진료과", () => extra.AppliedDepartmentName("0506", "202601011200").Length.ToString()),
("전과목록", () => owner2 is null ? "-" : extra.TransferList(owner2.Value).Count.ToString()), ("전과목록", () => owner2 is null ? "-" : extra.TransferList(owner2.Value).Count.ToString()),
("간호과거력", () => owner2 is null ? "-" : extra.NurseHistoryRow(owner2.Value).Count.ToString()), ("간호과거력", () => owner2 is null ? "-" : extra.NurseHistoryRow(owner2.Value).Count.ToString()),
@@ -1718,6 +1718,8 @@ 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_퇴원약_New",
}; };
/// <summary> /// <summary>
@@ -2300,6 +2302,62 @@ public sealed class PatientTagResolver : ITagValueResolver
// 원문 결합(:3546-3556): 각 행 뒤 공백 1칸, 행 사이 CRLF(마지막에도 공백) // 원문 결합(:3546-3556): 각 행 뒤 공백 1칸, 행 사이 CRLF(마지막에도 공백)
return new TagValue(true, string.Join("\r\n", names.Select(n => $"{n} "))); 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_수술진단명": case "OCM_수술진단명":
// PURME 전용(:3066) — 그 밖의 병원은 Case 미매칭으로 항상 빈 값이다 // PURME 전용(:3066) — 그 밖의 병원은 Case 미매칭으로 항상 빈 값이다
return new TagValue(false, return new TagValue(false,
@@ -2434,6 +2492,72 @@ public sealed class PatientTagResolver : ITagValueResolver
=> value.Length > 0 ? new TagValue(true, value) : new TagValue(false, reasonWhenEmpty); => value.Length > 0 ? new TagValue(true, value) : new TagValue(false, reasonWhenEmpty);
} }
/// <summary>CP949 바이트 길이 근사 — ASCII 1, 그 밖(한글 등) 2. 레거시 LenK 대응</summary>
private static int ByteLen(string value) => value.Sum(c => c > 127 ? 2 : 1);
/// <summary>바이트 기준 절단 — 문자 경계에서 자르므로 레거시의 반각 깨짐("?") 재절단 분기는 필요 없다</summary>
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;
}
/// <summary>
/// 퇴원약 한 줄(OCM_퇴원약, :10640-10700) — 원문 결함까지 그대로다:
/// PadRight 를 "채울 개수"로 오용해(실제 의미는 총폭) 고정폭 정렬이 어긋나고,
/// 코드가 20바이트를 넘으면 음수 인자 예외 → 레거시는 오류창 후 빈 값이었다
/// (여기서는 위 catch 가 사유로 바꾼다). SATCH 의 비고 괄호 분기는 미이식.
/// </summary>
private static string MedicationLineLegacy(IReadOnlyDictionary<string, string> row)
{
static string Get(IReadOnlyDictionary<string, string> 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")}";
}
/// <summary>
/// 퇴원약 한 줄(OCM_퇴원약_New, :10760-10860) — 바이트 고정폭(코드 10·명칭 37).
/// 용량·횟수·일수의 패딩이 잘라낸 값이 아니라 <b>원본 전체</b> 바이트로 계산되는
/// 원문 결함 그대로 — 5자 이상이면 음수 예외 → 사유가 된다(레거시는 오류창+빈 값).
/// </summary>
private static string MedicationLineNew(IReadOnlyDictionary<string, string> row)
{
static string Get(IReadOnlyDictionary<string, string> 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")}";
}
/// <summary> /// <summary>
/// 병리판독의·진단검사의 4종 — 환자와 무관한 원내 의사 조회지만 레거시가 환자 태그로 노출한다. /// 병리판독의·진단검사의 4종 — 환자와 무관한 원내 의사 조회지만 레거시가 환자 태그로 노출한다.
/// 이름과 번호가 <b>같은 행</b>에서 나온다(레거시는 태그마다 따로 조회해 다른 사람이 될 수 있었다). /// 이름과 번호가 <b>같은 행</b>에서 나온다(레거시는 태그마다 따로 조회해 다른 사람이 될 수 있었다).
@@ -2555,6 +2679,8 @@ 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_퇴원약_New",
"OCM_입원시간", "OCM_입원일시", "OCM_입원일시_영문", "OCM_입실시간", "OCM_입원시간", "OCM_입원일시", "OCM_입원일시_영문", "OCM_입실시간",
"OCM_의사퇴원예고일시", "OCM_퇴원예정일시", "OCM_퇴원시간", "OCM_퇴원일시", "OCM_의사퇴원예고일시", "OCM_퇴원예정일시", "OCM_퇴원시간", "OCM_퇴원일시",
"OCM_퇴원예고일시", "OCM_재원일수", "OCM_퇴원예고재원일수", "OCM_입원일자_낮병동", "OCM_퇴원예고일시", "OCM_재원일수", "OCM_퇴원예고재원일수", "OCM_입원일자_낮병동",