ETC_요양기관명칭_* 계열(bzDataInterface.vb:15656-16709). 12종 전부 <b>같은 조인(M_DepMst⨝M_HspMst)의 다른 컬럼</b>이라는 것을 본문에서 확인했다 — 병원명(이미 이식)·병원명_영문·병원대표자·병원장·병원장귀하·전화번호·팩스번호· 전화_팩스·영문_전화_팩스·주소·주소_영문·사업자등록번호·요양기관기호. 레거시는 태그마다 이 조인을 따로 돌린다(12종 = 왕복 12회). 컬럼 9개를 한 번에 뽑아 행으로 들고, 값 선택은 해석기가 한다. HospitalName(문자열) → HospitalRow(행) 로 일반화했다. 접미·결합도 본문 그대로다: 병원장 = 병원명+"장" / 병원장귀하 = +"장 귀하" 전화_팩스 = "Tel : … FAX : …" / 영문판 = "Tel : +82 - … FAX : +82 - …" 접두·접미가 붙는 태그는 <b>재료 컬럼</b>으로 빈 값을 판정한다 — 병원명이 비면 "장"만 찍히는 것을 값으로 세지 않는다. 실DB 확인: --db-patient ㉔ 가 9열·병원명 6자를 돌려줬다. ## 게이트 - dotnet test 338/338 · --edit-smoke 실패 0(이름 검사 75종) - --db-patient 전건 통과 · --db-render P062 md5 불변 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2589 lines
134 KiB
C#
2589 lines
134 KiB
C#
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 <리포트> [N] : 읽기 전용 — 활성 디자인 N건 일괄 Read→Write 의미론 diff 통계.
|
||
/// --db-save-smoke <서식코드> <리포트> : 쓰기 — 무변경 재저장 후 재로드 비교 + 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 <패턴>) — 이름을 모르는 마스터 테이블을 찾을 때.
|
||
/// 레거시 스키마에 코드 마스터가 여러 관례로 흩어져 있어(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 <서식코드> <리포트></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 <검색어> <리포트></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' 조건이나 조인을 의심할 것");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|