Files
SheetMe/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
T
MsystechandClaude Opus 5 94bdcc763f 바이탈 17종 + 주치의 1종 + _Refer 사유 정정 — 76종 → 95종
## 바이탈(측정치) 17종

E_EmdInf_VITAL ⨝ E_EmrInf(삭제 제외) 를 내원으로 거르고 해당 컬럼이 빈 값이 아닌
행을 측정일시 순으로 한 건 집는다 — 무인자는 첫 측정(ASC), _LAST 는 마지막(DESC).
키/몸무게/체온/맥박/호흡/SPO2 (±LAST) · 혈압/혈압_LAST(BPS||'/'||BPD) ·
혈압_BPS/_BPD(단독값이지만 널 필터는 둘 다 — 레거시 그대로) · VITAL접수일시_LAST(HH:MM).

태그별 (식·컬럼·필터·정렬)을 전부 본문에서 확인해 표로 박았다(:11508-13046).
조회는 태그가 물을 때 한 값씩, 태그별 1회 캐시.

## 머리둘레 2종은 레거시가 고장이다

SELECT 는 EmdHc AS HC 인데 Item("BP") 를 읽는다(:11744, 12581) —
행이 있으면 예외 → MessageBox → "". 레거시에서 한 번도 값이 나온 적 없는 태그다.
그대로 빈 값 + "레거시 결함(컬럼명 불일치)으로 항상 빈 값이던 태그" 사유로 둔다.
고쳐서 값을 내면 레거시와 달라진다 — 고칠지는 별도 결정.

## 실DB 확인

--db-patient ㉙ 신규: 내원 6004487 에서 첫/마지막 몸무게 2자.
표 부재 가능성도 수술과 같은 방식으로 가른다(ORA-00942 명시).

## 게이트

- dotnet test 338/338 · --edit-smoke 실패 0(이름 검사 95종)
- --db-patient ①~㉙ 전건 통과 · --db-render P062 md5 불변

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:59:04 +09:00

