Files
SheetMe/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
T
MsystechandClaude Opus 5 8eeff42a72 전수조사 1단계 — 저장된 서식을 오염시키는 결함 7건 수정
레거시 대비 전수조사에서 나온 출시 차단 9건 중, 하루 안에 끝나는 국소 수정 7건.
저장 데이터를 망가뜨리는 ③④ 를 먼저 고쳤다.

③ 페이지 복제·추가가 루트 이름을 중복 발급했다
   Clone 이 Root.Id 까지 베끼는데 자식만 재발급해서 한 서식에 MDesignerHost1 이 둘 생겼다.
   AddPage 도 '페이지 수 + 1' 로 지어서 루트가 1·3 인 2장짜리 서식에서 곧바로 3 이 겹쳤다.
   E_SctMst 동기화는 SctObjNam 사전이라 둘째 루트가 첫째 루트 행에 매핑되고,
   그 페이지 자식들의 SctParObj 가 없는 ObjID 를 가리킨 채 남는다.
   → 두 경로 모두 RenamePageRoot 로 이름과 Name 속성을 함께 새로 받는다.

④ 이름 채번이 빈 번호를 메웠다
   TextBox3 을 지우고 새로 놓으면 다시 TextBox3 이 나왔다. 이름은 저장된 답변
   (E_SctDta.SctObjNam)이 붙는 열쇠라서, 지운 칸의 과거 입력이 새 칸에 그대로 나타난다.
   액션 대상과 CalcBox 수식도 이름으로 서로를 가리키므로 조용히 오배선된다.
   → 레거시 clsNameCreationService 규약대로 '사용 중 최대번호 + 1'.
   숫자가 아닌 접미사(TextBoxTotal)는 번호로 보지 않는다.

① 탭순서 적용이 문서 전체를 밀었다
   한 페이지를 편집해도 전 페이지 컨트롤이 999999+i 로 초기화됐다.
   레거시는 페이지마다 별도 디자인 서피스라 다른 페이지는 건드리지 않는다.
   → 초기화 범위를 이번에 클릭한 페이지로 한정.

② 이력본이 열린 상태에서 현재본을 열면 이력 탭이 활성화됐다
   FindOpenDbDocument 가 ShtCod 만 보고 있었다. 사용자는 현재본을 고친다고 믿은 채
   읽기 전용 스냅샷을 편집하게 된다. → HistorySdgKey is null 조건 추가.

⑤ 레거시에 없는 태그 3종을 고를 수 있었다
   PAT_BMI · PAT_IBW · PAT_IBW_퍼센트 는 bzDataInterface.vb:922/957/970 에서
   주석 처리된 채로 남아 있어 런타임에 해석되지 않는다. 고르면 배포 후에야 빈칸으로 드러난다.
   → 카탈로그에서 제거(값은 자유 입력이라 기존 서식에는 영향 없다).

⑥ 신규 서식 등록이 ShtHspYon·ShtExpDte 를 비웠다
   --db-shtmst 진단을 신설해 실측한 결과 운영 E_ShtMst 1,737행 중 두 컬럼이
   NULL 인 행은 0건이다. ShtExpDte 는 dtEMRLoader.vb:1229 가
   NVL(ShtExpDte,' ')='29991231' 로 못박아 거르고, 여러 조회가
   sysdate BETWEEN ShtAdpDte AND ShtExpDte 를 쓴다 — NULL 이면 BETWEEN 이 거짓이라
   등록은 성공했는데 EMR 어디에서도 안 보이는 서식이 된다(29991231 이 1,718행 98.9%).
   ShtHspYon 은 기록지정보·자동생성 목록이 'Y' 로 거르며, 디자이너가 만든 서식
   169건 중 160건이 'Y'. → 두 값을 INSERT 에 넣는다.

⑦ 빈 선택 상태의 Ctrl+드래그가 예외로 죽었다
   Ctrl/Shift 클릭은 선택을 바꾸지 않고 업에서 토글하므로 주선택이 빈 채 드래그가
   시작될 수 있는데, BeginMove 가 Selection.Primary! 를 그대로 넘겨
   RootAncestorOf 가 즉시 역참조했다. → 앵커를 Primary ?? 첫 선택으로 완화.

편집 스모크 9건 추가(루트 이름 유일 2 · 채번 4 · 탭순서 페이지 격리 1 · 빈 선택 드래그 1 · 태그 3종 부재 1).
채번 검사는 '가운데 번호'를 지운다 — 최대 번호를 지우면 최대값 자체가 내려가므로
같은 이름이 다시 나오는 것이 레거시와 같은 정상 동작이다.

