환자를 골라 태그에 값이 들어간다 — 데이터 계층만 있던 것을 화면에 붙인다
전 커밋까지 환자 조회·시각 판정·권한 규칙은 다 있었지만 화면에 붙은 것이 하나도 없었다. 미리보기를 열면 환자 선택이 없고, 태그는 전부 사유만 찍혔다. ## 만든 사슬 PatientPickerDialogView(모달) → PatientContextStore(문맥 5행) → PatientTagResolver(태그 → 컬럼) → PreviewWindow.UsePatient → 종이 모달로 둔 이유: 30초 안에 끝나는 자족적인 일이고 권한 문이 걸린 동작이다. 사이드 패널로 두면 실제 환자 정보를 꺼내는 창이 항상 열려 있게 되는데 이 창은 열려 있는 것 자체가 비용이다. 환자와 내원을 한 화면에 둔다. 고르는 대상은 환자가 아니라 내원이다 — 문맥 5행이 전부 ComNum + AdpDtm 으로 읽히므로 환자만 골라서는 아무것도 못 읽는다. 기준 시점(마지막/처음)을 화면에 노출한다. 입원 내원의 진료과·병실·자격 값을 실제로 바꾸는 값이라 숨기면 값이 왜 다른지 설명할 수 없다. 문맥 읽기를 창 안에서 한다. 닫고 나서 읽으면 실패를 미리보기 창에서 알려야 하고 그때는 이미 고른 것이 사라진 뒤다. ## 매핑은 확인된 6종만 PAT_이름 / PAT_차트번호 / PAT_주민번호 / PAT_휴대전화 / PAT_전화번호 / PAT_주소. 전부 레거시 조회 SQL 의 SELECT 목록에서 컬럼을 직접 확인한 것이다. PAT_나이·PAT_성별은 넣지 않았다 — UDF_GETAGE·UDF_GETSEX 를 거치는 값이라 컬럼을 짐작해 넣으면 값이 조용히 틀린다. 종이에 그럴듯한 값이 찍히면 아무도 의심하지 않으므로 빈칸보다 나쁘다. 안 옮긴 환자 태그는 "아직 옮기지 않았습니다"라고 말한다. "DB 접속이 없습니다"로 뭉개면 환자를 골랐는데도 그 사유가 나와 접속을 의심하게 된다. ## 문맥 조회를 한 번에 다섯 번 읽는다 레거시는 5행을 프로퍼티 접근 시점에 하나씩 지연 로딩한다(각각 별도 왕복). 태그마다 다시 읽으면 태그 40개짜리 서식에 왕복 40회가 된다. ## 개인정보 - 감사 로그는 고른 직후 남긴다 — 종이에 값이 찍히는 것과 무관하게 조회는 이미 일어났다. - 로그에 환자 이름을 남기지 않는다(내원번호로 추적 가능하고, 로그가 개인정보를 들고 있으면 로그 자체가 위험물이 된다). - 진단 리포트도 값 대신 길이만 적는다. - 권한 문은 수정(TK_MODIFY)과 같은 범위다. 넓히면 서식생성기가 환자 조회 도구가 된다. - 화면에서 감사 기록 사실을 말한다. ## 게이트가 잡은 것 셋 ① --db-patient 가 "전건 통과"인데 ⑧~⑬ 이 통째로 SKIP 이었다. 첫 환자에게 내원이 0건이라 문맥 조회를 한 번도 안 돌린 채 초록불이었다. 내원 있는 환자를 30명까지 훑고, 못 찾으면 SKIP 이 아니라 FAIL 로 바꿨다. ② 그렇게 찾은 내원에서 CodInf·CoiInf·CowInf 가 셋 다 0행이었다. 0 을 적어 두는 것으로는 "조건이 어긋났다"와 "행이 없다"를 못 가른다. 조건 없는 원본 행을 함께 찍게 하니 후자였다(1988년 내원, 행 자체가 없음). ③ 그러면 기간 조회 세 갈래는 아직 한 번도 행을 돌려준 적이 없다. 안 돌아본 갈래는 통과한 갈래가 아니므로, 각 표에서 직접 내원을 찾아 들어가 BETWEEN·MtiSeq 조건이 실제로 행을 뽑는 것까지 확인하게 했다(⑮). --edit-smoke 판정 7건 추가. 전부 대조군이 있다 — 환자 없이 같은 종이를 찍어 사유가 나오는 것을 먼저 확인한 뒤 값으로 바뀌는 것을 본다. 미리보기 창 배선도 따로 본다(붙이면 값, 떼면 사라짐). 그래서 고르는 일과 붙이는 일을 나눠 두었다 — 모달에 묻어 두면 "골랐는데 종이가 그대로"를 자동으로 못 잡는다. ## 게이트 - dotnet test 312/312 - --edit-smoke 실패 0 (환자·미리보기 판정 7건 추가) - --dialog-shots FAIL 0 (08b-patient-picker 추가) - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 (변화 없음) - --db-smoke 1,271건 diff 0 - --db-patient ①~⑮ 전건 통과 (기간 조회 3갈래 실제 통과 확인) - --db-modify-smoke S999 8/8 (⑤ 는 E_SctMst 0행으로 여전히 SKIP) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
be9889ed4e
commit
88dab2edcf
@@ -13,6 +13,18 @@ namespace SheetMe.Designer.Diagnostics;
|
||||
/// </summary>
|
||||
public static class DbSmoke
|
||||
{
|
||||
#region Nested Types
|
||||
/// <summary>
|
||||
/// 아무것도 해석하지 않는 사슬 꼬리 — 환자 해석기만 판정하려면 뒤가 조용해야 한다.
|
||||
/// 세션 해석기를 꼬리로 쓰면 시각 태그가 값이 되어 "환자 해석기가 됐다"와 섞인다.
|
||||
/// </summary>
|
||||
private sealed class NullTagResolver : SheetMe.Core.Catalog.ITagValueResolver
|
||||
{
|
||||
public SheetMe.Core.Catalog.TagValue Resolve(string tag)
|
||||
=> new(false, "해석기 없음");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>읽기 전용 일괄 왕복 스모크</summary>
|
||||
public static int RunReadSmoke(string reportPath, int maxSheets)
|
||||
@@ -1874,8 +1886,21 @@ public static class DbSmoke
|
||||
var capped = store.Search(PatientSearchKind.Name, term, max: 3);
|
||||
Check("⑤ 행 상한이 걸린다", capped.Count <= 3, $"{capped.Count}건");
|
||||
|
||||
// 내원 목록 — 검색으로 찾은 첫 환자로
|
||||
var people = store.Search(PatientSearchKind.Name, term, max: 1);
|
||||
// 내원 목록 — <b>내원이 있는</b> 환자를 찾아 들어간다.
|
||||
// 처음에는 첫 환자만 봤는데 그 환자에게 내원이 0건이라 ⑧~⑬ 이 통째로 SKIP 되고도
|
||||
// 리포트는 "전건 통과"였다. 아무것도 확인하지 않은 통과는 통과가 아니다.
|
||||
var people = store.Search(PatientSearchKind.Name, term, max: 30);
|
||||
var probed = 0;
|
||||
var visits = new List<VisitSummary>();
|
||||
foreach (var candidate in people)
|
||||
{
|
||||
probed++;
|
||||
visits = store.ListVisits(candidate.ChtNum);
|
||||
if (visits.Count > 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (people.Count == 0)
|
||||
{
|
||||
lines.Add("SKIP ⑥ 내원 목록 — 이 검색어로 환자를 못 찾아 판정 불가"
|
||||
@@ -1883,13 +1908,133 @@ public static class DbSmoke
|
||||
}
|
||||
else
|
||||
{
|
||||
var visits = store.ListVisits(people[0].ChtNum);
|
||||
lines.Add($" 환자 {probed}명을 훑어 내원 있는 환자를 찾았다(전체 {people.Count}명 중)");
|
||||
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}");
|
||||
|
||||
// ── 환자 문맥 ──
|
||||
// 여기가 이 조사의 목적이다. 위 ①~⑦ 은 목록이 뜨는지만 본다.
|
||||
// 문맥 조회의 컬럼 이름은 <b>추측으로 쓴 것</b>이라 실DB 아니면 확인할 방법이 없고,
|
||||
// 틀리면 예외가 아니라 <b>빈 값</b>이 나온다 — 화면에서는 "환자가 값이 없다"로 보인다.
|
||||
if (visits.Count == 0)
|
||||
{
|
||||
// 여기까지 오면 훑은 환자 전부가 내원 0건이다. 검색어를 바꿔 다시 돌려야 한다 —
|
||||
// 이 줄을 통과로 세면 문맥 조회를 <b>한 번도 확인하지 않은 채</b> 초록불이 된다.
|
||||
Check($"⑧~⑬ 환자 문맥 — 훑은 {probed}명 모두 내원 0건이라 확인 불가", false,
|
||||
"다른 검색어로 다시 돌릴 것");
|
||||
}
|
||||
else
|
||||
{
|
||||
var target = visits[0].ComNum;
|
||||
var contexts = new PatientContextStore(ConfigService.Current.ConnectionString);
|
||||
var context = contexts.Load(target);
|
||||
Check("⑧ 문맥 5행 조회가 예외 없이 돈다", context is not null,
|
||||
$"내원 {target:F0} 을 읽지 못했다(목록에는 있는데 다시 읽으면 없다)");
|
||||
|
||||
if (context is { } ctx)
|
||||
{
|
||||
lines.Add($" 내원 {target:F0} 적용일시={ctx.AdpDtm}"
|
||||
+ $" / PatInf {ctx.PatInf.Count}열, ComInf {ctx.ComInf.Count}열,"
|
||||
+ $" CodInf {ctx.CodInf.Count}열, CoiInf {ctx.CoiInf.Count}열, CowInf {ctx.CowInf.Count}열");
|
||||
|
||||
// 컬럼 이름을 실제로 확인한다 — 오타면 여기서 걸린다
|
||||
var missing = new[] { "ComChtNum", "ComAcpDtm", "ComPatTyp", "ComLevDtm" }
|
||||
.Where(c => !ctx.ComInf.ContainsKey(c)).ToList();
|
||||
Check("⑨ P_ComInf 컬럼 이름이 맞다", missing.Count == 0,
|
||||
$"없는 컬럼: {string.Join(", ", missing)}");
|
||||
|
||||
// 이 조인이 깨지면 환자 이름·차트번호 태그가 전부 빈다
|
||||
Check("⑩ 내원의 차트번호로 P_PatInf 를 찾는다", ctx.PatInf.Count > 0,
|
||||
$"차트번호 '{PatientContext.Value(ctx.ComInf, "ComChtNum")}' 로 환자를 못 찾았다"
|
||||
+ " (P_ComInf.ComChtNum 과 P_PatInf.PatChtNum 의 자릿수·공백 규약이 다를 수 있다)");
|
||||
|
||||
// 적용일시가 12자리가 아니면 BETWEEN 이 전부 0행이 된다
|
||||
Check("⑪ 적용일시가 12자리다", ctx.AdpDtm.Length == 12, $"'{ctx.AdpDtm}'");
|
||||
|
||||
// 기간 조회 3행. 0행은 실패가 아닐 수 있다(외래는 병실이 없다) —
|
||||
// 그래서 0을 그냥 적지 않고 <b>이유를 캔다</b>. 조건을 뺀 원본 행을 함께 찍으면
|
||||
// "그 내원에 애초에 행이 없다"와 "행은 있는데 시각·MtiSeq 조건이 어긋났다"가 갈린다.
|
||||
// 0 을 이유 없이 적어 두면 리포트가 초록불인 채로 태그가 통째로 비게 된다.
|
||||
lines.Add($" 기간 조회: CodInf {(ctx.CodInf.Count > 0 ? "있음" : "0행")},"
|
||||
+ $" CoiInf {(ctx.CoiInf.Count > 0 ? "있음" : "0행")},"
|
||||
+ $" CowInf {(ctx.CowInf.Count > 0 ? "있음" : "0행")}");
|
||||
var conn = ConfigService.Current.ConnectionString;
|
||||
if (ctx.CodInf.Count == 0)
|
||||
{
|
||||
lines.Add(" [진단] P_CodInf 조건 없는 원본 행:");
|
||||
foreach (var row in ExplainPeriod(conn, target,
|
||||
"SELECT CodMtiSeq, CodStrDtm, CodEndDtm FROM P_CodInf"
|
||||
+ " WHERE CodComNum = :k ORDER BY CodStrDtm"))
|
||||
{
|
||||
lines.Add($" {row}");
|
||||
}
|
||||
}
|
||||
if (ctx.CoiInf.Count == 0)
|
||||
{
|
||||
lines.Add(" [진단] P_CoiInf 조건 없는 원본 행:");
|
||||
foreach (var row in ExplainPeriod(conn, target,
|
||||
"SELECT CoiMtiSeq, CoiStrDte, CoiEndDte FROM P_CoiInf"
|
||||
+ " WHERE CoiComNum = :k ORDER BY CoiStrDte"))
|
||||
{
|
||||
lines.Add($" {row}");
|
||||
}
|
||||
}
|
||||
Check("⑭ CodInf 0행이면 그 이유가 원본 행으로 설명된다", true,
|
||||
"위 [진단] 줄을 읽어 시각·MtiSeq 조건을 판단할 것");
|
||||
|
||||
// ── 기간 조회 세 갈래를 <b>실제로 한 번은 통과시킨다</b> ──
|
||||
// 위 내원은 CodInf·CoiInf·CowInf 가 전부 0행이었다. 즉 이 세 SQL 의
|
||||
// BETWEEN·MtiSeq 조건은 아직 <b>한 번도 행을 돌려준 적이 없다</b>.
|
||||
// 안 돌아본 갈래는 통과한 갈래가 아니다 — 그래서 각 표에서 직접 내원을 찾아 들어간다.
|
||||
foreach (var (label, table, key, stamp) in new[]
|
||||
{
|
||||
("CodInf(진료과·담당의)", "P_CodInf", "CodComNum", "CodStrDtm"),
|
||||
("CoiInf(보험 자격)", "P_CoiInf", "CoiComNum", "CoiStrDte"),
|
||||
("CowInf(병동·병실)", "P_CowInf", "CowComNum", "CowStrDtm"),
|
||||
})
|
||||
{
|
||||
var owner = FirstComNum(conn, table, key, stamp);
|
||||
if (owner is null)
|
||||
{
|
||||
Check($"⑮ {label} 기간 조회 — {table} 에 행이 없어 확인 불가", false,
|
||||
"이 DB 로는 이 갈래를 확인할 수 없다");
|
||||
continue;
|
||||
}
|
||||
var sample = contexts.Load(owner.Value);
|
||||
var got = table switch
|
||||
{
|
||||
"P_CodInf" => sample?.CodInf.Count ?? 0,
|
||||
"P_CoiInf" => sample?.CoiInf.Count ?? 0,
|
||||
_ => sample?.CowInf.Count ?? 0,
|
||||
};
|
||||
Check($"⑮ {label} 기간 조회가 실제로 행을 돌려준다", got > 0,
|
||||
$"내원 {owner.Value:F0} 적용일시={sample?.AdpDtm ?? "(문맥 없음)"} → {got}열"
|
||||
+ " (0이면 BETWEEN 시각이나 MtiSeq 조건이 어긋난 것이다)");
|
||||
}
|
||||
|
||||
// 실제 문맥으로 태그가 값이 되는가 — 가상 문맥(--edit-smoke)과 짝이 되는 판정이다
|
||||
var resolver = new Services.PatientTagResolver(ctx, new NullTagResolver());
|
||||
var chart = resolver.Resolve("PAT_차트번호");
|
||||
var name = resolver.Resolve("PAT_이름");
|
||||
Check("⑫ 실제 문맥으로 PAT_차트번호·PAT_이름 이 값이 된다",
|
||||
chart.Resolved && name.Resolved,
|
||||
$"차트번호 {(chart.Resolved ? "값" : chart.Text)} / 이름 {(name.Resolved ? "값" : name.Text)}");
|
||||
// 값 자체는 개인정보라 리포트에 쓰지 않는다 — 길이만 적는다
|
||||
lines.Add($" 값 길이: 차트번호 {chart.Text.Length}자, 이름 {name.Text.Length}자"
|
||||
+ " (값은 개인정보라 기록하지 않는다)");
|
||||
|
||||
// 대조군 — 매핑에 없는 환자 태그는 값이 되지 않아야 한다.
|
||||
// 이게 없으면 ⑫ 는 "해석기가 아무 태그나 통과시킨다"와 구분되지 않는다.
|
||||
var unmapped = resolver.Resolve("PAT_나이");
|
||||
Check("⑬ (대조군) 안 옮긴 환자 태그는 값이 되지 않는다",
|
||||
!unmapped.Resolved && unmapped.Text.Contains("아직 옮기지", StringComparison.Ordinal),
|
||||
$"'{unmapped.Text}' (Resolved={unmapped.Resolved})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.Add(string.Empty);
|
||||
@@ -1903,6 +2048,67 @@ public static class DbSmoke
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이 표에 행을 가진 내원 하나 — 기간 조회 갈래를 실제로 통과시켜 보려면 재료가 필요하다.
|
||||
/// 최근 것을 고른다(오래된 내원은 구간이 이미 닫혀 있어 판정이 덜 대표적이다).
|
||||
/// <b>표 이름·컬럼 이름은 호출부의 리터럴만 들어온다</b> — 사용자 입력이 닿는 경로가 아니다.
|
||||
/// </summary>
|
||||
private static decimal? FirstComNum(string connectionString, string table, string key, string stamp)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
$"SELECT {key} FROM (SELECT {key} FROM {table} ORDER BY {stamp} DESC) WHERE ROWNUM = 1";
|
||||
var value = command.ExecuteScalar();
|
||||
return value is null or DBNull ? null : Convert.ToDecimal(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 기간 조회가 0행일 때 <b>왜</b> 0행인지 보여 준다 — 조건을 뺀 원본 행의 구간을 그대로 찍는다.
|
||||
/// 값이 아니라 구간만 읽으므로 개인정보를 꺼내지 않는다.
|
||||
/// </summary>
|
||||
private static List<string> ExplainPeriod(string connectionString, decimal comNum, string sql)
|
||||
{
|
||||
var rows = new List<string>();
|
||||
try
|
||||
{
|
||||
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText = sql;
|
||||
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("k", comNum));
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var cells = new string[reader.FieldCount];
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
cells[i] = $"{reader.GetName(i)}="
|
||||
+ (reader.IsDBNull(i) ? "(null)" : $"'{reader.GetValue(i).ToString()!.Trim()}'");
|
||||
}
|
||||
rows.Add(string.Join(" ", cells));
|
||||
}
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
rows.Add("(원본 행 자체가 없다 — 조건 문제가 아니라 이 내원에 그 정보가 없는 것이다)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
rows.Add($"진단 실패: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
public static int RunModifySmoke(string shtCod, string reportPath)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
|
||||
Reference in New Issue
Block a user