2616 lines
136 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.IO;
using SheetMe.Core.Models;
using SheetMe.Core.Serialization;
using SheetMe.Data.Stores;
using SheetMe.Designer.Services;
namespace SheetMe.Designer.Diagnostics;
/// <summary>
/// 실DB 검증 진단.
/// --db-smoke &lt;리포트&gt; [N] : 읽기 전용 — 활성 디자인 N건 일괄 Read→Write 의미론 diff 통계.
/// --db-save-smoke &lt;서식코드&gt; &lt;리포트&gt; : 쓰기 — 무변경 재저장 후 재로드 비교 + E_SctMst 행 수 검증.
/// </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)
{
var lines = new List<string>();
try
{
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var sheets = store.ListSheets(null, max: 3000);
lines.Add($"E_ShtMst 서식 {sheets.Count}건, 디자인 보유 {sheets.Count(s => s.HasDesign)}건");
var targets = sheets.Where(s => s.HasDesign).Take(maxSheets).ToList();
var perfect = 0;
var withWarnings = 0;
var diffSheets = new List<string>();
var failed = new List<string>();
var placeholderTypes = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var sheet in targets)
{
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
var document = serializer.Read(raw.Value.Xml);
var rewritten = serializer.Write(document);
var diffs = XmlSemanticDiff.Compare(raw.Value.Xml, rewritten, maxDiffs: 5);
foreach (var warning in document.Meta.ReadWarnings.Where(w => w.StartsWith("미지원")))
{
var type = warning[(warning.IndexOf('(') + 1)..].TrimEnd(')');
placeholderTypes[type] = placeholderTypes.GetValueOrDefault(type) + 1;
}
if (diffs.Count == 0)
{
perfect++;
if (document.Meta.ReadWarnings.Count > 0)
{
withWarnings++;
}
}
else
{
diffSheets.Add($" {sheet.ShtCod} {sheet.Name}: {diffs[0]}");
}
}
catch (Exception ex)
{
failed.Add($" {sheet.ShtCod} {sheet.Name}: {ex.Message}");
}
}
lines.Add($"왕복 검사 {targets.Count}건 → 의미론 diff 0: {perfect}건 (미지원 컨트롤 보존 포함 {withWarnings}건), diff 발생: {diffSheets.Count}건, 예외: {failed.Count}건");
if (placeholderTypes.Count > 0)
{
lines.Add("미지원(자리표시 보존) 타입 분포: " + string.Join(", ",
placeholderTypes.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Key}×{kv.Value}")));
}
if (diffSheets.Count > 0)
{
lines.Add("[diff 발생 서식]");
lines.AddRange(diffSheets.Take(20));
}
if (failed.Count > 0)
{
lines.Add("[예외 서식]");
lines.AddRange(failed.Take(20));
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return diffSheets.Count == 0 && failed.Count == 0 ? 0 : 1;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary>
/// 권한 게이트 영향 범위(--db-gate) — ShtUsrDesYon 값별 서식 수를 집계한다.
/// 게이트 도입 전에 "갑자기 서식이 안 열린다"는 문의 규모를 미리 알기 위한 읽기 전용 진단.
/// </summary>
/// <summary>
/// 페이지 루트(MDesignerHost) 속성 전수 집계 — 페이지 속성 편집기의 기본값 근거.
///
/// 레거시 PropertyGrid 는 <b>속성이 없으면 WinForms 기본값</b>을 보여 준다(BackColor 는 Control).
/// 우리가 어떤 키를 얼마나 마주치는지, 어떤 값이 실제로 쓰이는지 알아야 기본 표시를 정할 수 있다.
/// </summary>
public static int RunPagePropsReport(string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList();
var keyCounts = new Dictionary<string, int>(StringComparer.Ordinal);
var backColors = new Dictionary<string, int>(StringComparer.Ordinal);
var fonts = new Dictionary<string, int>(StringComparer.Ordinal);
var pages = 0;
var docs = 0;
foreach (var sheet in sheets)
{
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
docs++;
foreach (var page in serializer.Read(raw.Value.Xml).Pages)
{
pages++;
foreach (var key in page.Root.Props.Keys)
{
keyCounts[key] = keyCounts.GetValueOrDefault(key) + 1;
}
var back = page.Root.Props.GetText("BackColor") ?? "(없음)";
backColors[back] = backColors.GetValueOrDefault(back) + 1;
var font = page.Root.Props.GetText("Font") ?? "(없음)";
fonts[font] = fonts.GetValueOrDefault(font) + 1;
}
}
catch (Exception ex)
{
lines.Add($" 실패 {sheet.ShtCod}: {ex.Message}");
}
}
// 속성 패널을 '여는 것만으로' 문서가 바뀌면 안 된다.
// 값이 없는 페이지에 기본값을 지어내 저장해 버리면 왕복 diff 0 이 조용히 깨진다.
var mutated = new List<string>();
var checkedDocs = 0;
foreach (var sheet in sheets)
{
if (checkedDocs >= 40)
{
break;
}
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
var document = serializer.Read(raw.Value.Xml);
// 값이 없는 페이지를 가진 서식만 본다 — 지어낸 값이 생기는지가 관심사다
if (!document.Pages.Any(p => p.Root.Props.GetText("BackColor") is null))
{
continue;
}
checkedDocs++;
var before = serializer.Write(document);
var designer = new ViewModels.DesignerViewModel(document);
designer.ActivatePage(designer.Pages[0]);
var after = serializer.Write(designer.Document);
if (!string.Equals(before, after, StringComparison.Ordinal))
{
mutated.Add($" {sheet.ShtCod} {sheet.Name}");
}
}
catch (Exception ex)
{
mutated.Add($" {sheet.ShtCod} 예외: {ex.Message}");
}
}
lines.Insert(0, mutated.Count == 0
? $"[열기 무변경] BackColor 없는 서식 {checkedDocs}건 — 속성 패널을 열어도 XML 동일"
: $"[열기 변경 발생] {mutated.Count}건 — 아래 서식은 여는 것만으로 XML 이 바뀐다");
lines.InsertRange(1, mutated);
lines.Insert(mutated.Count + 1, string.Empty);
lines.Insert(0, $"디자인 {docs}건 / 페이지 {pages}장");
lines.Add(string.Empty);
lines.Add("[페이지 루트 속성 키 분포]");
foreach (var (key, count) in keyCounts.OrderByDescending(p => p.Value))
{
lines.Add($" {key,-24} {count,6}장 ({count * 100.0 / Math.Max(1, pages):0.0}%)");
}
lines.Add(string.Empty);
lines.Add("[BackColor 값 분포]");
foreach (var (value, count) in backColors.OrderByDescending(p => p.Value))
{
lines.Add($" {value,-24} {count,6}장 ({count * 100.0 / Math.Max(1, pages):0.0}%)");
}
lines.Add(string.Empty);
lines.Add("[Font 값 분포]");
foreach (var (value, count) in fonts.OrderByDescending(p => p.Value).Take(15))
{
lines.Add($" {value,-30} {count,6}장 ({count * 100.0 / Math.Max(1, pages):0.0}%)");
}
}
catch (Exception ex)
{
lines.Add($"예외: {ex}");
}
File.WriteAllLines(reportPath, lines);
return 0;
}
/// <summary>
/// 색 속성 값 전수 집계 — 색상 행의 인라인 팔레트를 실제 사용값으로 채우기 위한 근거.
/// 레거시는 명명색(White/Window/Control…)과 "R, G, B" 를 섞어 쓴다.
/// </summary>
public static int RunColorReport(string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList();
var byKey = new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
var total = 0;
void Visit(Core.Models.ControlElement element)
{
foreach (var key in new[] { "BackColor", "ForeColor" })
{
if (element.Props.GetText(key) is not { } value || value.Length == 0)
{
continue;
}
if (!byKey.TryGetValue(key, out var bucket))
{
bucket = new Dictionary<string, int>(StringComparer.Ordinal);
byKey[key] = bucket;
}
bucket[value] = bucket.GetValueOrDefault(value) + 1;
total++;
}
foreach (var child in element.Children)
{
Visit(child);
}
}
foreach (var sheet in sheets)
{
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
foreach (var page in serializer.Read(raw.Value.Xml).Pages)
{
Visit(page.Root);
}
}
catch
{
// 색 집계는 통계용이라 개별 실패는 건너뛴다(왕복 검증은 --db-smoke 가 본다)
}
}
lines.Add($"색 속성 값 {total}건");
foreach (var (key, bucket) in byKey.OrderBy(p => p.Key, StringComparer.Ordinal))
{
lines.Add(string.Empty);
lines.Add($"[{key}] 서로 다른 값 {bucket.Count}종");
foreach (var (value, count) in bucket.OrderByDescending(p => p.Value).Take(25))
{
lines.Add($" {value,-22} {count,7}건");
}
}
}
catch (Exception ex)
{
lines.Add($"예외: {ex}");
}
File.WriteAllLines(reportPath, lines);
return 0;
}
public static int RunGateReport(string reportPath)
{
try
{
var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText =
"SELECT NVL(TRIM(s.ShtUsrDesYon),'(미설정)') AS Flag, COUNT(*) AS Total, " +
" SUM(CASE WHEN EXISTS (SELECT 1 FROM E_SdgMst d " +
" WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') " +
" THEN 1 ELSE 0 END) AS WithDesign " +
"FROM E_ShtMst s WHERE NVL(s.ShtTyp,' ') <> 'S' " +
"GROUP BY NVL(TRIM(s.ShtUsrDesYon),'(미설정)') ORDER BY 2 DESC";
var lines = new List<string> { "ShtUsrDesYon 분포 — 'Y' 와 (미설정)만 열기가 허용된다", string.Empty };
var blocked = 0m;
using var reader = command.ExecuteReader();
while (reader.Read())
{
var flag = reader.GetString(0);
var total = reader.GetDecimal(1);
var withDesign = reader.GetDecimal(2);
var allowed = flag is "Y" or "(미설정)";
if (!allowed)
{
blocked += withDesign;
}
lines.Add($" {flag,-10} 전체 {total,5}건 / 디자인 보유 {withDesign,5}건 → {(allowed ? "허용" : "차단")}");
}
lines.Add(string.Empty);
lines.Add($"게이트 도입 시 열기가 차단되는 디자인 보유 서식: {blocked}건");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>
/// 서식 목록 그룹핑 후보 분포(--db-cls) — 분류 컬럼별 값 분포를 집계한다.
///
/// 그룹 기준을 잘못 고르면 "묶였는데 오히려 못 찾는" 결과가 된다. 판정 기준 셋:
/// ① 그룹 수가 과다하지 않을 것(수십 개면 스크롤이 더 길어진다)
/// ② 한 그룹에 전부 몰리지 않을 것(그룹핑 효과 0)
/// ③ 빈 값 비율이 낮을 것(대부분이 '(미분류)'로 가면 의미가 없다)
/// 목록에 실제로 뜨는 모집단(ShtTyp≠'S')만 세고, 디자인 보유 건수를 함께 낸다.
/// </summary>
public static int RunClassReport(string reportPath)
{
var candidates = new[] { "ShtClsCod", "ShtDepCls", "ShtTyp", "ShtUsrTyp", "ShtPatTyp", "ShtWrtTyp", "ShtLstTyp" };
try
{
var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
var lines = new List<string>
{
"서식 목록 그룹핑 후보 분포 — 모집단: E_ShtMst 에서 스캔서식(ShtTyp='S') 제외",
string.Empty,
};
// 분류 코드 → 한글 라벨 마스터 실측(EmrTreeDataBusiness.cs:55 의 조인 규약 검증)
using (var command = connection.CreateCommand())
{
command.CommandText =
"SELECT TRIM(DtlCod), DtlCodNam, NVL(DtlDspSeq,'-'), NVL(DtlStrDte,'-'), NVL(DtlExpDte,'-') " +
"FROM M_DtlMst WHERE DtlTblCod = 'EMR_SHEET_GROUP' ORDER BY DtlDspSeq, DtlCod";
lines.Add("[M_DtlMst DtlTblCod='EMR_SHEET_GROUP'] — ShtClsCod 라벨 마스터 (DspSeq 순)");
using var reader = command.ExecuteReader();
while (reader.Read())
{
lines.Add($" {reader.GetString(0),-6} → {(reader.IsDBNull(1) ? "(NULL)" : reader.GetString(1)),-20} " +
$"seq={reader.GetString(2),-6} {reader.GetString(3)}~{reader.GetString(4)}");
}
lines.Add(string.Empty);
}
// 조인 행 증식 검사 — M_DtlMst 는 PK 에 DtlStrDte 가 들어가 같은 코드가 기간별 다중행일 수 있다.
// 증식하면 서식이 목록에 두 번 뜬다(사용자는 '데이터가 깨졌다'로 읽는다).
using (var command = connection.CreateCommand())
{
command.CommandText =
"SELECT TRIM(DtlCod), COUNT(*) FROM M_DtlMst WHERE DtlTblCod = 'EMR_SHEET_GROUP' " +
"GROUP BY TRIM(DtlCod) HAVING COUNT(*) > 1 ORDER BY 2 DESC";
lines.Add("[조인 증식 검사] 같은 DtlCod 가 2행 이상인 코드");
using var reader = command.ExecuteReader();
var any = false;
while (reader.Read())
{
any = true;
lines.Add($" ⚠ {reader.GetString(0)} — {reader.GetDecimal(1)}행");
}
lines.Add(any ? " → 조인에 최신행 1건 선택이 반드시 필요" : " 없음(단, 스키마상 가능하므로 방어는 유지)");
lines.Add(string.Empty);
}
// 마스터에 없는 고아 분류 코드 — 이 건수만큼 헤더가 원시 코드로 뜬다
using (var command = connection.CreateCommand())
{
command.CommandText =
"SELECT NVL(TRIM(s.ShtClsCod),'(빈값)'), COUNT(*) FROM E_ShtMst s " +
"WHERE NVL(s.ShtTyp,' ') <> 'S' AND NOT EXISTS (" +
" SELECT 1 FROM M_DtlMst d WHERE d.DtlTblCod = 'EMR_SHEET_GROUP' " +
" AND TRIM(d.DtlCod) = TRIM(s.ShtClsCod)) " +
"GROUP BY NVL(TRIM(s.ShtClsCod),'(빈값)') ORDER BY 2 DESC";
lines.Add("[고아 코드] E_ShtMst 에는 있으나 마스터에 없는 분류");
using var reader = command.ExecuteReader();
var any = false;
while (reader.Read())
{
any = true;
lines.Add($" {reader.GetString(0)} — {reader.GetDecimal(1)}건");
}
if (!any)
{
lines.Add(" 없음");
}
lines.Add(string.Empty);
}
// 폐기 서식(ShtUseYon='N')이 섞이면 그룹 헤더 건수가 부풀어 보인다
using (var command = connection.CreateCommand())
{
command.CommandText =
"SELECT NVL(TRIM(ShtTyp),'(빈값)'), NVL(TRIM(ShtUseYon),'(빈값)'), COUNT(*) " +
"FROM E_ShtMst GROUP BY NVL(TRIM(ShtTyp),'(빈값)'), NVL(TRIM(ShtUseYon),'(빈값)') ORDER BY 3 DESC";
lines.Add("[모집단 검증] ShtTyp × ShtUseYon");
using var reader = command.ExecuteReader();
while (reader.Read())
{
lines.Add($" ShtTyp={reader.GetString(0),-8} ShtUseYon={reader.GetString(1),-8} {reader.GetDecimal(2),5}건");
}
lines.Add(string.Empty);
}
foreach (var column in candidates)
{
using var command = connection.CreateCommand();
command.CommandText =
$"SELECT NVL(TRIM(s.{column}),'(빈값)') AS K, COUNT(*) AS Total, " +
" SUM(CASE WHEN EXISTS (SELECT 1 FROM E_SdgMst d " +
" WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') " +
" THEN 1 ELSE 0 END) AS WithDesign " +
"FROM E_ShtMst s WHERE NVL(s.ShtTyp,' ') <> 'S' " +
$"GROUP BY NVL(TRIM(s.{column}),'(빈값)') ORDER BY 2 DESC";
var rows = new List<(string Key, decimal Total, decimal WithDesign)>();
using (var reader = command.ExecuteReader())
{
while (reader.Read())
{
rows.Add((reader.GetString(0), reader.GetDecimal(1), reader.GetDecimal(2)));
}
}
var all = rows.Sum(r => r.Total);
var blank = rows.FirstOrDefault(r => r.Key == "(빈값)").Total;
var top = rows.Count > 0 ? rows[0] : default;
lines.Add($"=== {column} — 서로 다른 값 {rows.Count}개 / 빈값 {blank}건({(all == 0 ? 0 : blank * 100 / all)}%) / " +
$"최대 그룹 '{top.Key}' {top.Total}건 ===");
foreach (var row in rows.Take(40))
{
lines.Add($" {row.Key,-32} {row.Total,5}건 (디자인 {row.WithDesign,5})");
}
if (rows.Count > 40)
{
lines.Add($" … 이하 {rows.Count - 40}개 생략");
}
lines.Add(string.Empty);
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>
/// 테이블/컬럼 이름 검색(--db-tables &lt;패턴&gt;) — 이름을 모르는 마스터 테이블을 찾을 때.
/// 레거시 스키마에 코드 마스터가 여러 관례로 흩어져 있어(E_*/M_*), 이름 추측으로 헛도는 것을 막는다.
/// 접근 가능한 모든 스키마를 훑되 소유자를 함께 표시한다(읽기 전용).
/// </summary>
public static int RunTableSearch(string pattern, string reportPath)
{
try
{
var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
var lines = new List<string>();
using (var command = connection.CreateCommand())
{
command.BindByName = true;
command.CommandText =
"SELECT OWNER, TABLE_NAME FROM ALL_TABLES WHERE TABLE_NAME LIKE UPPER(:p) " +
"ORDER BY OWNER, TABLE_NAME";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("p", pattern));
lines.Add($"[테이블명 LIKE {pattern}]");
using var reader = command.ExecuteReader();
while (reader.Read())
{
lines.Add($" {reader.GetString(0)}.{reader.GetString(1)}");
}
}
lines.Add(string.Empty);
using (var command = connection.CreateCommand())
{
command.BindByName = true;
command.CommandText =
"SELECT OWNER, TABLE_NAME, COLUMN_NAME FROM ALL_TAB_COLUMNS WHERE COLUMN_NAME LIKE UPPER(:p) " +
"ORDER BY OWNER, TABLE_NAME, COLUMN_NAME";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("p", pattern));
lines.Add($"[컬럼명 LIKE {pattern}]");
using var reader = command.ExecuteReader();
var count = 0;
while (reader.Read() && count++ < 200)
{
lines.Add($" {reader.GetString(0)}.{reader.GetString(1)}.{reader.GetString(2)}");
}
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용)</summary>
public static int RunColumns(string tableName, string reportPath)
{
try
{
var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT COLUMN_NAME, DATA_TYPE, NVL(DATA_LENGTH,0), NULLABLE " +
"FROM USER_TAB_COLUMNS WHERE TABLE_NAME = UPPER(:t) ORDER BY COLUMN_ID";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("t", tableName));
var lines = new List<string>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
lines.Add($"{reader.GetString(0),-16} {reader.GetString(1),-10} {reader.GetDecimal(2),5} " +
$"{(reader.GetString(3) == "N" ? "NOT NULL" : "")}");
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>서식 마스터 행 주요 플래그 조회(--db-row) — 신규 등록 기본값 참조용(읽기 전용)</summary>
public static int RunRow(string shtCod, string reportPath)
{
try
{
var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT NVL(ShtTyp,'-'), NVL(ShtUseYon,'-'), NVL(ShtUsrDesYon,'-'), NVL(ShtUseSdg,'-'), NVL(ShtUseSct,'-'), " +
"NVL(ShtUseEmr,'-'), NVL(ShtEleSig,'-'), NVL(ShtEdtAth,'-'), NVL(ShtRedAth,'-'), NVL(ShtWrtTyp,'-'), " +
"NVL(ShtEdtTyp,'-'), NVL(ShtViwTyp,'-'), NVL(ShtClsCod,'-'), NVL(ShtCneYon,'-'), NVL(ShtTraSto,'-') " +
"FROM E_ShtMst WHERE ShtCod = :s";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("s", shtCod));
using var reader = command.ExecuteReader();
if (!reader.Read())
{
File.WriteAllText(reportPath, "행 없음");
return 1;
}
var names = new[] { "ShtTyp", "ShtUseYon", "ShtUsrDesYon", "ShtUseSdg", "ShtUseSct", "ShtUseEmr",
"ShtEleSig", "ShtEdtAth", "ShtRedAth", "ShtWrtTyp", "ShtEdtTyp", "ShtViwTyp", "ShtClsCod", "ShtCneYon", "ShtTraSto" };
var lines = names.Select((n, i) => $"{n}={reader.GetString(i)}");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>
/// 자동완성 팝업 동작 점검(--query-popup) — 위치와 소멸 규칙을 실제 창을 띄워 잰다.
///
/// 이 둘은 눈으로만 보면 놓친다. 팝업이 옛 문맥을 물고 남아 있거나 엉뚱한 자리에 뜨는 문제가
/// 실제로 두 번 있었다(커서 이동 시 갱신 안 됨 / 열린 채로는 Placement 재계산 안 됨).
/// </summary>
public static int RunQueryPopupCheck(string reportPath)
{
var lines = new List<string>();
var failures = 0;
void Check(string label, bool ok, string detail = "")
{
if (!ok)
{
failures++;
}
lines.Add($"{(ok ? "PASS" : "FAIL")} {label}" + (detail.Length > 0 ? $" — {detail}" : string.Empty));
}
try
{
var window = new Views.QueryEditorWindow("점검", string.Empty);
// 화면 밖에 띄운다 — 진단이 사용자 화면을 가리지 않게
window.WindowStartupLocation = System.Windows.WindowStartupLocation.Manual;
window.Left = -10000;
window.Top = -10000;
window.Show();
window.UpdateLayout();
var box = (System.Windows.Controls.TextBox)window.FindName("SqlBox")!;
var popup = (System.Windows.Controls.Primitives.Popup)window.FindName("CompletionPopup")!;
void Type(string text, int? caret = null)
{
box.Text = text;
box.CaretIndex = caret ?? text.Length;
// Text 를 프로그램으로 넣으면 TextChanged 시점의 커서가 아직 옛 자리다 —
// 사용자가 친 것과 같은 상태로 맞춘 뒤 갱신을 부른다
window.RefreshCompletionForSmoke();
DrainDispatcher();
}
// ① FROM 뒤에서 테이블 목록이 뜬다
Type("SELECT * FROM P_");
Check("FROM 뒤 목록이 열린다", popup.IsOpen);
var firstOffset = (popup.HorizontalOffset, popup.VerticalOffset);
// ② 팝업은 완성 중인 낱말의 시작에 붙는다 — 캐럿을 따라 오른쪽으로 흐르면 안 된다
Type("SELECT * FROM P_PATINF");
Check("같은 낱말을 이어 쳐도 기준점이 그대로",
Math.Abs(popup.HorizontalOffset - firstOffset.HorizontalOffset) < 0.5,
$"{firstOffset.HorizontalOffset:F1} → {popup.HorizontalOffset:F1}");
// ③ 줄이 바뀌면 세로 위치도 따라간다
Type("SELECT * FROM P_PATINF\nWHERE PAT");
Check("다음 줄에서는 아래로 내려간다",
popup.VerticalOffset > firstOffset.VerticalOffset,
$"{firstOffset.VerticalOffset:F1} → {popup.VerticalOffset:F1}");
// ④ 커서만 옮기면 닫힌다 — 옛 문맥의 목록이 남아 있으면 안 된다
var wasOpen = popup.IsOpen;
box.CaretIndex = 3;
DrainDispatcher();
Check("커서를 옮기면 닫힌다", wasOpen && !popup.IsOpen);
// ⑤ 범위를 벗어난 좌표에서도 화면 안에 있어야 한다
Type(new string('\n', 200) + "SELECT * FROM P_");
// 목록이 편집 영역 밖에 뜨면 화면 구석에 떠 있는 것처럼 보인다
Check("긴 문서에서도 편집 영역 안에 뜬다",
!double.IsNaN(popup.VerticalOffset) && !double.IsInfinity(popup.VerticalOffset)
&& popup.VerticalOffset >= 0 && popup.VerticalOffset <= box.ActualHeight
&& popup.HorizontalOffset >= 0 && popup.HorizontalOffset <= box.ActualWidth,
$"세로 {popup.VerticalOffset:F1} / 편집 높이 {box.ActualHeight:F1}");
// ⑥ 포커스를 잃으면 닫힌다
Type("SELECT * FROM P_");
var openBeforeBlur = popup.IsOpen;
System.Windows.Input.Keyboard.ClearFocus();
box.RaiseEvent(new System.Windows.RoutedEventArgs(System.Windows.UIElement.LostFocusEvent));
DrainDispatcher();
Check("포커스를 잃으면 닫힌다", openBeforeBlur && !popup.IsOpen,
$"blur 전 열림={openBeforeBlur}, blur 후 열림={popup.IsOpen}");
// ⑦ 바깥 클릭으로 닫히는 설정인지
Check("항목 클릭이 캡처에 먹히지 않는 설정(StaysOpen=True)", popup.StaysOpen);
// ⑦-2 목록 항목을 집으면 실제로 입력돼야 한다.
// 예전에는 클릭이 포커스를 가져가 LostFocus 가 팝업을 닫아 버려 아무것도 안 들어갔다.
// 마우스 라우팅 자체는 화면 없이 흉내 낼 수 없어(Ctrl+Enter 때와 같은 이유)
// 핸들러가 부르는 그 경로(PickCompletionForSmoke)를 태워 삽입까지 확인한다.
var list = (System.Windows.Controls.ListBox)window.FindName("CompletionList")!;
Type("SELECT * FROM E_Sht");
if (popup.IsOpen && list.Items.Count > 0)
{
var before = box.Text;
list.UpdateLayout();
var picked = window.PickCompletionForSmoke(0);
DrainDispatcher();
Check("목록 항목을 집으면 입력되고 목록이 닫힌다",
picked && box.Text.Length > before.Length && !popup.IsOpen,
$"'{before}' → '{box.Text}'");
}
else
{
Check("목록 항목을 집으면 입력되고 목록이 닫힌다", false, "검사 전에 목록이 열리지 않았다");
}
// ⑧ 색칠 레이어와 실제 글자 위치가 맞는지 — 한글이 섞이면 폰트 대체 때문에 어긋났었다.
// 레이어는 TextBox 에게 좌표를 물으므로, 같은 인덱스의 사각형이 곧 그리는 자리다.
// 여기서는 '캐럿이 글자 끝에 있을 때 그 x 가 앞 글자들 폭의 합과 단조 증가하는가'를 본다.
Type("SELECT ㄴㅁㅇㄹ FROM ㅎㄱ");
var xs = new List<double>();
for (var i = 0; i <= box.Text.Length; i++)
{
var r = box.GetRectFromCharacterIndex(i);
xs.Add(r.IsEmpty ? double.NaN : r.X);
}
var monotonic = true;
for (var i = 1; i < xs.Count; i++)
{
if (!double.IsNaN(xs[i]) && !double.IsNaN(xs[i - 1]) && xs[i] < xs[i - 1] - 0.01)
{
monotonic = false;
break;
}
}
Check("한글 섞인 줄에서 글자 좌표가 뒤로 밀리지 않는다", monotonic,
$"첫 x {xs[0]:F1} → 끝 x {xs[^1]:F1}");
// 마지막 글자의 오른쪽 끝이 캐럿 자리와 같아야 한다(레이어가 이 값으로 그린다)
var lastCharRect = box.GetRectFromCharacterIndex(box.Text.Length - 1);
var caretRect = box.GetRectFromCharacterIndex(box.Text.Length);
Check("마지막 글자 다음 좌표가 캐럿 자리와 이어진다",
!lastCharRect.IsEmpty && !caretRect.IsEmpty && caretRect.X >= lastCharRect.X,
$"글자 {lastCharRect.X:F1} / 캐럿 {caretRect.X:F1}");
// ⑨ 검증 실행 결과가 표로 실제로 채워지는가.
// 요약만 보고 넘어가면 '컬럼 2개, 표본 3행'이라 적혀 있는데 표는 비어 있는 상태를 놓친다
// (ExpandoObject 를 넘기면 DataGrid 가 열을 못 만든다).
var grid = (System.Windows.Controls.DataGrid)window.FindName("TrialGrid")!;
var pane = (System.Windows.FrameworkElement)window.FindName("TrialPane")!;
Type("SELECT ShtCod, ShtKorNam FROM E_ShtMst WHERE ROWNUM <= 3");
window.RunTrialForSmoke();
DrainDispatcher();
grid.UpdateLayout();
Check("검증 결과 창이 열린다", pane.Visibility == System.Windows.Visibility.Visible);
Check("결과 표에 열이 생긴다", grid.Columns.Count >= 2, $"열 {grid.Columns.Count}개");
Check("결과 표에 행이 채워진다", grid.Items.Count >= 1, $"행 {grid.Items.Count}개");
// ⑨-b 치환 변수를 값 자리에 쓴 쿼리가 검증 실행을 <b>통과할 수 있는가</b>.
// 전에는 변수를 빈 문자열로 지워 "WHERE X = " 가 되었고 ORA-00936 이 났다 —
// 즉 이 기능이 존재하는 이유인 쿼리들이 하나도 검사되지 못했다.
var summary = (System.Windows.Controls.TextBlock)window.FindName("TrialSummary")!;
Type("select * from P_PatInf"
+ " where PatChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>");
window.RunTrialForSmoke();
DrainDispatcher();
Check("⑨-b 치환 변수를 값 자리에 써도 구문 검사가 돈다",
!summary.Text.Contains("ORA-00936", StringComparison.Ordinal),
summary.Text);
Check("⑨-b 변수 자리가 비지 않는다(NULL 로 채운다)",
window.LastTrialSql.Contains("NULL", StringComparison.Ordinal)
&& !window.LastTrialSql.TrimEnd().EndsWith("=", StringComparison.Ordinal),
window.LastTrialSql);
// 무엇으로 돌렸는지 말해야 한다 — 0행을 보고 "이 환자에게 자료가 없다"로 잘못 읽는다
Check("⑨-b 무엇으로 돌렸는지 요약이 밝힌다",
summary.Text.Contains("NULL", StringComparison.Ordinal)
|| summary.Text.Contains("환자", StringComparison.Ordinal),
summary.Text);
// 따옴표 밖 변수는 이 배선의 가장 흔한 실수다 — 값이 원문 그대로 박히기 때문이다
Check("⑨-b 따옴표 밖 변수를 짚어 준다",
summary.Text.Contains("따옴표", StringComparison.Ordinal), summary.Text);
// 대조군 — 따옴표로 감싼 같은 쿼리에는 그 경고가 없어야 한다.
// 없으면 위 판정은 "항상 경고한다"와 구분되지 않는다.
Type("select * from P_PatInf"
+ " where PatChtNum = '<<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>'");
window.RunTrialForSmoke();
DrainDispatcher();
Check("⑨-b (대조군) 따옴표로 감싸면 그 경고가 없다",
!summary.Text.Contains("따옴표", StringComparison.Ordinal), summary.Text);
// ⑩ 검증 실행 단축키가 실제로 배선돼 있는가.
// 합성 키 이벤트로는 Keyboard.Modifiers 가 잡히지 않으므로(실제 키 상태를 읽는다)
// 선언된 KeyBinding 을 찾아 그 명령을 직접 실행해 확인한다.
var bindings = window.InputBindings.OfType<System.Windows.Input.KeyBinding>().ToList();
var ctrlEnter = bindings.FirstOrDefault(b =>
b.Key == System.Windows.Input.Key.Return
&& b.Modifiers == System.Windows.Input.ModifierKeys.Control);
var f5 = bindings.FirstOrDefault(b => b.Key == System.Windows.Input.Key.F5);
Check("Ctrl+Enter 단축키가 등록돼 있다", ctrlEnter is not null);
Check("F5 단축키도 남아 있다", f5 is not null);
if (ctrlEnter?.Command is { } trialCommand)
{
pane.Visibility = System.Windows.Visibility.Collapsed;
Type("SELECT 1 FROM DUAL WHERE 1=1 AND SEL");
var popupWasOpen = popup.IsOpen;
trialCommand.Execute(null);
DrainDispatcher();
Check("Ctrl+Enter 로 검증이 실행된다", pane.Visibility == System.Windows.Visibility.Visible);
// 목록이 떠 있어도 검증이 돈다 — Enter(목록 확정)와 뜻이 갈려야 한다
Check("실행하면서 자동완성 목록을 닫는다", popupWasOpen && !popup.IsOpen);
}
window.Close();
DrainDispatcher();
}
catch (Exception ex)
{
failures++;
lines.Add("예외: " + ex);
}
lines.Add(string.Empty);
lines.Add($"결과: 실패 {failures}건");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return failures == 0 ? 0 : 1;
}
/// <summary>대기 중인 디스패처 작업을 비운다 — 팝업 갱신이 Input 우선순위로 예약된다</summary>
private static void DrainDispatcher()
{
var frame = new System.Windows.Threading.DispatcherFrame();
System.Windows.Threading.Dispatcher.CurrentDispatcher.BeginInvoke(
System.Windows.Threading.DispatcherPriority.Background,
new Action(() => frame.Continue = false));
System.Windows.Threading.Dispatcher.PushFrame(frame);
}
/// <summary>
/// 쿼리 검증 실행 점검(--db-trial) — 편집기의 '검증 실행'이 실제 DB 에서 무엇을 돌려주는지.
///
/// 이 기능은 오타를 배포 전에 잡으라고 만든 것이라, 정상 쿼리는 통과하고
/// 틀린 쿼리는 <b>사람이 읽을 수 있는 이유</b>와 함께 실패해야 한다.
/// 조회 외 문장을 막는 안전 장치도 함께 검사한다 — 운영 DB 에 붙을 수 있는 도구다.
/// </summary>
public static int RunQueryTrial(string reportPath)
{
var lines = new List<string>();
try
{
var workbench = new SheetMe.Data.Stores.OracleQueryWorkbench(ConfigService.Current.ConnectionString);
lines.Add($"DB 사용 가능: {workbench.CanUseDb}");
var cases = new (string Label, string Sql, bool ExpectOk)[]
{
("정상 조회", "SELECT ShtCod, ShtKorNam FROM E_ShtMst WHERE ROWNUM <= 3", true),
("없는 테이블", "SELECT * FROM 없는테이블_XYZ", false),
("없는 컬럼", "SELECT 없는컬럼_XYZ FROM E_ShtMst", false),
("구문 오류", "SELECT FROM WHERE", false),
("앞 주석 뒤 정상", "-- 설명\nSELECT 1 FROM DUAL", true),
("조회 아님(INSERT)", "INSERT INTO E_ShtMst (ShtCod) VALUES ('X')", false),
("조회 아님(DELETE)", "DELETE FROM E_ShtMst", false),
("조회 아님(DROP)", "DROP TABLE E_ShtMst", false),
("주석으로 가린 DELETE", "/* SELECT */ DELETE FROM E_ShtMst", false),
("빈 쿼리", " ", false),
};
var failures = 0;
foreach (var (label, sql, expectOk) in cases)
{
var result = workbench.Trial(sql);
var ok = result.Ok == expectOk;
if (!ok)
{
failures++;
}
lines.Add($"{(ok ? "PASS" : "FAIL")} {label,-18} → " +
(result.Ok ? result.Summary : "실패: " + result.Error));
}
var tables = workbench.ListTables();
var columns = workbench.ListColumns("E_ShtMst");
lines.Add(string.Empty);
lines.Add($"자동완성 원천 — 테이블·뷰 {tables.Count}건, E_ShtMst 컬럼 {columns.Count}건");
lines.Add(" 표본 테이블: " + string.Join(", ", tables.Take(8).Select(t => t.Name)));
lines.Add(" 표본 컬럼: " + string.Join(", ", columns.Take(10)));
lines.Add(string.Empty);
lines.Add($"결과: 실패 {failures}건");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return failures == 0 ? 0 : 1;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary>
/// 데이터·액션 태그 실사용 분포(--db-tags) — 태그 피커를 어떻게 정렬·큐레이션할지 근거(읽기 전용).
///
/// 카탈로그는 수백 종인데 목록이 가나다순이면 자주 쓰는 태그가 어디 있는지 알 수 없다.
/// 상위 몇 개가 전체의 몇 %를 덮는지 알아야 '자주 쓰는 것 먼저' 가 의미 있는지 판단할 수 있다.
/// 타입별로도 나눈다 — 이미지에 붙는 태그와 텍스트박스에 붙는 태그는 겹치지 않는다.
/// </summary>
public static int RunTagUsageReport(string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList();
var byKey = new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
// 태그 → 이 태그를 쓰는 컨트롤 타입 분포
var byType = new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
// 한 서식이 쓰는 서로 다른 태그 수 — 피커를 몇 번 여는지의 대리 지표
var perSheet = new List<int>();
foreach (var sheet in sheets)
{
var sheetTags = new HashSet<string>(StringComparer.Ordinal);
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
void Visit(Core.Models.ControlElement element)
{
foreach (var key in new[] { "DataInterfaceTag", "DataActionTag" })
{
var value = element.Props.GetText(key);
// "None" 은 액션 없음의 기본값이라 사용으로 치지 않는다
if (value is null || value.Length == 0 || value == "None")
{
continue;
}
if (!byKey.TryGetValue(key, out var bucket))
{
bucket = new Dictionary<string, int>(StringComparer.Ordinal);
byKey[key] = bucket;
}
bucket[value] = bucket.GetValueOrDefault(value) + 1;
sheetTags.Add(key + "/" + value);
var typeKey = key + "|" + element.Type;
if (!byType.TryGetValue(typeKey, out var types))
{
types = new Dictionary<string, int>(StringComparer.Ordinal);
byType[typeKey] = types;
}
types[value] = types.GetValueOrDefault(value) + 1;
}
foreach (var child in element.Children)
{
Visit(child);
}
}
foreach (var page in serializer.Read(raw.Value.Xml).Pages)
{
Visit(page.Root);
}
if (sheetTags.Count > 0)
{
perSheet.Add(sheetTags.Count);
}
}
catch
{
// 통계용
}
}
foreach (var (key, bucket) in byKey.OrderBy(p => p.Key, StringComparer.Ordinal))
{
var total = bucket.Values.Sum();
var ranked = bucket.OrderByDescending(p => p.Value).ToList();
lines.Add(string.Empty);
lines.Add($"[{key}] 사용 {total}건 / 서로 다른 값 {bucket.Count}종");
foreach (var n in new[] { 5, 10, 20, 30, 50 })
{
var covered = ranked.Take(n).Sum(p => p.Value);
lines.Add($" 상위 {n,2}종이 전체의 {100.0 * covered / Math.Max(1, total),5:F1}% 를 덮는다");
}
lines.Add(" ── 상위 30 ──");
var rank = 0;
foreach (var (value, count) in ranked.Take(30))
{
rank++;
lines.Add($" {rank,2}. {value,-34} {count,6}건 ({100.0 * count / total,4:F1}%)");
}
// 카탈로그에 없는 값 = 사이트가 추가했거나 오타
var catalog = key == "DataInterfaceTag"
? (IReadOnlyList<string>)Core.Catalog.LegacyTagCatalog.DataInterfaceTags
: Core.Catalog.LegacyTagCatalog.DataActionTags;
var unknown = ranked.Where(p => !catalog.Contains(p.Key)).ToList();
lines.Add($" 카탈로그에 없는 값: {unknown.Count}종 / {unknown.Sum(p => p.Value)}건");
lines.AddRange(unknown.Take(10).Select(p => $" {p.Key} ×{p.Value}"));
// 카탈로그 생성용 전량 덤프 — 피커가 건수를 보여 주려면 상위 30종으로는 부족하다
lines.Add($" ══ 전량({key}) ══");
lines.AddRange(ranked.Where(p => catalog.Contains(p.Key)).Select(p => $" §{p.Key}\t{p.Value}"));
}
if (perSheet.Count > 0)
{
perSheet.Sort();
lines.Add(string.Empty);
lines.Add($"태그를 쓰는 서식 {perSheet.Count}건 — 한 서식이 쓰는 서로 다른 태그 수: " +
$"중앙값 {perSheet[perSheet.Count / 2]}, 최대 {perSheet[^1]}, 평균 {perSheet.Average():F1}");
}
lines.Add(string.Empty);
lines.Add("[컨트롤 타입별 상위 태그] — 타입을 알면 후보를 좁힐 수 있는지 확인용");
foreach (var (typeKey, values) in byType
.Where(p => p.Value.Values.Sum() >= 20)
.OrderByDescending(p => p.Value.Values.Sum()))
{
var ranked = values.OrderByDescending(p => p.Value).ToList();
var sum = values.Values.Sum();
var top16 = ranked.Take(16).Sum(p => p.Value);
lines.Add(string.Empty);
lines.Add($" {typeKey} {sum}건 / {ranked.Count}종 — 상위 16종이 {100.0 * top16 / sum:F1}%");
foreach (var (value, count) in ranked.Take(16))
{
lines.Add($" {value,-34} {count,5}");
}
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary>
/// 3단계 판단용 실측(--db-phase3) — 목록·정렬 관련 컬럼이 실제로 채워져 있는지(읽기 전용).
///
/// 레거시에는 있는데 우리에게 없는 기능 중 어느 것이 실제로 업무를 막는지는
/// 코드만 봐서는 알 수 없다. 컬럼이 비어 있으면 그 기능은 이 병원에서 안 쓰던 것이고,
/// 채워져 있으면 이식하지 않는 순간 목록이 달라진다.
/// </summary>
public static int RunPhase3Report(string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
void Distribution(string title, string sql)
{
lines.Add(string.Empty);
lines.Add(title);
try
{
using var command = connection.CreateCommand();
command.CommandText = sql;
using var reader = command.ExecuteReader();
var any = false;
var rank = 0;
while (reader.Read() && rank++ < 12)
{
any = true;
var parts = new List<string>();
for (var i = 0; i < reader.FieldCount; i++)
{
parts.Add(reader.IsDBNull(i) ? "(null)" : reader.GetValue(i).ToString() ?? string.Empty);
}
lines.Add(" " + string.Join(" ", parts));
}
if (!any)
{
lines.Add(" (행 없음)");
}
}
catch (Exception ex)
{
lines.Add(" 조회 실패: " + ex.Message);
}
}
// ① TK_MODIFY(제자리 정정) 대체 필요성 — ShtCneYon='Y' 서식이 얼마나 되는가
Distribution("[ShtCneYon] 제자리 갱신 서식 수 — 'Y' 가 많으면 TK_MODIFY 부재가 곧 업무 차단이다",
"SELECT NVL(ShtCneYon,'(null)') v, COUNT(*) c FROM E_ShtMst GROUP BY NVL(ShtCneYon,'(null)') ORDER BY COUNT(*) DESC");
Distribution("[ShtCneYon='Y' 중 사용 중] 실제로 현역인 것만",
"SELECT COUNT(*) FROM E_ShtMst WHERE ShtCneYon = 'Y' AND ShtUseYon = 'Y' AND NVL(ShtHspYon,' ') = 'Y'");
// ② 서식 목록의 진료과 2단계 그룹 — ShtDepCls 가 채워져 있는가
Distribution("[ShtDepCls] 진료과 분류 — 비어 있으면 2단계 그룹 이식은 무의미하다",
"SELECT NVL(ShtDepCls,'(null)') v, COUNT(*) c FROM E_ShtMst GROUP BY NVL(ShtDepCls,'(null)') ORDER BY COUNT(*) DESC");
// ③ 병원별 정렬 설정 — EMRShtSortTyp / ShtDspSeq
Distribution("[ShtDspSeq] 표시 순번 — 0/null 뿐이면 정렬 설정은 안 쓰이는 것이다",
"SELECT CASE WHEN ShtDspSeq IS NULL THEN '(null)' WHEN ShtDspSeq = 0 THEN '0' ELSE '1 이상' END v, " +
"COUNT(*) c FROM E_ShtMst GROUP BY CASE WHEN ShtDspSeq IS NULL THEN '(null)' WHEN ShtDspSeq = 0 THEN '0' ELSE '1 이상' END " +
"ORDER BY COUNT(*) DESC");
Distribution("[EMRShtSortTyp] 병원 환경설정의 정렬 방식",
"SELECT EnvCod, EnvVal FROM M_EnvMst WHERE UPPER(EnvCod) LIKE '%SHTSORT%' OR UPPER(EnvCod) LIKE '%EMRSHT%'");
// ④ 스캔형 동의서 — 사이트 조건이 걸리는 대상이 실제로 있는가
Distribution("[ShtTyp='S'] 스캔형 서식 수 — 사이트 조건 이식이 필요한 모집단",
"SELECT NVL(ShtTyp,'(null)') v, COUNT(*) c FROM E_ShtMst GROUP BY NVL(ShtTyp,'(null)') ORDER BY COUNT(*) DESC");
Distribution("[스캔형 중 현역]",
"SELECT COUNT(*) FROM E_ShtMst WHERE ShtTyp = 'S' AND ShtUseYon = 'Y' AND NVL(ShtHspYon,' ') = 'Y'");
// ⑤ 이 DB 가 어느 병원인지 — 사이트 조건 판단의 출발점
Distribution("[병원 식별] M_EnvMst 의 병원명/코드 계열 설정",
"SELECT EnvCod, EnvVal FROM M_EnvMst WHERE UPPER(EnvCod) LIKE '%HSP%' OR UPPER(EnvCod) LIKE '%HOSP%'");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
lines.Add("실패: " + ex.Message);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary>
/// DataTableField / DataTableItms 값 형태 분포(--db-dtf) — 전용 편집기가 어떤 형태를 읽어야 하는지(읽기 전용).
///
/// 레거시 편집기가 만드는 정본은 <c>{Name}.Select("{필터}")({행}).Item("{필드}")</c> 한 가지지만,
/// 손으로 쓴 <c>{Name}.Rows({행}).Item("{필드}")</c> 형이 섞여 있다. 어느 쪽이 얼마나 되는지 모르면
/// 편집기가 기존 값을 못 읽어 사용자가 열 때마다 백지에서 다시 조립하게 된다.
/// 파서 제약도 함께 센다 — 필터에 큰따옴표가 들어가면 런타임이 조용히 어긋난다.
/// </summary>
public static int RunDataTableFieldReport(string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList();
var selectForm = 0;
var rowsForm = 0;
var otherForm = new Dictionary<string, int>(StringComparer.Ordinal);
var empty = 0;
var quoteInFilter = new List<string>();
var samples = new List<string>();
var itms = new Dictionary<string, int>(StringComparer.Ordinal);
// 첫 토큰이 그 서식의 MDataTable 이름과 대소문자까지 맞는지 — 어긋나면 런타임이 무음 실패한다
var unresolved = new List<string>();
foreach (var sheet in sheets)
{
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
var document = serializer.Read(raw.Value.Xml);
var tableNames = new HashSet<string>(StringComparer.Ordinal);
void CollectTables(Core.Models.ControlElement element)
{
if (string.Equals(element.Type, "DataTable", StringComparison.Ordinal))
{
tableNames.Add(element.Id);
}
foreach (var child in element.Children)
{
CollectTables(child);
}
}
void Visit(Core.Models.ControlElement element)
{
var value = element.Props.GetText("DataTableField");
if (value is not null)
{
var trimmed = value.Trim();
if (trimmed.Length == 0)
{
empty++;
}
else if (trimmed.Contains(".Select(", StringComparison.Ordinal))
{
selectForm++;
if (samples.Count < 12)
{
samples.Add($" {sheet.ShtCod} {element.Id}: {trimmed}");
}
// C6 — 필터 안의 큰따옴표. Split(""")(") 이 앞쪽에서 잘려 전부 어긋난다
var afterSelect = trimmed.Split(".Select(\"", 2, StringSplitOptions.None);
if (afterSelect.Length == 2)
{
var filter = afterSelect[1].Split("\")(", 2, StringSplitOptions.None)[0];
if (filter.Contains('"'))
{
quoteInFilter.Add($" {sheet.ShtCod} {element.Id}: {trimmed}");
}
}
}
else if (trimmed.Contains(".Rows(", StringComparison.Ordinal))
{
rowsForm++;
if (samples.Count < 12)
{
samples.Add($" {sheet.ShtCod} {element.Id}: {trimmed}");
}
}
else
{
otherForm[trimmed] = otherForm.GetValueOrDefault(trimmed) + 1;
}
if (trimmed.Length > 0)
{
var head = trimmed.Split('.')[0];
if (head.Length > 0 && !tableNames.Contains(head) && unresolved.Count < 20)
{
unresolved.Add($" {sheet.ShtCod} {element.Id}: '{head}' — 이 서식의 데이터소스: " +
(tableNames.Count == 0 ? "(없음)" : string.Join(",", tableNames)));
}
}
}
if (element.Props.GetText("DataTableItms") is { } items && items.Trim().Length > 0)
{
var kind = items.Contains(".Select(", StringComparison.Ordinal) ? "Select형"
: items.Contains(".Rows(", StringComparison.Ordinal) ? "Rows형(콤보 안 채워짐)"
: "기타";
itms[kind] = itms.GetValueOrDefault(kind) + 1;
}
foreach (var child in element.Children)
{
Visit(child);
}
}
foreach (var page in document.Pages)
{
CollectTables(page.Root);
}
foreach (var page in document.Pages)
{
Visit(page.Root);
}
}
catch
{
// 통계용
}
}
lines.Add($"DataTableField — Select형 {selectForm}건 / Rows형 {rowsForm}건 / 기타 {otherForm.Values.Sum()}건 / 빈값 {empty}건");
if (otherForm.Count > 0)
{
lines.Add("[기타 형태]");
lines.AddRange(otherForm.OrderByDescending(p => p.Value).Take(10).Select(p => $" {p.Key} ×{p.Value}"));
}
lines.Add($"필터에 큰따옴표가 든 값(런타임 무음 실패): {quoteInFilter.Count}건");
lines.AddRange(quoteInFilter.Take(10));
lines.Add($"첫 토큰이 같은 서식의 데이터소스 이름과 안 맞는 값: {unresolved.Count}건");
lines.AddRange(unresolved.Take(10));
lines.Add("DataTableItms 형태: " + (itms.Count == 0 ? "(없음)"
: string.Join(", ", itms.OrderByDescending(p => p.Value).Select(p => $"{p.Key}×{p.Value}"))));
lines.Add("[표본]");
lines.AddRange(samples);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary>
/// 선(MLine) 방향 일치 검사(--db-lines) — 디자이너 렌더를 바꿔야 하는지 판단하는 근거(읽기 전용).
///
/// 우리 캔버스는 경계 사각형으로 선을 그리고, EMR 런타임은 Orientation 속성으로 그린다
/// (MLine.vb: Horizontal 이면 (0,0)→(Width,0), Vertical 이면 (0,0)→(0,Height)).
/// 두 규칙이 운영 데이터에서 실제로 어긋나는 건수를 세면,
/// 렌더를 고쳐야 하는지 / 속성만 노출하면 되는지가 사실로 갈린다.
/// </summary>
public static int RunLineReport(string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList();
var total = 0;
var hasOrientation = 0;
// 경계 모양(가로로 길다/세로로 길다)과 Orientation 이 어긋나는 건수
var lookVerticalDrawHorizontal = new List<string>(); // 화면엔 세로, EMR 은 가로(=1px 점) → 사라짐
var lookHorizontalDrawVertical = new List<string>();
var square = 0;
void Visit(Core.Models.ControlElement element, string sheetCod)
{
if (string.Equals(element.Type, "Line", StringComparison.Ordinal))
{
total++;
var orientation = element.Props.GetText("Orientation");
if (orientation is not null)
{
hasOrientation++;
}
var vertical = string.Equals(orientation, "Vertical", StringComparison.OrdinalIgnoreCase);
var w = element.Bounds.W;
var h = element.Bounds.H;
if (Math.Abs(w - h) < 0.5)
{
square++;
}
else if (h > w && !vertical)
{
lookVerticalDrawHorizontal.Add($" {sheetCod} {element.Id} {w}×{h} Orientation={orientation ?? "(없음)"}");
}
else if (w > h && vertical)
{
lookHorizontalDrawVertical.Add($" {sheetCod} {element.Id} {w}×{h} Orientation=Vertical");
}
}
foreach (var child in element.Children)
{
Visit(child, sheetCod);
}
}
foreach (var sheet in sheets)
{
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
foreach (var page in serializer.Read(raw.Value.Xml).Pages)
{
Visit(page.Root, sheet.ShtCod);
}
}
catch
{
// 통계용
}
}
lines.Add($"선 {total}개, Orientation 보유 {hasOrientation}개 ({100.0 * hasOrientation / Math.Max(1, total):F1}%), 정사각 {square}개");
lines.Add($"모양은 세로인데 EMR 은 가로로 그림(사라짐): {lookVerticalDrawHorizontal.Count}개");
lines.AddRange(lookVerticalDrawHorizontal.Take(30));
lines.Add($"모양은 가로인데 EMR 은 세로로 그림: {lookHorizontalDrawVertical.Count}개");
lines.AddRange(lookHorizontalDrawVertical.Take(30));
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary>
/// 타입별 속성 사용 실태 전수 집계(--db-props) — 속성 큐레이션의 근거(읽기 전용).
///
/// 어떤 키를 인스펙터에 올릴지는 취향이 아니라 실사용 빈도로 정해야 한다.
/// 함께 '기술자에 없는 키'를 표시해, 운영에서 쓰는데 우리 화면에서 편집할 수 없는 것을 드러낸다.
/// </summary>
public static int RunPropUsageReport(string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList();
// 타입 → 인스턴스 수
var typeCounts = new Dictionary<string, int>(StringComparer.Ordinal);
// 타입 → 키 → 보유 인스턴스 수
var keyCounts = new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
// 타입 → 키 → 값 → 건수
var valueCounts = new Dictionary<string, Dictionary<string, Dictionary<string, int>>>(StringComparer.Ordinal);
void Visit(Core.Models.ControlElement element)
{
var type = element.Type;
typeCounts[type] = typeCounts.GetValueOrDefault(type) + 1;
if (!keyCounts.TryGetValue(type, out var keys))
{
keys = new Dictionary<string, int>(StringComparer.Ordinal);
keyCounts[type] = keys;
valueCounts[type] = new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
}
foreach (var key in element.Props.Keys)
{
keys[key] = keys.GetValueOrDefault(key) + 1;
// 값 분포는 짧은 값만 — 수식·쿼리·이미지는 집계 의미가 없다
var value = element.Props.GetText(key);
if (value is not null && value.Length <= 24)
{
if (!valueCounts[type].TryGetValue(key, out var bucket))
{
bucket = new Dictionary<string, int>(StringComparer.Ordinal);
valueCounts[type][key] = bucket;
}
bucket[value] = bucket.GetValueOrDefault(value) + 1;
}
}
foreach (var child in element.Children)
{
Visit(child);
}
}
var scanned = 0;
foreach (var sheet in sheets)
{
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
scanned++;
foreach (var page in serializer.Read(raw.Value.Xml).Pages)
{
foreach (var child in page.Root.Children)
{
Visit(child);
}
}
}
catch
{
// 통계용 — 개별 실패는 건너뛴다(왕복 검증은 --db-smoke 가 본다)
}
}
lines.Add($"디자인 {scanned}건 스캔, 컨트롤 {typeCounts.Values.Sum()}개");
foreach (var (type, count) in typeCounts.OrderByDescending(p => p.Value))
{
lines.Add(string.Empty);
var descriptor = Core.Catalog.ControlRegistry.Find(type);
var known = descriptor is null
? new HashSet<string>(StringComparer.Ordinal)
: descriptor.Properties.Select(p => p.Key).ToHashSet(StringComparer.Ordinal);
lines.Add($"[{type}] {count}개" + (descriptor is null ? " ※ 기술자 없음" : ""));
foreach (var (key, holders) in keyCounts[type].OrderByDescending(p => p.Value))
{
var share = 100.0 * holders / count;
var mark = known.Contains(key) ? " " : ""; // = 인스펙터에서 편집 불가
var values = string.Empty;
if (valueCounts[type].TryGetValue(key, out var bucket))
{
values = " " + string.Join(" ", bucket.OrderByDescending(p => p.Value).Take(4)
.Select(p => $"{p.Key}×{p.Value}"));
}
lines.Add($"{mark} {key,-28} {holders,7} ({share,5:F1}%){values}");
}
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary>
/// 서식 마스터 등록 관련 컬럼 값 분포(--db-shtmst) — 신규 등록 기본값을 실측으로 정하기 위한 집계(읽기 전용).
///
/// 관행값을 추측으로 넣으면 등록은 성공하는데 목록에서만 사라지는 종류의 사고가 난다
/// (예: ShtExpDte 가 비면 dtEMRLoader 의 NVL(ShtExpDte,' ')='29991231' 조건에서 통째로 탈락).
/// </summary>
public static int RunShtMstReport(string reportPath)
{
try
{
var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
var lines = new List<string>();
var columns = new[] { "ShtHspYon", "ShtExpDte", "ShtUseYon", "ShtUsrDesYon", "ShtTyp", "ShtCneYon" };
foreach (var column in columns)
{
using var command = connection.CreateCommand();
command.CommandText =
$"SELECT NVL({column},'(null)') v, COUNT(*) c FROM E_ShtMst " +
$"GROUP BY NVL({column},'(null)') ORDER BY COUNT(*) DESC";
using var reader = command.ExecuteReader();
var parts = new List<string>();
var rank = 0;
while (reader.Read() && rank++ < 8)
{
parts.Add($"{reader.GetString(0)}={reader.GetDecimal(1)}");
}
lines.Add($"{column,-14} {string.Join(" ", parts)}");
}
// 디자이너가 만든 서식만(ShtUsrDesYon='Y') — 우리 등록 경로가 흉내 내야 할 모집단
using (var command = connection.CreateCommand())
{
command.CommandText =
"SELECT NVL(ShtHspYon,'(null)') h, NVL(ShtExpDte,'(null)') e, COUNT(*) c FROM E_ShtMst " +
"WHERE ShtUsrDesYon = 'Y' GROUP BY NVL(ShtHspYon,'(null)'), NVL(ShtExpDte,'(null)') ORDER BY COUNT(*) DESC";
using var reader = command.ExecuteReader();
var rank = 0;
while (reader.Read() && rank++ < 10)
{
lines.Add($" 디자이너서식 ShtHspYon={reader.GetString(0)} ShtExpDte={reader.GetString(1)} : {reader.GetDecimal(2)}");
}
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>테이블 샘플 행 조회(--db-sample) — 읽기 전용, 스키마 규약 확인용</summary>
public static int RunSample(string tableName, string reportPath, int max = 12)
{
try
{
if (!System.Text.RegularExpressions.Regex.IsMatch(tableName, "^[A-Za-z0-9_]{1,30}$"))
{
throw new ArgumentException("유효하지 않은 테이블명");
}
var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = $"SELECT * FROM {tableName} WHERE ROWNUM <= {max}";
var lines = new List<string>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var parts = new List<string>();
for (var i = 0; i < reader.FieldCount; i++)
{
var value = reader.IsDBNull(i) ? "∅" : reader.GetValue(i).ToString() ?? "";
if (value.Length > 30)
{
value = value[..30] + "…";
}
parts.Add($"{reader.GetName(i)}={value}");
}
lines.Add(string.Join(" | ", parts));
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>Spread 디자인 조회(--db-spd) — 목록 또는 특정 건 원문 덤프(읽기 전용)</summary>
public static int RunSpreadDump(string shtCodOrAll, string reportPath)
{
try
{
var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
if (shtCodOrAll == "*")
{
command.CommandText =
"SELECT SpdShtCod, SpdName, LENGTH(SpdDesign), NVL(SpdDelYon,' ') FROM E_SpdMst ORDER BY SpdShtCod";
var lines = new List<string>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
lines.Add($"{reader.GetString(0)}\t{reader.GetString(1)}\t{reader.GetDecimal(2):N0}자\t{(reader.GetString(3) == "Y" ? "이력" : "활성")}");
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
}
else
{
command.CommandText =
"SELECT SpdDesign FROM E_SpdMst WHERE TRIM(SpdShtCod) = :s AND NVL(SpdDelYon,' ') <> 'Y' AND ROWNUM = 1";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("s", shtCodOrAll));
File.WriteAllText(reportPath, command.ExecuteScalar() as string ?? "(없음)");
}
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>활성 디자인 XML 원문 덤프(--db-xml) — 읽기 전용</summary>
public static int RunDesignXml(string shtCod, string reportPath)
{
try
{
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var raw = store.LoadActiveDesignRaw(shtCod);
File.WriteAllText(reportPath,
raw is null ? "(없음)" : $"SdgKey={raw.Value.SdgKey}{Environment.NewLine}{raw.Value.Xml}");
return raw is null ? 1 : 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>서식 검색(--db-find) — 키워드 목록 출력</summary>
public static int RunFind(string keyword, string reportPath)
{
try
{
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var sheets = store.ListSheets(keyword, max: 50);
File.WriteAllText(reportPath, string.Join(Environment.NewLine,
sheets.Select(s => $"{s.ShtCod}\t{(s.HasDesign ? "디자인O" : "-")}\t{s.Name}")));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>상용구(E_SHTWRDMST) CRUD 왕복 스모크(--db-word-smoke) — 등록→조회→수정→순서→삭제(흔적 없음)</summary>
public static int RunWordSmoke(string shtCod, string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigService.Current;
var store = new RecordWordStore(config.ConnectionString);
// 이전 스모크 잔여 정리
foreach (var stale in store.List(shtCod).Where(w => w.Value.StartsWith("스모크 문구")))
{
store.Remove(stale.Key, UserSession.AuditUid);
}
var before = store.List(shtCod).Count;
lines.Add($"[사전] {shtCod} 상용구 {before}건");
var key1 = store.Add(shtCod, "스모크 문구 1 (특이사항 없음)", UserSession.AuditUid);
var key2 = store.Add(shtCod, "스모크 문구 2 (경과 양호)", UserSession.AuditUid);
var afterAdd = store.List(shtCod);
var okAdd = afterAdd.Count == before + 2
&& afterAdd.Any(w => w.Key == key1) && afterAdd.Any(w => w.Key == key2);
lines.Add($"[추가] 2건 → {afterAdd.Count}건 (키 {key1}, {key2}) : {(okAdd ? "통과" : "실패")}");
store.Update(key1, "스모크 문구 1 (수정됨)", UserSession.AuditUid);
var updatedValue = store.List(shtCod).FirstOrDefault(w => w.Key == key1)?.Value;
var okUpdate = updatedValue == "스모크 문구 1 (수정됨)";
lines.Add($"[수정] 값=\"{updatedValue}\" : {(okUpdate ? "통과" : "실패")}");
var reordered = afterAdd.Select(w => w.Key).ToList();
reordered.Reverse();
store.Reorder(reordered, UserSession.AuditUid);
var firstKey = store.List(shtCod).FirstOrDefault()?.Key;
var okReorder = firstKey == reordered[0];
lines.Add($"[순서] 첫 키={firstKey} 기대={reordered[0]} : {(okReorder ? "통과" : "실패")}");
store.Remove(key1, UserSession.AuditUid);
store.Remove(key2, UserSession.AuditUid);
var afterRemove = store.List(shtCod).Count;
var okRemove = afterRemove == before;
lines.Add($"[삭제] 원상복구 → {afterRemove}건 : {(okRemove ? "통과" : "실패")}");
var ok = okAdd && okUpdate && okReorder && okRemove;
lines.Add(ok ? "결과: 통과" : "결과: 실패");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return ok ? 0 : 1;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary>
/// 제자리 갱신(ShtCneYon='Y') 서식에서 컨트롤 문구를 실제로 바꿔 E_SctMst.SctObjTxt 반영을 확인하고 원복한다.
/// 레거시 bzSaveSheetDesignNControlInfo.vb:378 은 기존 행의 SctObjTxt 만 덮어쓰고 SctKey 는 보존하므로
/// 둘 다 검사한다. 확인 후 원문으로 되돌려 DB 상태를 원위치시킨다.
/// </summary>
private static bool VerifySctObjTxtUpdate(OracleLegacyFormStore store, LegacyXmlSerializer serializer,
string connectionString, string shtCod, decimal sdgKey, List<string> lines)
{
var raw = store.LoadActiveDesignRaw(shtCod);
if (raw is null)
{
lines.Add("[SctObjTxt] 활성 디자인 조회 실패");
return false;
}
var document = serializer.Read(raw.Value.Xml);
document.FormId = shtCod;
var target = EnumerateControls(document).FirstOrDefault(c => (c.Props.GetText("Text") ?? string.Empty).Length > 0);
if (target is null)
{
lines.Add("[SctObjTxt] Text 를 가진 컨트롤이 없어 검사 생략(통과로 간주)");
return true;
}
var originalText = target.Props.GetText("Text")!;
var probeText = originalText + "_SCTPROBE";
var (keyBefore, txtBefore) = ReadSctRow(connectionString, sdgKey, target.Id);
lines.Add($"[SctObjTxt] 대상 '{target.Id}' SctKey={keyBefore} 원문='{Trim30(txtBefore)}'");
try
{
target.Props.SetText("Text", probeText);
store.SaveDesign(document, UserSession.AuditUid);
var (keyAfter, txtAfter) = ReadSctRow(connectionString, sdgKey, target.Id);
var textApplied = string.Equals(txtAfter, probeText, StringComparison.Ordinal);
var keyStable = keyAfter == keyBefore;
lines.Add($"[SctObjTxt] 변경 후 SctKey={keyAfter}({(keyStable ? "보존" : "변동!")}) " +
$"SctObjTxt='{Trim30(txtAfter)}' → {(textApplied ? "반영됨" : "미반영!")}");
return textApplied && keyStable;
}
finally
{
// 원복 — 검사 실패 여부와 무관하게 DB 를 원상태로 되돌린다
var restore = serializer.Read(store.LoadActiveDesignRaw(shtCod)!.Value.Xml);
restore.FormId = shtCod;
var restoreTarget = EnumerateControls(restore).FirstOrDefault(c => c.Id == target.Id);
if (restoreTarget is not null)
{
restoreTarget.Props.SetText("Text", originalText);
store.SaveDesign(restore, UserSession.AuditUid);
var (_, txtRestored) = ReadSctRow(connectionString, sdgKey, target.Id);
lines.Add($"[SctObjTxt] 원복 → '{Trim30(txtRestored)}' " +
$"{(string.Equals(txtRestored, originalText, StringComparison.Ordinal) ? "확인" : "불일치!")}");
}
}
}
/// <summary>E_SctMst 단일 행의 (SctKey, SctObjTxt) — 동명 중복 시 최초 1건</summary>
private static (decimal Key, string Text) ReadSctRow(string connectionString, decimal sdgKey, string objName)
{
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT SctKey, NVL(SctObjTxt,' ') FROM E_SctMst WHERE SctSdgKey = :k AND SctObjNam = :n ORDER BY SctKey";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("k", sdgKey));
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("n", objName));
using var reader = command.ExecuteReader();
return reader.Read() ? (reader.GetDecimal(0), reader.GetString(1)) : (0m, string.Empty);
}
/// <summary>문서의 모든 컨트롤(자식 포함) 평탄화</summary>
private static IEnumerable<ControlElement> EnumerateControls(FormDocument document)
{
foreach (var page in document.Pages)
{
foreach (var control in page.Controls)
{
foreach (var item in Flatten(control))
{
yield return item;
}
}
}
static IEnumerable<ControlElement> Flatten(ControlElement element)
{
yield return element;
foreach (var child in element.Children)
{
foreach (var item in Flatten(child))
{
yield return item;
}
}
}
}
private static string Trim30(string value) => value.Length > 30 ? value[..30] + "…" : value;
/// <summary>무변경 재저장 스모크 — 저장 경로 전체(트랜잭션/버저닝/E_SctMst) 검증</summary>
/// <summary>리포트를 파일과 stderr 에 남기고 종료코드를 돌려준다</summary>
private static int Finish(List<string> lines, string reportPath, int code)
{
var text = string.Join(Environment.NewLine, lines);
File.WriteAllText(reportPath, text);
Console.Error.WriteLine(text);
return code;
}
/// <summary>
/// 제자리 정정(TK_MODIFY) 왕복 검증 — <c>--db-modify-smoke &lt;서식코드&gt; &lt;리포트&gt;</c>.
///
/// <b>왜 필요한가.</b> ModifyDesign 은 운영 테이블에 쓰는데 한 번도 실행해 본 적이 없다.
/// 저장 경로에는 이런 검증이 있고(RunSaveSmoke) 수정 경로에만 없었다 —
/// "코드는 맞아 보이는데" 상태이고, 이 저장소에서 그건 여러 번 틀렸다.
///
/// <b>무엇을 단정하는가.</b> 수정은 저장과 <b>이력 방향이 반대</b>다.
/// ① 활성 SdgKey 가 <b>바뀌지 않는다</b>(EMR 이 참조하는 키가 유지되는 것이 이 동작의 존재 이유다)
/// ② 활성 행의 디자인이 <b>새 XML</b> 로 바뀐다
/// ③ 옛 XML 을 담은 이력 행이 <b>하나</b> 생긴다(SdgDelYon='Y')
/// ④ 활성 행의 감사 컬럼이 <b>지금 사용자·시각</b>이다 — 레거시는 이걸 이력 행에 썼다(버그, 미복제)
/// ⑤ <c>E_SctMst</c> 행 수가 <b>변하지 않는다</b>(수정은 건드리지 않는다)
///
/// <b>DB 를 원상태로 되돌린다.</b> 활성 행의 XML·감사값을 원문으로 UPDATE 하고,
/// 이 진단이 만든 이력 행을 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건이어도 쿼리는 성공해야 한다.
// <b>시간을 함께 잰다.</b> "느리다"는 체감이고, 어디가 느린지는 숫자로만 안다.
foreach (var kind in new[]
{
PatientSearchKind.ChartNumber, PatientSearchKind.Name,
PatientSearchKind.ResidentNumber, PatientSearchKind.MobilePhone,
})
{
var watch = System.Diagnostics.Stopwatch.StartNew();
var found = store.Search(kind, term);
watch.Stop();
lines.Add($" {kind,-16} {found.Count,4}건 {watch.ElapsedMilliseconds,6}ms");
}
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}건");
// 내원 목록 — <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++;
var vw = System.Diagnostics.Stopwatch.StartNew();
visits = store.ListVisits(candidate.ChtNum);
vw.Stop();
lines.Add($" ListVisits {vw.ElapsedMilliseconds}ms → {visits.Count}건");
if (visits.Count > 0)
{
break;
}
}
if (people.Count == 0)
{
lines.Add("SKIP ⑥ 내원 목록 — 이 검색어로 환자를 못 찾아 판정 불가"
+ " (다른 검색어로 다시 돌려야 한다)");
}
else
{
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);
// 환자를 고른 뒤 사람이 기다리는 구간이다 — 문맥 5행 + 시각 + 복호화까지 붙는다
var loadWatch = System.Diagnostics.Stopwatch.StartNew();
var context = contexts.Load(target);
loadWatch.Stop();
lines.Add($" 문맥 읽기 {loadWatch.ElapsedMilliseconds}ms (환자를 고른 뒤 기다리는 구간)");
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})");
// ── MDataTable 치환 + 실행 ──
// 태그와 다른 관이다. 여기까지 돌지 않으면 "미리보기는 되는데 실제 서식은
// 반쯤 빈" 상태를 확인할 수 없다. 실DB 로만 확인 가능한 것은
// <b>치환한 SQL 이 실제로 실행되는가</b>이므로 그것만 본다.
var probeDoc = new SheetMe.Core.Models.FormDocument();
var probePage = new SheetMe.Core.Models.FormPage { Width = 720, Height = 856 };
probeDoc.Pages.Add(probePage);
var probeSource = new SheetMe.Core.Models.ControlElement
{
Type = "MDataTable", Id = "MDataTable1",
Bounds = new SheetMe.Core.Models.LayoutRect { X = 0, Y = 0, W = 20, H = 20 },
};
// 스칼라 치환과 DataRow 치환을 한 문장에 넣는다 — 둘은 다른 코드 경로다
probeSource.Props.SetText("Query",
"SELECT TRIM(ComChtNum) CHT FROM P_ComInf"
+ " WHERE ComNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ComNum>>"
+ " AND TRIM(ComChtNum) = '<<M.CMM.HISOperatingInfo.bzPatientInfo.PatInfDR.item(\"PatChtNum\")>>'");
probePage.Controls.Add(probeSource);
var runner = new Services.MDataTableRunner(probeDoc,
new Services.PatientQueryVariableSource(ctx, "SMOKE"));
var ran = runner.Run("MDataTable1");
Check("⑯ 치환한 SQL 이 실제로 실행된다",
ran.Ok && ran.Rows.Count == 1,
ran.Ok ? $"{ran.Rows.Count}행 (내원번호로 1행이어야 한다)" : ran.Error);
Check("⑰ 스칼라·DataRow 치환이 둘 다 값으로 박힌다",
!ran.Sql.Contains("<<", StringComparison.Ordinal)
&& !ran.Sql.Contains("= '' ", StringComparison.Ordinal)
&& !ran.Sql.EndsWith("= ''", StringComparison.Ordinal),
ran.Sql);
// 대조군 — 환자 없이 같은 쿼리. 치환이 빈 값이 되어 SQL 이 깨져야 한다.
// 이게 없으면 ⑯ 은 "쿼리가 원래 환자와 무관했다"와 구분되지 않는다.
var blind = new Services.MDataTableRunner(probeDoc, null).Run("MDataTable1");
Check("⑱ (대조군) 환자 없이는 같은 쿼리가 값을 내지 못한다",
!blind.Ok || blind.Rows.Count != 1,
$"Ok={blind.Ok} {blind.Rows.Count}행 — 치환이 실제로 기여하지 않았다는 뜻이다");
lines.Add($" 대조군 SQL: {blind.Sql.Replace("\r", " ").Replace("\n", " ")}");
lines.Add($" 치환 못 한 변수 {blind.Unknown.Count}개");
// ── 주민번호 복호화 ──
// 값은 <b>절대 리포트에 쓰지 않는다</b> — 길이와 형식만 적는다.
// 이 앱이 다루는 것 중 가장 민감한 값이고, 리포트는 파일로 남는다.
var personal = new PersonalDataStore(conn);
var flags = personal.Flags();
var hospital = Services.UserSession.Current.HspCod;
lines.Add($" 암호화 설정: 전체={(flags.Enabled ? "Y" : "N")}"
+ $" 주민번호={(flags.ResidentNumber ? "Y" : "N")}"
+ $" / 병원코드='{hospital}'");
Check("⑲ 암호화 설정을 예외 없이 읽는다", true);
// 병원코드가 비면 SRCH·JINJU 기관기호 보정이 걸리지 않는다 —
// 그 갈래는 이 진단으로 확인되지 않는다. 대신 <b>복호화 자체</b>는 확인한다.
// 그것이 이 단계에서 실패하면 나머지가 전부 무의미하고, DB 권한·패키지 문제라
// 코드로는 알 수 없다. 확인한 것과 못 한 것을 갈라서 적는다.
if (hospital.Length == 0)
{
lines.Add(" [미확인] 병원코드가 없어 SRCH·JINJU 기관기호 보정은"
+ " 이 진단으로 확인되지 않았다(순수 함수 테스트가 그 갈래를 덮는다)");
}
{
var raw = flags.ResidentNumberEncrypted
? personal.Decrypt(PatientContext.Value(ctx.PatInf, "PatResEnc"), hospital)
: PatientContext.Value(ctx.PatInf, "PatResNum");
var normalized = SheetMe.Core.Catalog.ResidentNumber.Normalize(
raw,
PatientContext.Value(ctx.PatInf, "PatBthDay"),
PatientContext.Value(ctx.PatInf, "PatNatCod"),
hospital);
var sex = SheetMe.Core.Catalog.ResidentNumber.SexOf(normalized);
lines.Add($" 주민번호: 원본 {raw.Length}자 → 보정 {normalized.Length}자,"
+ $" 전부 숫자={normalized.All(char.IsAsciiDigit)} (값은 기록하지 않는다)");
Check("⑳ 주민번호가 13자리 숫자로 만들어진다",
normalized.Length == 13 && normalized.All(char.IsAsciiDigit),
$"{normalized.Length}자 — 복호화 실패나 컬럼 부재를 의심할 것");
Check("㉑ 그 번호에서 성별이 나온다", sex is "M" or "F", $"'{sex}'");
}
// ── OCM_담당의사 배치의 선행 조사 ──
// 레거시 GetCodInfDT 는 ORDER BY 없이 CodMtiSeq='0' 만 걸고
// 호출부가 Rows(0) 을 집는다. 한 내원에 그런 행이 둘 이상이면
// <b>오라클이 어느 행을 먼저 주느냐에 따라 담당의가 달라진다</b>.
// 실제로 그런 내원이 있는지 세어 본다 — 0이면 없는 문제고,
// 있으면 정렬 규칙을 정해야 한다. 세기 전에는 어느 쪽인지 알 수 없다.
foreach (var line in ExplainPeriod(conn, 0,
"SELECT COUNT(*) 다중내원, MAX(행수) 최대행수 FROM ("
+ " SELECT CodComNum, COUNT(*) 행수 FROM P_CodInf"
+ " WHERE CodMtiSeq = 0 GROUP BY CodComNum HAVING COUNT(*) > 1)"
+ " WHERE :k = 0"))
{
lines.Add($" [조사] CodMtiSeq=0 이 2행 이상인 내원: {line}");
}
// ㉒ 담당의 조회가 실제로 행을 돌려주는가.
// 적용일시 BETWEEN + 최신순 1행으로 골라야 결정적이다(위 조사 근거).
// 0행이면 그 조건이 어긋난 것이라 태그 셋이 통째로 빈다.
{
// 시험 내원에 진료과 행이 <b>아예 없으면</b> 0열이 나오는데
// 그건 쿼리가 아니라 데이터다. P_CodInf 에 행을 가진 내원을 찾아 건다 —
// 앞서 이 함정을 밟은 적이 있다(내원 0건인 환자로 판정하려 했다).
var owner = FirstComNum(conn, "P_CodInf", "CodComNum", "CodStrDtm") ?? target;
var ownerCtx = owner == target ? ctx : contexts.Load(owner);
var doctor = ownerCtx is null
? new Dictionary<string, string>()
: (IReadOnlyDictionary<string, string>)contexts.Doctor(owner, ownerCtx.AdpDtm);
var uidNam = PatientContext.Value(doctor, "UidNam");
lines.Add($" 담당의 조회: 내원 {owner:F0} → {doctor.Count}열,"
+ $" 이름 {uidNam.Length}자 (값은 기록하지 않는다)");
Check("㉒ 담당의 조회가 예외 없이 돌고 행을 돌려준다", doctor.Count > 0,
"0열 — 적용일시 BETWEEN 조건이나 CodDtrCod 를 의심할 것");
// ㉓ 진료과·요양기관명 — 같은 내원의 CodDepCod 로.
// 코드가 이름이 되는 것까지 확인해야 태그가 값을 낸다.
if (ownerCtx is { } octx)
{
var depCod = PatientContext.Value(octx.CodInf, "CodDepCod");
var acp = PatientContext.Value(octx.ComInf, "ComAcpDtm");
var dep = contexts.Department(depCod, acp);
var korNam = PatientContext.Value(dep, "DepKorNam");
lines.Add($" 진료과 조회: 코드 '{depCod}' → {dep.Count}열,"
+ $" 한글명 {korNam.Length}자");
Check("㉓ 진료과 마스터가 이름을 돌려준다", korNam.Trim().Length > 0,
$"코드 '{depCod}' 로 M_DepMst 에서 이름을 못 찾았다");
var hospRow = contexts.HospitalRow(depCod, octx.AdpDtm);
var hosp = PatientContext.Value(hospRow, "HspInsNam");
lines.Add($" 요양기관: {hospRow.Count}열, 병원명 {hosp.Length}자");
Check("㉔ 요양기관명이 나온다", hosp.Trim().Length > 0,
"M_DepMst⨝M_HspMst 조인이 0행 — 유효기간이나 DepHspCod 를 의심할 것");
// ㉕ 상병 — 원문 기계 추출 SQL 이 실제로 돈다.
// 이 내원에 상병이 없을 수 있으므로 O_OkdInf 에 행을 가진 내원을 찾아 건다
// (앞서 두 번 밟은 "행 없는 내원으로 판정" 함정).
var okdOwner = FirstComNum(conn, "O_OkdInf", "OkdComNum", "OkdComNum");
if (okdOwner is null)
{
Check("㉕ 상병 — O_OkdInf 에 행이 없어 확인 불가", false,
"이 DB 로는 이 갈래를 확인할 수 없다");
}
else
{
var diag = new DiagnosisStore(conn);
var all = diag.Diagnoses(okdOwner.Value);
var main = diag.Diagnoses(okdOwner.Value, DiagnosisStore.Scope.Main);
var sub = diag.Diagnoses(okdOwner.Value, DiagnosisStore.Scope.Sub);
lines.Add($" 상병: 내원 {okdOwner.Value:F0} → 전체 {all.Count}행,"
+ $" 주 {main.Count}행, 부 {sub.Count}행");
Check("㉕ 상병 쿼리(전체)가 행을 돌려준다", all.Count > 0,
"OkdStt='E' 조건이나 UNION 결합을 의심할 것");
Check("㉖ 주/부 필터가 실제로 가른다", main.Count + sub.Count == all.Count
&& main.Count <= all.Count,
$"주 {main.Count} + 부 {sub.Count} ≠ 전체 {all.Count} — RN 접기 전 필터 규칙 확인");
if (all.Count > 0)
{
var okdKorNam = all[0].TryGetValue("KcdKorNam", out var kn) ? kn : "";
lines.Add($" 첫 상병: 한글명 {okdKorNam.Length}자,"
+ $" 코드 {(all[0].TryGetValue("KcdElcCod", out var kc) ? kc.Length : 0)}자"
+ " (값은 기록하지 않는다)");
Check("㉗ 상병 한글명이 나온다", okdKorNam.Trim().Length > 0,
"M_KcdMst 조인이 빈 이름을 냈다");
}
}
// ㉘ 수술 — 원문 기계 추출 SQL 이 실제로 돈다.
var oprOwner = FirstComNum(conn, "O_OprInf", "OprComNum", "OprComNum");
if (oprOwner is null)
{
// "행이 없다"와 "표가 없다/조회가 죽었다"를 가른다 —
// FirstComNum 은 예외를 삼키므로 이 줄 없이는 못 가른다.
foreach (var line in ExplainPeriod(conn, 0,
"SELECT COUNT(*) OPRCNT FROM O_OprInf WHERE :k = 0"))
{
lines.Add($" [진단] O_OprInf: {line}");
}
lines.Add("SKIP ㉘ 수술 — 확인 불가. 위 [진단] 줄이 원인이다:"
+ " ORA-00942 면 이 시험 DB 에 O_OprInf 가 아예 없는 것이고,"
+ " 수술 태그는 운영 DB 에서만 검증할 수 있다");
}
else
{
var surgeries = new SurgeryStore(conn).Surgeries(oprOwner.Value);
var opName = surgeries.Count > 0
&& surgeries[0].TryGetValue("OPNAME", out var on) ? on : "";
lines.Add($" 수술: 내원 {oprOwner.Value:F0} → {surgeries.Count}행,"
+ $" OPNAME {opName.Length}자 (값은 기록하지 않는다)");
Check("㉘ 수술 쿼리가 행을 돌려준다", surgeries.Count > 0,
"OprStt='E' 조건이나 조인을 의심할 것");
}
// ㉙ 바이탈 — E_EmdInf_VITAL 에 행을 가진 내원으로 실행 확인
var vitOwner = FirstComNum(conn,
"E_EmdInf_VITAL INNER JOIN E_EmrInf ON EmrKey = EmdEmrKey",
"EmrComNum", "EmrComNum");
if (vitOwner is null)
{
foreach (var line in ExplainPeriod(conn, 0,
"SELECT COUNT(*) VITCNT FROM E_EmdInf_VITAL WHERE :k = 0"))
{
lines.Add($" [진단] E_EmdInf_VITAL: {line}");
}
lines.Add("SKIP ㉙ 바이탈 — 확인 불가. 위 [진단] 줄이 원인이다"
+ " (ORA-00942 면 이 시험 DB 에 표가 없어 운영 DB 에서만 검증 가능)");
}
else
{
var vital = new VitalStore(conn);
var weight = vital.One(vitOwner.Value, "EmdWeight", "EmdWeight",
new[] { "EmdWeight" }, "EmdDte ASC, EmdTime ASC");
var weightLast = vital.One(vitOwner.Value, "EmdWeight", "EmdWeight",
new[] { "EmdWeight" }, "EmdDte DESC, EmdTime DESC");
lines.Add($" 바이탈: 내원 {vitOwner.Value:F0} → 첫 몸무게 {weight.Length}자,"
+ $" 마지막 {weightLast.Length}자 (값은 기록하지 않는다)");
Check("㉙ 바이탈 조회가 값을 돌려준다", weight.Length > 0,
"EmdWeight 널 필터나 조인을 의심할 것");
}
}
}
}
}
}
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);
}
}
/// <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>();
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 config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var before = store.LoadActiveDesignRaw(shtCod)
?? throw new InvalidOperationException($"활성 디자인이 없습니다: {shtCod}");
var versionsBefore = store.ListVersions(shtCod);
var sctBefore = store.CountSctRows(before.SdgKey);
var auditBefore = ReadAudit(config.ConnectionString, before.SdgKey);
lines.Add($"[사전] {shtCod} 활성 SdgKey={before.SdgKey}, 버전 {versionsBefore.Count}건,"
+ $" E_SctMst {sctBefore}건, XML {before.Xml.Length:N0}자, 감사=({auditBefore.Uid},{auditBefore.Stamp})");
// 문구를 실제로 바꾼다 — 무변경 재저장으로는 "디자인이 갱신됐다"를 증명할 수 없다
var document = serializer.Read(before.Xml);
document.FormId = shtCod;
var marker = "수정검증-" + before.SdgKey;
var target = FirstTextControl(document);
if (target is null)
{
lines.Add("글자를 담은 컨트롤이 없어 갱신을 증명할 수 없습니다 — 다른 서식으로 시도하세요.");
return Finish(lines, reportPath, 2);
}
var originalText = target.Props.GetText("Text") ?? string.Empty;
target.Props.SetText("Text", marker);
var returnedKey = store.ModifyDesign(document, UserSession.AuditUid);
var after = store.LoadActiveDesignRaw(shtCod)
?? throw new InvalidOperationException("수정 후 활성 디자인 조회 실패");
var versionsAfter = store.ListVersions(shtCod);
var auditAfter = ReadAudit(config.ConnectionString, before.SdgKey);
lines.Add($"[수정] 반환 SdgKey={returnedKey}, 활성 SdgKey={after.SdgKey},"
+ $" 버전 {versionsBefore.Count} → {versionsAfter.Count}건, 감사=({auditAfter.Uid},{auditAfter.Stamp})");
Check("① 활성 SdgKey 가 바뀌지 않는다",
after.SdgKey == before.SdgKey && returnedKey == before.SdgKey,
$"{before.SdgKey} → {after.SdgKey} (반환 {returnedKey})");
Check("② 활성 행이 새 디자인으로 갱신된다", after.Xml.Contains(marker, StringComparison.Ordinal));
Check("③ 이력 행이 하나 생긴다", versionsAfter.Count == versionsBefore.Count + 1,
$"{versionsBefore.Count} → {versionsAfter.Count}");
var added = versionsAfter.Select(v => v.SdgKey).Except(versionsBefore.Select(v => v.SdgKey)).ToList();
var historyKey = added.Count == 1 ? added[0] : 0m;
Check("③-b 새 행이 이력(SdgDelYon='Y')이다",
historyKey > 0 && versionsAfter.First(v => v.SdgKey == historyKey).Deleted);
if (historyKey > 0)
{
var historyXml = store.LoadDesignRawByKey(shtCod, historyKey);
Check("③-c 이력 행이 <b>옛</b> XML 을 담는다",
historyXml is not null && !historyXml.Contains(marker, StringComparison.Ordinal),
"이력 행에 새 내용이 들어갔다 — 방향이 뒤집혔다");
}
// 레거시는 활성 행에 감사값을 안 쓰고 이력 행에 현재 사용자·시각을 썼다. 그 버그를 복제하지 않았는지 본다.
Check("④ 감사 컬럼이 활성 행에 기록된다",
auditAfter.Uid == UserSession.AuditUid && auditAfter.Stamp != auditBefore.Stamp,
$"활성 행 감사=({auditAfter.Uid},{auditAfter.Stamp}), 기대 uid={UserSession.AuditUid}");
// 행이 0건이면 "0 → 0" 을 비교하는 것이라 아무것도 증명하지 못한다.
// 통과로 적으면 읽는 사람이 검증됐다고 믿는다 — 판정 불가라고 밝힌다.
var sctAfter = store.CountSctRows(before.SdgKey);
if (sctBefore == 0)
{
lines.Add("SKIP ⑤ E_SctMst 를 건드리지 않는다 — 이 서식은 행이 0건이라 판정 불가"
+ " (행이 있는 서식으로 다시 돌려야 한다)");
}
else
{
Check("⑤ E_SctMst 를 건드리지 않는다", sctAfter == sctBefore, $"{sctBefore} → {sctAfter}");
}
// 원복 — 검사 결과와 무관하게 DB 를 원상태로 되돌린다
target.Props.SetText("Text", originalText);
RestoreDesign(config.ConnectionString, before.SdgKey, before.Xml, auditBefore.Uid, auditBefore.Stamp);
var removed = historyKey > 0 ? DeleteHistoryRow(config.ConnectionString, historyKey) : 0;
var restored = store.LoadActiveDesignRaw(shtCod);
var versionsRestored = store.ListVersions(shtCod);
Check("원복: 활성 XML 이 원문으로 돌아온다",
restored is not null && restored.Value.Xml.Length == before.Xml.Length
&& !restored.Value.Xml.Contains(marker, StringComparison.Ordinal));
Check("원복: 이력 행이 지워진다", removed == 1 && versionsRestored.Count == versionsBefore.Count,
$"삭제 {removed}행, 버전 {versionsRestored.Count} (기대 {versionsBefore.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);
}
}
/// <summary>글자를 담은 첫 컨트롤 — 갱신을 증명할 대상</summary>
private static SheetMe.Core.Models.ControlElement? FirstTextControl(SheetMe.Core.Models.FormDocument document)
{
foreach (var page in document.Pages)
{
foreach (var control in page.Controls)
{
if ((control.Props.GetText("Text") ?? string.Empty).Length > 0)
{
return control;
}
}
}
return null;
}
private static (string Uid, string Stamp) ReadAudit(string connectionString, decimal sdgKey)
{
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT NVL(SdgUidCod,' '), NVL(SdgUpdDtm,' ') FROM E_SdgMst WHERE SdgKey = :k";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("k", sdgKey));
using var reader = command.ExecuteReader();
return reader.Read()
? (reader.GetString(0).Trim(), reader.GetString(1).Trim())
: (string.Empty, string.Empty);
}
/// <summary>원복 전용 — 활성 행을 원문 XML·원 감사값으로 되돌린다</summary>
private static void RestoreDesign(string connectionString, decimal sdgKey, string xml, string uid, string stamp)
{
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"UPDATE E_SdgMst SET SdgDesign = :d, SdgUidCod = :u, SdgUpdDtm = :t WHERE SdgKey = :k";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("d",
Oracle.ManagedDataAccess.Client.OracleDbType.Clob) { Value = xml });
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("u", uid));
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("t", stamp));
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("k", sdgKey));
command.ExecuteNonQuery();
}
/// <summary>
/// 이 진단이 만든 이력 행 하나를 지운다.
/// <c>SdgDelYon='Y'</c> 를 조건에 함께 건다 — SdgKey 를 잘못 짚어도 <b>활성 행은 지워지지 않는다</b>.
/// </summary>
private static int DeleteHistoryRow(string connectionString, decimal sdgKey)
{
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = "DELETE FROM E_SdgMst WHERE SdgKey = :k AND SdgDelYon = 'Y'";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("k", sdgKey));
return command.ExecuteNonQuery();
}
public static int RunSaveSmoke(string shtCod, string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var before = store.LoadActiveDesignRaw(shtCod)
?? throw new InvalidOperationException($"활성 디자인이 없습니다: {shtCod}");
var cneYon = store.GetShtCneYon(shtCod);
var versionsBefore = store.ListVersions(shtCod);
lines.Add($"[사전] {shtCod} ShtCneYon='{cneYon}' 활성 SdgKey={before.SdgKey}, 버전 {versionsBefore.Count}건, XML {before.Xml.Length:N0}자");
// 무변경 재저장(문서 그대로) — 레거시 저장 시와 동일하게 새 버전 생성(또는 제자리 갱신)
var document = serializer.Read(before.Xml);
document.FormId = shtCod;
var savedKey = store.SaveDesign(document, UserSession.AuditUid);
var after = store.LoadActiveDesignRaw(shtCod)
?? throw new InvalidOperationException("저장 후 활성 디자인 조회 실패");
var versionsAfter = store.ListVersions(shtCod);
lines.Add($"[저장] SdgKey {before.SdgKey} → {savedKey} ({(cneYon == "Y" ? "제자리 갱신" : "신규 버전")}), 버전 {versionsAfter.Count}건");
var diffs = XmlSemanticDiff.Compare(before.Xml, after.Xml, maxDiffs: 10);
lines.Add($"[비교] 원본 vs 재저장 활성본 의미론 diff: {diffs.Count}건");
lines.AddRange(diffs.Select(d => " " + d));
var sctCount = store.CountSctRows(savedKey);
var walkerCount = SctMstXmlWalker.Walk(after.Xml).Count;
bool sctOk;
if (cneYon == "Y")
{
// 제자리 갱신은 레거시와 동일하게 삭제된 컨트롤의 행을 지우지 않는다(SctKey 안정성).
// 따라서 과거에 지워진 컨트롤의 고아 행이 누적될 수 있고, 행 수 동일은 성립하지 않는다.
// 옳은 불변식은 "워커가 낸 행이 전부 존재하고, 재저장으로 늘지 않는다" 이다.
sctOk = sctCount >= walkerCount;
var orphans = sctCount - walkerCount;
lines.Add($"[E_SctMst] 저장본 행 {sctCount}건 / 워커 기대 {walkerCount}건 → " +
(sctOk ? $"정상{(orphans > 0 ? $" (과거 삭제 컨트롤의 잔존 {orphans} 레거시도 지우지 않음)" : "")}"
: "부족! 워커 산출 행이 DB 에 없음"));
}
else
{
// 버저닝은 E_SctMst 를 새 키로 전량 재생성하므로 정확히 일치해야 한다
sctOk = sctCount == walkerCount;
lines.Add($"[E_SctMst] 저장본 행 {sctCount}건 / 워커 기대 {walkerCount}건 → {(sctOk ? "일치" : "불일치")}");
var oldVersion = versionsAfter.FirstOrDefault(v => v.SdgKey == before.SdgKey);
lines.Add($"[이력] 이전 버전 SdgKey={before.SdgKey} SdgDelYon='Y' 처리: {(oldVersion?.Deleted == true ? "확인" : "미확인!")}");
}
var ok = diffs.Count == 0 && sctOk;
// 제자리 갱신 경로에서만 의미가 있는 검사 — 무변경 재저장으로는 SctObjTxt 갱신을 증명할 수 없으므로
// 문구를 실제로 바꿔 E_SctMst 에 반영되는지 확인하고 원복한다(레거시는 SctObjTxt 만 덮어쓴다).
if (ok && cneYon == "Y")
{
ok &= VerifySctObjTxtUpdate(store, serializer, config.ConnectionString, shtCod, savedKey, lines);
}
lines.Add(ok ? "결과: 통과 — 레거시 디자이너/EMR 미리보기로 열어 최종 육안 확인 권장" : "결과: 실패");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return ok ? 0 : 1;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
#endregion
}