환자·내원 조회를 붙인다 — 레거시 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
+5
View File
@@ -158,6 +158,11 @@ public partial class App : Application
return Diagnostics.DbSmoke.RunReadSmoke(args[1], max);
}
if (args.Length >= 3 && args[0] == "--db-patient")
{
return Diagnostics.DbSmoke.RunPatientProbe(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-modify-smoke")
{
return Diagnostics.DbSmoke.RunModifySmoke(args[1], args[2]);
@@ -1825,6 +1825,84 @@ public static class DbSmoke
/// 이 진단이 만든 이력 행을 SdgKey 로 지운다. 지우는 것은 <b>방금 만든 그 행 하나</b>뿐이고
/// SdgDelYon='Y' 조건을 함께 걸어 활성 행은 어떤 경우에도 지워지지 않게 한다.
/// </summary>
/// <summary>
/// 환자·내원 조회가 <b>실제 DB 에서 도는가</b> — <c>--db-patient &lt;검색어&gt; &lt;리포트&gt;</c>.
///
/// 컴파일이 통과해도 컬럼명이 틀리면 화면에서만 터진다. 레거시 SELECT 를 그대로 옮기지 않고
/// 최소 컬럼으로 다시 썼기 때문에 <b>그 컬럼들이 실재하는지</b>를 여기서 확인한다.
///
/// 개인정보를 리포트에 남기지 않는다 — 건수와 컬럼 유무만 적고 이름·주민번호는 찍지 않는다.
/// 진단 리포트는 파일로 남고 접근 통제가 DB 보다 느슨하다.
/// </summary>
public static int RunPatientProbe(string term, string reportPath)
{
var lines = new List<string>();
var ok = true;
void Check(string name, bool pass, string? detail = null)
{
ok &= pass;
lines.Add($"{(pass ? "PASS" : "FAIL")} {name}{(pass || detail is null ? string.Empty : " " + detail)}");
}
try
{
var store = new PatientVisitStore(ConfigService.Current.ConnectionString);
// 네 갈래 SQL 이 전부 도는지 — 결과가 0건이어도 쿼리는 성공해야 한다
foreach (var kind in new[]
{
PatientSearchKind.ChartNumber, PatientSearchKind.Name,
PatientSearchKind.ResidentNumber, PatientSearchKind.MobilePhone,
})
{
var found = store.Search(kind, term);
lines.Add($" {kind,-16} {found.Count}건");
}
Check("① 검색 네 갈래가 예외 없이 돈다", true);
// 조건 없는 전 환자 조회를 막는가 — 레거시에는 이 방어가 없다
Check("② 빈 검색어는 아무것도 돌려주지 않는다",
store.Search(PatientSearchKind.Name, " ").Count == 0);
Check("③ 주민번호 1글자는 막는다",
store.Search(PatientSearchKind.ResidentNumber, "8").Count == 0);
// 따옴표가 SQL 로 새지 않는가 — 레거시는 이스케이프가 없어 그대로 주입된다
var quoted = store.Search(PatientSearchKind.Name, "'||'");
Check("④ 따옴표가 SQL 로 새지 않는다", quoted.Count == 0, $"{quoted.Count}건 (예외 없이 0건이어야 한다)");
// 행 상한이 실제로 걸리는가
var capped = store.Search(PatientSearchKind.Name, term, max: 3);
Check("⑤ 행 상한이 걸린다", capped.Count <= 3, $"{capped.Count}건");
// 내원 목록 — 검색으로 찾은 첫 환자로
var people = store.Search(PatientSearchKind.Name, term, max: 1);
if (people.Count == 0)
{
lines.Add("SKIP ⑥ 내원 목록 — 이 검색어로 환자를 못 찾아 판정 불가"
+ " (다른 검색어로 다시 돌려야 한다)");
}
else
{
var visits = store.ListVisits(people[0].ChtNum);
lines.Add($" 내원 {visits.Count}건");
Check("⑥ 내원 목록이 예외 없이 돈다", true);
// 같은 내원이 여러 번 나오면 기간 PK 마스터 조인을 안 접은 것이다
Check("⑦ 같은 내원이 중복되지 않는다",
visits.Select(v => v.ComNum).Distinct().Count() == visits.Count,
$"고유 {visits.Select(v => v.ComNum).Distinct().Count()} / 전체 {visits.Count}");
}
lines.Add(string.Empty);
lines.Add($"결과: {(ok ? " " : " ")}");
return Finish(lines, reportPath, ok ? 0 : 1);
}
catch (Exception ex)
{
lines.Add($"EXCEPTION {ex.GetType().Name}: {ex.Message}");
return Finish(lines, reportPath, 2);
}
}
public static int RunModifySmoke(string shtCod, string reportPath)
{
var lines = new List<string>();