레거시 대비 전수조사에서 나온 출시 차단 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>
1020 lines
47 KiB
C#
1020 lines
47 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 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>
|
||
/// 서식 마스터 등록 관련 컬럼 값 분포(--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
|
||
}
|