회귀: 테스트 124/124, 편집 스모크 169건 실패 0,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일(HEAD 대비 md5 일치).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:08:06 +09:00

1020 lines
47 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.IO;
using SheetMe.Core.Models;
using SheetMe.Core.Serialization;
using SheetMe.Data.Stores;
using SheetMe.Designer.Services;
namespace SheetMe.Designer.Diagnostics;
/// <summary>
/// 실DB 검증 진단.
/// --db-smoke &lt;리포트&gt; [N] : 읽기 전용 — 활성 디자인 N건 일괄 Read→Write 의미론 diff 통계.
/// --db-save-smoke &lt;서식코드&gt; &lt;리포트&gt; : 쓰기 — 무변경 재저장 후 재로드 비교 + E_SctMst 행 수 검증.
/// </summary>
public static class DbSmoke
{
#region Methods
/// <summary>읽기 전용 일괄 왕복 스모크</summary>
public static int RunReadSmoke(string reportPath, int maxSheets)
{
var lines = new List<string>();
try
{
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var sheets = store.ListSheets(null, max: 3000);
lines.Add($"E_ShtMst 서식 {sheets.Count}건, 디자인 보유 {sheets.Count(s => s.HasDesign)}건");
var targets = sheets.Where(s => s.HasDesign).Take(maxSheets).ToList();
var perfect = 0;
var withWarnings = 0;
var diffSheets = new List<string>();
var failed = new List<string>();
var placeholderTypes = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var sheet in targets)
{
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
var document = serializer.Read(raw.Value.Xml);
var rewritten = serializer.Write(document);
var diffs = XmlSemanticDiff.Compare(raw.Value.Xml, rewritten, maxDiffs: 5);
foreach (var warning in document.Meta.ReadWarnings.Where(w => w.StartsWith("미지원")))
{
var type = warning[(warning.IndexOf('(') + 1)..].TrimEnd(')');
placeholderTypes[type] = placeholderTypes.GetValueOrDefault(type) + 1;
}
if (diffs.Count == 0)
{
perfect++;
if (document.Meta.ReadWarnings.Count > 0)
{
withWarnings++;
}
}
else
{
diffSheets.Add($" {sheet.ShtCod} {sheet.Name}: {diffs[0]}");
}
}
catch (Exception ex)
{
failed.Add($" {sheet.ShtCod} {sheet.Name}: {ex.Message}");
}
}
lines.Add($"왕복 검사 {targets.Count}건 → 의미론 diff 0: {perfect}건 (미지원 컨트롤 보존 포함 {withWarnings}건), diff 발생: {diffSheets.Count}건, 예외: {failed.Count}건");
if (placeholderTypes.Count > 0)
{
lines.Add("미지원(자리표시 보존) 타입 분포: " + string.Join(", ",
placeholderTypes.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Key}×{kv.Value}")));
}
if (diffSheets.Count > 0)
{
lines.Add("[diff 발생 서식]");
lines.AddRange(diffSheets.Take(20));
}
if (failed.Count > 0)
{
lines.Add("[예외 서식]");
lines.AddRange(failed.Take(20));
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return diffSheets.Count == 0 && failed.Count == 0 ? 0 : 1;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary>
/// 권한 게이트 영향 범위(--db-gate) — ShtUsrDesYon 값별 서식 수를 집계한다.
/// 게이트 도입 전에 "갑자기 서식이 안 열린다"는 문의 규모를 미리 알기 위한 읽기 전용 진단.
/// </summary>
/// <summary>
/// 페이지 루트(MDesignerHost) 속성 전수 집계 — 페이지 속성 편집기의 기본값 근거.
///
/// 레거시 PropertyGrid 는 <b>속성이 없으면 WinForms 기본값</b>을 보여 준다(BackColor 는 Control).
/// 우리가 어떤 키를 얼마나 마주치는지, 어떤 값이 실제로 쓰이는지 알아야 기본 표시를 정할 수 있다.
/// </summary>
public static int RunPagePropsReport(string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList();
var keyCounts = new Dictionary<string, int>(StringComparer.Ordinal);
var backColors = new Dictionary<string, int>(StringComparer.Ordinal);
var fonts = new Dictionary<string, int>(StringComparer.Ordinal);
var pages = 0;
var docs = 0;
foreach (var sheet in sheets)
{
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
docs++;
foreach (var page in serializer.Read(raw.Value.Xml).Pages)
{
pages++;
foreach (var key in page.Root.Props.Keys)
{
keyCounts[key] = keyCounts.GetValueOrDefault(key) + 1;
}
var back = page.Root.Props.GetText("BackColor") ?? "(없음)";
backColors[back] = backColors.GetValueOrDefault(back) + 1;
var font = page.Root.Props.GetText("Font") ?? "(없음)";
fonts[font] = fonts.GetValueOrDefault(font) + 1;
}
}
catch (Exception ex)
{
lines.Add($" 실패 {sheet.ShtCod}: {ex.Message}");
}
}
// 속성 패널을 '여는 것만으로' 문서가 바뀌면 안 된다.
// 값이 없는 페이지에 기본값을 지어내 저장해 버리면 왕복 diff 0 이 조용히 깨진다.
var mutated = new List<string>();
var checkedDocs = 0;
foreach (var sheet in sheets)
{
if (checkedDocs >= 40)
{
break;
}
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
var document = serializer.Read(raw.Value.Xml);
// 값이 없는 페이지를 가진 서식만 본다 — 지어낸 값이 생기는지가 관심사다
if (!document.Pages.Any(p => p.Root.Props.GetText("BackColor") is null))
{
continue;
}
checkedDocs++;
var before = serializer.Write(document);
var designer = new ViewModels.DesignerViewModel(document);
designer.ActivatePage(designer.Pages[0]);
var after = serializer.Write(designer.Document);
if (!string.Equals(before, after, StringComparison.Ordinal))
{
mutated.Add($" {sheet.ShtCod} {sheet.Name}");
}
}
catch (Exception ex)
{
mutated.Add($" {sheet.ShtCod} 예외: {ex.Message}");
}
}
lines.Insert(0, mutated.Count == 0
? $"[열기 무변경] BackColor 없는 서식 {checkedDocs}건 — 속성 패널을 열어도 XML 동일"
: $"[열기 변경 발생] {mutated.Count}건 — 아래 서식은 여는 것만으로 XML 이 바뀐다");
lines.InsertRange(1, mutated);
lines.Insert(mutated.Count + 1, string.Empty);
lines.Insert(0, $"디자인 {docs}건 / 페이지 {pages}장");
lines.Add(string.Empty);
lines.Add("[페이지 루트 속성 키 분포]");
foreach (var (key, count) in keyCounts.OrderByDescending(p => p.Value))
{
lines.Add($" {key,-24} {count,6}장 ({count * 100.0 / Math.Max(1, pages):0.0}%)");
}
lines.Add(string.Empty);
lines.Add("[BackColor 값 분포]");
foreach (var (value, count) in backColors.OrderByDescending(p => p.Value))
{
lines.Add($" {value,-24} {count,6}장 ({count * 100.0 / Math.Max(1, pages):0.0}%)");
}
lines.Add(string.Empty);
lines.Add("[Font 값 분포]");
foreach (var (value, count) in fonts.OrderByDescending(p => p.Value).Take(15))
{
lines.Add($" {value,-30} {count,6}장 ({count * 100.0 / Math.Max(1, pages):0.0}%)");
}
}
catch (Exception ex)
{
lines.Add($"예외: {ex}");
}
File.WriteAllLines(reportPath, lines);
return 0;
}
/// <summary>
/// 색 속성 값 전수 집계 — 색상 행의 인라인 팔레트를 실제 사용값으로 채우기 위한 근거.
/// 레거시는 명명색(White/Window/Control…)과 "R, G, B" 를 섞어 쓴다.
/// </summary>
public static int RunColorReport(string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList();
var byKey = new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
var total = 0;
void Visit(Core.Models.ControlElement element)
{
foreach (var key in new[] { "BackColor", "ForeColor" })
{
if (element.Props.GetText(key) is not { } value || value.Length == 0)
{
continue;
}
if (!byKey.TryGetValue(key, out var bucket))
{
bucket = new Dictionary<string, int>(StringComparer.Ordinal);
byKey[key] = bucket;
}
bucket[value] = bucket.GetValueOrDefault(value) + 1;
total++;
}
foreach (var child in element.Children)
{
Visit(child);
}
}
foreach (var sheet in sheets)
{
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
foreach (var page in serializer.Read(raw.Value.Xml).Pages)
{
Visit(page.Root);
}
}
catch
{
// 색 집계는 통계용이라 개별 실패는 건너뛴다(왕복 검증은 --db-smoke 가 본다)
}
}
lines.Add($"색 속성 값 {total}건");
foreach (var (key, bucket) in byKey.OrderBy(p => p.Key, StringComparer.Ordinal))
{
lines.Add(string.Empty);
lines.Add($"[{key}] 서로 다른 값 {bucket.Count}종");
foreach (var (value, count) in bucket.OrderByDescending(p => p.Value).Take(25))
{
lines.Add($" {value,-22} {count,7}건");
}
}
}
catch (Exception ex)
{
lines.Add($"예외: {ex}");
}
File.WriteAllLines(reportPath, lines);
return 0;
}
public static int RunGateReport(string reportPath)
{
try
{
var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText =
"SELECT NVL(TRIM(s.ShtUsrDesYon),'(미설정)') AS Flag, COUNT(*) AS Total, " +
" SUM(CASE WHEN EXISTS (SELECT 1 FROM E_SdgMst d " +
" WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') " +
" THEN 1 ELSE 0 END) AS WithDesign " +
"FROM E_ShtMst s WHERE NVL(s.ShtTyp,' ') <> 'S' " +
"GROUP BY NVL(TRIM(s.ShtUsrDesYon),'(미설정)') ORDER BY 2 DESC";
var lines = new List<string> { "ShtUsrDesYon 분포 — 'Y' 와 (미설정)만 열기가 허용된다", string.Empty };
var blocked = 0m;
using var reader = command.ExecuteReader();
while (reader.Read())
{
var flag = reader.GetString(0);
var total = reader.GetDecimal(1);
var withDesign = reader.GetDecimal(2);
var allowed = flag is "Y" or "(미설정)";
if (!allowed)
{
blocked += withDesign;
}
lines.Add($" {flag,-10} 전체 {total,5}건 / 디자인 보유 {withDesign,5}건 → {(allowed ? "허용" : "차단")}");
}
lines.Add(string.Empty);
lines.Add($"게이트 도입 시 열기가 차단되는 디자인 보유 서식: {blocked}건");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>
/// 서식 목록 그룹핑 후보 분포(--db-cls) — 분류 컬럼별 값 분포를 집계한다.
///
/// 그룹 기준을 잘못 고르면 "묶였는데 오히려 못 찾는" 결과가 된다. 판정 기준 셋:
/// ① 그룹 수가 과다하지 않을 것(수십 개면 스크롤이 더 길어진다)
/// ② 한 그룹에 전부 몰리지 않을 것(그룹핑 효과 0)
/// ③ 빈 값 비율이 낮을 것(대부분이 '(미분류)'로 가면 의미가 없다)
/// 목록에 실제로 뜨는 모집단(ShtTyp≠'S')만 세고, 디자인 보유 건수를 함께 낸다.
/// </summary>
public static int RunClassReport(string reportPath)
{
var candidates = new[] { "ShtClsCod", "ShtDepCls", "ShtTyp", "ShtUsrTyp", "ShtPatTyp", "ShtWrtTyp", "ShtLstTyp" };
try
{
var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
var lines = new List<string>
{
"서식 목록 그룹핑 후보 분포 — 모집단: E_ShtMst 에서 스캔서식(ShtTyp='S') 제외",
string.Empty,
};
// 분류 코드 → 한글 라벨 마스터 실측(EmrTreeDataBusiness.cs:55 의 조인 규약 검증)
using (var command = connection.CreateCommand())
{
command.CommandText =
"SELECT TRIM(DtlCod), DtlCodNam, NVL(DtlDspSeq,'-'), NVL(DtlStrDte,'-'), NVL(DtlExpDte,'-') " +
"FROM M_DtlMst WHERE DtlTblCod = 'EMR_SHEET_GROUP' ORDER BY DtlDspSeq, DtlCod";
lines.Add("[M_DtlMst DtlTblCod='EMR_SHEET_GROUP'] — ShtClsCod 라벨 마스터 (DspSeq 순)");
using var reader = command.ExecuteReader();
while (reader.Read())
{
lines.Add($" {reader.GetString(0),-6} → {(reader.IsDBNull(1) ? "(NULL)" : reader.GetString(1)),-20} " +
$"seq={reader.GetString(2),-6} {reader.GetString(3)}~{reader.GetString(4)}");
}
lines.Add(string.Empty);
}
// 조인 행 증식 검사 — M_DtlMst 는 PK 에 DtlStrDte 가 들어가 같은 코드가 기간별 다중행일 수 있다.
// 증식하면 서식이 목록에 두 번 뜬다(사용자는 '데이터가 깨졌다'로 읽는다).
using (var command = connection.CreateCommand())
{
command.CommandText =
"SELECT TRIM(DtlCod), COUNT(*) FROM M_DtlMst WHERE DtlTblCod = 'EMR_SHEET_GROUP' " +
"GROUP BY TRIM(DtlCod) HAVING COUNT(*) > 1 ORDER BY 2 DESC";
lines.Add("[조인 증식 검사] 같은 DtlCod 가 2행 이상인 코드");
using var reader = command.ExecuteReader();
var any = false;
while (reader.Read())
{
any = true;
lines.Add($" ⚠ {reader.GetString(0)} — {reader.GetDecimal(1)}행");
}
lines.Add(any ? " → 조인에 최신행 1건 선택이 반드시 필요" : " 없음(단, 스키마상 가능하므로 방어는 유지)");
lines.Add(string.Empty);
}
// 마스터에 없는 고아 분류 코드 — 이 건수만큼 헤더가 원시 코드로 뜬다
using (var command = connection.CreateCommand())
{
command.CommandText =
"SELECT NVL(TRIM(s.ShtClsCod),'(빈값)'), COUNT(*) FROM E_ShtMst s " +
"WHERE NVL(s.ShtTyp,' ') <> 'S' AND NOT EXISTS (" +
" SELECT 1 FROM M_DtlMst d WHERE d.DtlTblCod = 'EMR_SHEET_GROUP' " +
" AND TRIM(d.DtlCod) = TRIM(s.ShtClsCod)) " +
"GROUP BY NVL(TRIM(s.ShtClsCod),'(빈값)') ORDER BY 2 DESC";
lines.Add("[고아 코드] E_ShtMst 에는 있으나 마스터에 없는 분류");
using var reader = command.ExecuteReader();
var any = false;
while (reader.Read())
{
any = true;
lines.Add($" {reader.GetString(0)} — {reader.GetDecimal(1)}건");
}
if (!any)
{
lines.Add(" 없음");
}
lines.Add(string.Empty);
}
// 폐기 서식(ShtUseYon='N')이 섞이면 그룹 헤더 건수가 부풀어 보인다
using (var command = connection.CreateCommand())
{
command.CommandText =
"SELECT NVL(TRIM(ShtTyp),'(빈값)'), NVL(TRIM(ShtUseYon),'(빈값)'), COUNT(*) " +
"FROM E_ShtMst GROUP BY NVL(TRIM(ShtTyp),'(빈값)'), NVL(TRIM(ShtUseYon),'(빈값)') ORDER BY 3 DESC";
lines.Add("[모집단 검증] ShtTyp × ShtUseYon");
using var reader = command.ExecuteReader();
while (reader.Read())
{
lines.Add($" ShtTyp={reader.GetString(0),-8} ShtUseYon={reader.GetString(1),-8} {reader.GetDecimal(2),5}건");
}
lines.Add(string.Empty);
}
foreach (var column in candidates)
{
using var command = connection.CreateCommand();
command.CommandText =
$"SELECT NVL(TRIM(s.{column}),'(빈값)') AS K, COUNT(*) AS Total, " +
" SUM(CASE WHEN EXISTS (SELECT 1 FROM E_SdgMst d " +
" WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') " +
" THEN 1 ELSE 0 END) AS WithDesign " +
"FROM E_ShtMst s WHERE NVL(s.ShtTyp,' ') <> 'S' " +
$"GROUP BY NVL(TRIM(s.{column}),'(빈값)') ORDER BY 2 DESC";
var rows = new List<(string Key, decimal Total, decimal WithDesign)>();
using (var reader = command.ExecuteReader())
{
while (reader.Read())
{
rows.Add((reader.GetString(0), reader.GetDecimal(1), reader.GetDecimal(2)));
}
}
var all = rows.Sum(r => r.Total);
var blank = rows.FirstOrDefault(r => r.Key == "(빈값)").Total;
var top = rows.Count > 0 ? rows[0] : default;
lines.Add($"=== {column} — 서로 다른 값 {rows.Count}개 / 빈값 {blank}건({(all == 0 ? 0 : blank * 100 / all)}%) / " +
$"최대 그룹 '{top.Key}' {top.Total}건 ===");
foreach (var row in rows.Take(40))
{
lines.Add($" {row.Key,-32} {row.Total,5}건 (디자인 {row.WithDesign,5})");
}
if (rows.Count > 40)
{
lines.Add($" … 이하 {rows.Count - 40}개 생략");
}
lines.Add(string.Empty);
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>
/// 테이블/컬럼 이름 검색(--db-tables &lt;패턴&gt;) — 이름을 모르는 마스터 테이블을 찾을 때.
/// 레거시 스키마에 코드 마스터가 여러 관례로 흩어져 있어(E_*/M_*), 이름 추측으로 헛도는 것을 막는다.
/// 접근 가능한 모든 스키마를 훑되 소유자를 함께 표시한다(읽기 전용).
/// </summary>
public static int RunTableSearch(string pattern, string reportPath)
{
try
{
var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
var lines = new List<string>();
using (var command = connection.CreateCommand())
{
command.BindByName = true;
command.CommandText =
"SELECT OWNER, TABLE_NAME FROM ALL_TABLES WHERE TABLE_NAME LIKE UPPER(:p) " +
"ORDER BY OWNER, TABLE_NAME";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("p", pattern));
lines.Add($"[테이블명 LIKE {pattern}]");
using var reader = command.ExecuteReader();
while (reader.Read())
{
lines.Add($" {reader.GetString(0)}.{reader.GetString(1)}");
}
}
lines.Add(string.Empty);
using (var command = connection.CreateCommand())
{
command.BindByName = true;
command.CommandText =
"SELECT OWNER, TABLE_NAME, COLUMN_NAME FROM ALL_TAB_COLUMNS WHERE COLUMN_NAME LIKE UPPER(:p) " +
"ORDER BY OWNER, TABLE_NAME, COLUMN_NAME";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("p", pattern));
lines.Add($"[컬럼명 LIKE {pattern}]");
using var reader = command.ExecuteReader();
var count = 0;
while (reader.Read() && count++ < 200)
{
lines.Add($" {reader.GetString(0)}.{reader.GetString(1)}.{reader.GetString(2)}");
}
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용)</summary>
public static int RunColumns(string tableName, string reportPath)
{
try
{
var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT COLUMN_NAME, DATA_TYPE, NVL(DATA_LENGTH,0), NULLABLE " +
"FROM USER_TAB_COLUMNS WHERE TABLE_NAME = UPPER(:t) ORDER BY COLUMN_ID";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("t", tableName));
var lines = new List<string>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
lines.Add($"{reader.GetString(0),-16} {reader.GetString(1),-10} {reader.GetDecimal(2),5} " +
$"{(reader.GetString(3) == "N" ? "NOT NULL" : "")}");
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>서식 마스터 행 주요 플래그 조회(--db-row) — 신규 등록 기본값 참조용(읽기 전용)</summary>
public static int RunRow(string shtCod, string reportPath)
{
try
{
var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT NVL(ShtTyp,'-'), NVL(ShtUseYon,'-'), NVL(ShtUsrDesYon,'-'), NVL(ShtUseSdg,'-'), NVL(ShtUseSct,'-'), " +
"NVL(ShtUseEmr,'-'), NVL(ShtEleSig,'-'), NVL(ShtEdtAth,'-'), NVL(ShtRedAth,'-'), NVL(ShtWrtTyp,'-'), " +
"NVL(ShtEdtTyp,'-'), NVL(ShtViwTyp,'-'), NVL(ShtClsCod,'-'), NVL(ShtCneYon,'-'), NVL(ShtTraSto,'-') " +
"FROM E_ShtMst WHERE ShtCod = :s";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("s", shtCod));
using var reader = command.ExecuteReader();
if (!reader.Read())
{
File.WriteAllText(reportPath, "행 없음");
return 1;
}
var names = new[] { "ShtTyp", "ShtUseYon", "ShtUsrDesYon", "ShtUseSdg", "ShtUseSct", "ShtUseEmr",
"ShtEleSig", "ShtEdtAth", "ShtRedAth", "ShtWrtTyp", "ShtEdtTyp", "ShtViwTyp", "ShtClsCod", "ShtCneYon", "ShtTraSto" };
var lines = names.Select((n, i) => $"{n}={reader.GetString(i)}");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>
/// 서식 마스터 등록 관련 컬럼 값 분포(--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>
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
}