환자·내원 조회를 붙인다 — 레거시 SQL 을 그대로 옮기지 않고

미리보기 레거시 호환 계획 A3 의 조회 계층. --db-patient 로 실DB 검증까지.

<b>레거시에서 옮기지 않은 것 셋</b>(dtPatVisitList.vb:42, 133):
  ① <b>바인드 변수가 하나도 없다.</b> 검색어를 SQL 문자열에 그대로 잇고 따옴표 이스케이프도 없다 —
     성명 칸에 작은따옴표를 넣으면 그대로 SQL 이 된다. 전부 OracleParameter 로 바꿨고
     와일드카드는 SQL 리터럴이 아니라 <b>파라미터 값</b>에 붙인다.
  ② <b>행 제한이 없다.</b> ROWNUM/FETCH 어디에도 없어서 성명 한 글자면 전 환자가 클라이언트로 온다.
     ROWNUM 상한 200 을 건다.
  ③ <b>TRIM 정규화가 없다.</b> CHAR 고정폭 잔여 공백에 걸린다.

<b>컬럼을 다 가져오지 않는다.</b> 레거시 SELECT 는 도로명·지번 주소를 CASE 로 조립하고
M_ZipMst 를 조인해 화면 12열을 채운다. 미리보기 환자 선택에 필요한 것은 사람을 특정할 최소한이다 —
개인정보는 덜 읽는 쪽이 낫다. 주민번호도 UDF_GetMaskedResNum 결과만 읽는다
(레거시는 암호화 병원에서 복호화한 평문을 그리드에 그대로 띄운다).

내원 목록의 <b>금액 6열은 옮기지 않았다</b> — 레거시도 값을 받지 못해 화면에서 늘 비어 있다.
기간 PK 마스터(M_DepMst·M_UidMst·M_InsMst)는 ROW_NUMBER … Rn = 1 로 접는다.
안 접으면 같은 내원이 여러 번 뜬다 — 검사 ⑦ 이 그것을 본다.

<b>UID 는 Oracle 내장 함수다.</b> 의사 마스터 별칭을 Uid 로 두었더니 ORA-00909(인수 개수 오류)가 났다 —
Uid.Nam 을 함수 호출로 파싱한다. 컴파일은 통과했고 실DB 에서만 드러났다.
--db-patient 가 없었으면 환자를 고르는 화면에서 처음 터졌을 것이다.

실측(테스트 접속): 성명 '김' 200건(상한), 네 갈래 전부 예외 없음,
빈 검색어 0건, 주민번호 1글자 0건, 따옴표 주입 0건, 상한 동작, 내원 중복 없음 — 7/7.

게이트: 테스트 303/303, --edit-smoke 0실패, --dialog-shots FAIL 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.

<b>남은 것</b>: 환자 선택 UI, ComNum→문맥 5행(P_PatInf/P_ComInf/P_CodInf/P_CoiInf/P_CowInf)과
적용일시(AdpDtm) 결정 규칙, 그리고 그 문맥을 쓰는 태그 해석기.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-18 10:21:47 +09:00
co-authored by Claude Opus 5
parent e9d8f7356e
commit 1f501df5f7
3 changed files with 298 additions and 0 deletions
@@ -0,0 +1,215 @@
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>환자 검색 방식 — 레거시 fmPatVisitList 의 검색 유형 네 가지</summary>
public enum PatientSearchKind
{
/// <summary>차트번호 — 레거시는 등호 완전일치다(병원별 zero-pad 후 전달)</summary>
ChartNumber,
/// <summary>성명 — 접두 일치</summary>
Name,
/// <summary>주민번호 — 하이픈을 떼고 접두 일치</summary>
ResidentNumber,
/// <summary>휴대전화 — 부분 일치</summary>
MobilePhone,
}
/// <summary>환자 검색 결과 1건</summary>
public sealed record PatientSummary(
string ChtNum, string Name, string ResNumMasked, string MobilePhone,
string LastVisit, bool Inpatient, bool Shielded, bool Active);
/// <summary>내원 1건</summary>
public sealed record VisitSummary(
decimal ComNum, string Kind, string AcceptedAt, string LeftAt,
string Department, string Doctor, string Insurance, string State);
/// <summary>
/// 환자·내원 조회 — 미리보기에 환자 문맥을 붙이기 위한 최소 조회.
///
/// <b>레거시에서 옮기지 않는 것 셋.</b> 원본(<c>dtPatVisitList.vb:42, 133</c>)은
/// ① <b>바인드 변수를 하나도 쓰지 않는다</b> — 검색어를 SQL 문자열에 그대로 잇고 따옴표 이스케이프도 없다.
/// 성명 칸에 작은따옴표를 넣으면 그대로 SQL 이 된다.
/// ② <b>행 제한이 없다</b>(ROWNUM/FETCH 어디에도 없음) — 성명 한 글자로 전 환자가 클라이언트로 온다.
/// ③ <b>TRIM·UPPER 정규화가 없다</b> — CHAR 고정폭 잔여 공백에 걸린다.
/// 셋 다 이 저장소에서는 <see cref="OracleLegacyFormStore"/> 규범대로 고친다.
/// 와일드카드는 SQL 리터럴이 아니라 <b>파라미터 값</b>에 붙인다.
///
/// <b>컬럼을 다 가져오지 않는다.</b> 레거시 SELECT 는 도로명·지번 주소를 CASE 로 조립하고
/// <c>M_ZipMst</c> 를 조인하는 등 화면 12열을 채우려 크다. 미리보기 환자 선택에 필요한 것은
/// 사람을 특정할 수 있는 최소한이므로 그만 읽는다 — 개인정보는 덜 읽는 쪽이 낫다.
///
/// <b>주민번호는 마스킹된 것만 읽는다.</b> 레거시는 암호화 병원에서 복호화한 평문을 그리드에 그대로 띄우고
/// 마스킹을 실제로 쓰는 병원은 셋뿐이다(<c>fmPatVisitList.vb:864-869</c>).
/// 여기서는 사람을 가려낼 수 있으면 충분하므로 <c>UDF_GetMaskedResNum</c> 결과만 가져온다.
/// </summary>
public sealed class PatientVisitStore
{
#region Member Fields
/// <summary>기본 행 상한 — 레거시에는 없다. 사람이 눈으로 고르는 목록이라 이보다 많으면 검색어를 좁혀야 한다</summary>
public const int DefaultLimit = 200;
private readonly string connectionString;
#endregion
#region Constructors
public PatientVisitStore(string connectionString) => this.connectionString = connectionString;
#endregion
#region Methods
/// <summary>
/// 환자 검색. 검색어가 비면 아무것도 돌려주지 않는다 —
/// 조건 없는 전 환자 조회는 레거시에서도 사고였고 여기서 되살릴 이유가 없다.
/// </summary>
public List<PatientSummary> Search(PatientSearchKind kind, string term, int max = DefaultLimit)
{
var value = (term ?? string.Empty).Trim();
if (value.Length == 0)
{
return new List<PatientSummary>();
}
// 주민번호는 하이픈을 떼고 본다(레거시 :96-100 과 같다).
// 최소 2글자 — 레거시도 이것만은 제한한다(fmPatVisitList.vb:761-763).
if (kind == PatientSearchKind.ResidentNumber)
{
value = value.Replace("-", string.Empty);
if (value.Length < 2)
{
return new List<PatientSummary>();
}
}
var (where, bound) = kind switch
{
// 레거시와 같이 완전일치. 호출부가 병원 규약대로 자릿수를 맞춰 넘긴다.
PatientSearchKind.ChartNumber => ("TRIM(PatChtNum) = :v", value),
PatientSearchKind.Name => ("PatNam LIKE :v", value + "%"),
PatientSearchKind.ResidentNumber => ("PatResNum LIKE :v", value + "%"),
// 레거시는 양쪽 와일드카드라 인덱스를 못 탄다. 뒤에서 찾는 것이 실사용이라 그대로 두되
// 행 상한이 있으므로 전건 스캔이 화면까지 오지는 않는다.
_ => ("PatMblPhn LIKE :v", "%" + value + "%"),
};
using var connection = Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT * FROM ("
+ " SELECT TRIM(PatChtNum) ChtNum, NVL(PatNam,' ') Nam,"
+ " NVL(UDF_GetMaskedResNum(PatResNum),' ') ResMasked,"
+ " NVL(PatMblPhn,' ') Mbl,"
+ " NVL(B.LastVisit,' ') LastVisit,"
+ " NVL(I.ComNum, 0) InP,"
+ " NVL(PatInfShd,' ') Shd, NVL(PatUseYon,' ') UseYon"
+ " FROM P_PatInf A"
// 최종 내원일시 — 레거시와 같은 서브쿼리(취소 내원 제외)
+ " LEFT JOIN (SELECT ComChtNum, MAX(ComAcpDtm) LastVisit FROM P_ComInf"
+ " WHERE ComAcpStt <> 'OC' GROUP BY ComChtNum) B"
+ " ON A.PatChtNum = B.ComChtNum"
// 지금 재원 중인가 — 레거시는 오늘 날짜를 클라이언트에서 넣지만 여기서는 DB 시각을 쓴다
+ " LEFT JOIN (SELECT ComChtNum, MIN(ComNum) ComNum FROM P_ComInf"
+ " WHERE ComPatTyp = 'I' AND ComAcpStt NOT IN ('OC','IC')"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') || '0000' <= ComLevDtm"
+ " AND ComAcpDtm <= TO_CHAR(SYSDATE,'YYYYMMDD') || '2359'"
+ " GROUP BY ComChtNum) I"
+ " ON A.PatChtNum = I.ComChtNum"
+ $" WHERE {where}"
+ " ORDER BY PatNam, PatChtNum"
+ $") WHERE ROWNUM <= {max}";
command.Parameters.Add(new OracleParameter("v", bound));
var result = new List<PatientSummary>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
result.Add(new PatientSummary(
reader.GetString(0).Trim(),
reader.GetString(1).Trim(),
reader.GetString(2).Trim(),
reader.GetString(3).Trim(),
reader.GetString(4).Trim(),
reader.GetDecimal(5) > 0,
reader.GetString(6).Trim() == "Y",
reader.GetString(7).Trim() != "N"));
}
return result;
}
/// <summary>
/// 이 환자의 내원 목록 — 최근 것이 위.
///
/// 레거시 <c>GetComInfo</c> 는 조인 10개로 금액 열까지 채우려 하지만
/// <b>그 여섯 열은 값을 받지 못해 화면에서 늘 비어 있다</b>(fmPatVisitList.vb enum 선언에만 존재).
/// 옮기지 않는다. 여기서는 어느 내원인지 사람이 고를 수 있을 만큼만 읽는다.
///
/// 기간 PK 를 가진 마스터(M_DepMst·M_UidMst·M_InsMst)는 그냥 조인하면 같은 내원이 여러 번 뜬다 —
/// <c>ROW_NUMBER() … Rn = 1</c> 로 접는다(OracleLegacyFormStore.cs:94-100 과 같은 이유).
/// </summary>
public List<VisitSummary> ListVisits(string chtNum, int max = DefaultLimit)
{
var key = (chtNum ?? string.Empty).Trim();
if (key.Length == 0)
{
return new List<VisitSummary>();
}
using var connection = Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT * FROM ("
+ " SELECT A.ComNum,"
+ " CASE WHEN A.ComPatTyp = 'I' THEN '입원' ELSE '외래' END Kind,"
+ " NVL(A.ComAcpDtm,' ') AcceptedAt, NVL(TRIM(A.ComLevDtm),' ') LeftAt,"
+ " NVL(Dep.Nam,' ') Dep, NVL(Doc.Nam,' ') Dtr, NVL(Ins.Nam,' ') Ins,"
+ " NVL(Stt.DtlCodNam,' ') Stt"
+ " FROM P_ComInf A"
+ " LEFT JOIN P_CodInf B ON A.ComNum = B.CodComNum AND B.CodMtiSeq = 0"
+ " LEFT JOIN P_CoiInf C ON A.ComNum = C.CoiComNum AND C.CoiMtiSeq = 0"
+ " LEFT JOIN (SELECT TRIM(DepCod) Cod, DepKorNam Nam,"
+ " ROW_NUMBER() OVER (PARTITION BY TRIM(DepCod) ORDER BY DepStrDte DESC) Rn"
+ " FROM M_DepMst) Dep ON Dep.Cod = TRIM(B.CodDepCod) AND Dep.Rn = 1"
+ " LEFT JOIN (SELECT TRIM(UidCod) Cod, UidNam Nam,"
+ " ROW_NUMBER() OVER (PARTITION BY TRIM(UidCod) ORDER BY UidStrDte DESC) Rn"
+ " FROM M_UidMst) Doc ON Doc.Cod = TRIM(B.CodDtrCod) AND Doc.Rn = 1"
+ " LEFT JOIN (SELECT TRIM(InsCod) Cod, InsNam Nam,"
+ " ROW_NUMBER() OVER (PARTITION BY TRIM(InsCod) ORDER BY InsStrDte DESC) Rn"
+ " FROM M_InsMst) Ins ON Ins.Cod = TRIM(C.CoiInsCod) AND Ins.Rn = 1"
+ " LEFT JOIN M_DtlMst Stt ON Stt.DtlTblCod = 'COMPRGSTT' AND Stt.DtlCod = A.ComPrgStt"
+ " WHERE TRIM(A.ComChtNum) = :c"
+ " ORDER BY A.ComAcpDtm DESC"
+ $") WHERE ROWNUM <= {max}";
command.Parameters.Add(new OracleParameter("c", key));
LastSql = command.CommandText;
var result = new List<VisitSummary>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
result.Add(new VisitSummary(
reader.GetDecimal(0),
reader.GetString(1).Trim(),
reader.GetString(2).Trim(),
reader.GetString(3).Trim(),
reader.GetString(4).Trim(),
reader.GetString(5).Trim(),
reader.GetString(6).Trim(),
reader.GetString(7).Trim()));
}
return result;
}
/// <summary>마지막으로 실행한 SQL — 진단이 예외를 진단할 때만 쓴다(문법 오류는 문장을 봐야 안다)</summary>
public static string LastSql { get; private set; } = string.Empty;
private OracleConnection Open()
{
var connection = new OracleConnection(connectionString);
connection.Open();
return connection;
}
#endregion
}