From 20e982faae045ededbbddf683b206cb96118ee6d Mon Sep 17 00:00:00 2001 From: Msystech Date: Tue, 11 Aug 2026 17:25:29 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=9C=EC=9E=90=EB=A6=AC=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EC=8B=9C=20E=5FSctMst.SctObjTxt=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShtCneYon='Y' 서식은 기존 이름 행을 continue 로 건너뛰기만 해서, 라벨 문구를 고쳐도 항목사전(SctObjTxt)이 옛 문구로 남았다. 이 값은 외부인터페이스·환자전달사항· 알람이 리터럴로 매칭하는 조회 키라 조용한 데이터 불일치가 된다. 레거시 bzSaveSheetDesignNControlInfo.vb:378 과 동일하게 SctObjTxt 만 덮어쓴다: - SctObjTyp/SctParObj/SctObjSeq/SctObjID 는 갱신하지 않음(SctKey 보존 의미론) - 삭제된 컨트롤의 잔존 행도 지우지 않음(레거시 동일) - SctKey 기준 UPDATE — 동명 중복 오염 데이터에서 다중 행 덮어쓰기 방지 - Spread AsTemplate 항목 행은 ObjName 이 항목 텍스트라 컨트롤 이름과 분리해 매칭. 레거시의 매 저장 중복 INSERT 버그는 복제하지 않는다(런타임이 개수를 세지 않음). --db-save-smoke 에 문구 변경 검증 추가 — 무변경 재저장으로는 이 경로를 증명할 수 없으므로 실제로 Text 를 바꿔 SctObjTxt 반영·SctKey 보존을 확인하고 원복한다. 검증: P163(ShtCneYon=Y) 2회 연속 통과(행 6/6 안정, SctKey 보존, 원복 확인), S999(버저닝) diff 0 + 이전 버전 SdgDelYon='Y', 전수 왕복 1,271건 diff 0/예외 0. Co-Authored-By: Claude Fable 5 --- .../Stores/OracleLegacyFormStore.cs | 59 ++++++++-- src/SheetMe.Designer/Diagnostics/DbSmoke.cs | 110 ++++++++++++++++++ 2 files changed, 159 insertions(+), 10 deletions(-) diff --git a/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs b/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs index d3b1fc8..c95adb7 100644 --- a/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs +++ b/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs @@ -13,13 +13,16 @@ public sealed record DesignVersionInfo(decimal SdgKey, string UpdDtm, string Upd /// /// 레거시 테이블(E_ShtMst/E_SdgMst/E_SctMst) 기반 서식 저장소 — 레거시 디자이너와 동일 저장 의미론. /// 저장 흐름(bzSaveSheetDesignNControlInfo 이식, 단일 트랜잭션): -/// ShtCneYon='Y'+활성행 존재 → 제자리 갱신(E_SctMst 는 신규 이름만 추가), +/// ShtCneYon='Y'+활성행 존재 → 제자리 갱신(E_SctMst 는 신규 이름만 추가 + 기존 행 SctObjTxt 갱신), /// 그 외 → 기존 활성행 SdgDelYon='Y' 후 신규 행 INSERT + E_SctMst 전체 재생성. /// 채번: 전용 시퀀스 NEXTVAL 우선, 부재 시 MAX+1 폴백(트랜잭션 내 — [200]SheetMe 검증 방식). /// public sealed class OracleLegacyFormStore { #region Member Fields + /// Spread AsTemplate 항목 행의 SctObjTyp — 이 행은 ObjName 이 항목 텍스트라 컨트롤 이름과 구분해야 한다 + private const string SpreadItemType = "ItemOfSpreadAsTemplate"; + private readonly string connectionString; private readonly LegacyXmlSerializer serializer = new(); #endregion @@ -324,29 +327,64 @@ public sealed class OracleLegacyFormStore { var rows = SctMstXmlWalker.Walk(mergedXml); - HashSet? existingNames = null; + // 제자리 갱신일 때만 기존 행을 읽는다. 컨트롤 행은 SctKey 로 갱신해야 하므로 키까지 가져오고, + // Spread AsTemplate 항목 행은 ObjName 이 항목 텍스트라 컨트롤 이름과 섞이면 안 되므로 따로 담는다. + Dictionary? existingControls = null; + HashSet? existingSpreadItems = null; if (updateInPlace) { - existingNames = new HashSet(StringComparer.Ordinal); + existingControls = new Dictionary(StringComparer.Ordinal); + existingSpreadItems = new HashSet(StringComparer.Ordinal); using var query = connection.CreateCommand(); query.Transaction = transaction; query.BindByName = true; - query.CommandText = "SELECT SctObjNam FROM E_SctMst WHERE SctSdgKey = :k"; + query.CommandText = + "SELECT SctKey, SctObjNam, NVL(SctObjTyp,' ') FROM E_SctMst WHERE SctSdgKey = :k ORDER BY SctKey"; query.Parameters.Add(new OracleParameter("k", sdgKey)); using var reader = query.ExecuteReader(); while (reader.Read()) { - existingNames.Add(reader.GetString(0)); + var key = reader.GetDecimal(0); + var name = reader.GetString(1); + var type = reader.GetString(2).Trim(); + if (string.Equals(type, SpreadItemType, StringComparison.OrdinalIgnoreCase)) + { + existingSpreadItems.Add(name); + } + else if (!existingControls.ContainsKey(name)) + { + // 동명 행이 이미 중복된 오염 데이터에서는 레거시 Select(...)(0) 과 동일하게 최초 1건만 채택 + existingControls[name] = key; + } } } foreach (var row in rows) { - // 제자리 갱신: 기존 이름은 유지(레거시 — SctKey 보존), 신규 이름만 추가 - if (existingNames is not null && existingNames.Contains(row.ObjName)) + if (updateInPlace) { - continue; + if (string.Equals(row.ObjType, SpreadItemType, StringComparison.OrdinalIgnoreCase)) + { + // 레거시(bzSaveSheetDesignNControlInfo.vb:355-370)는 존재 검사 없이 매 저장마다 항목 행을 + // 중복 INSERT 한다. 런타임은 개수를 세지 않으므로 그 버그는 복제하지 않고 행 수를 안정시킨다. + if (existingSpreadItems!.Contains(row.ObjName)) + { + continue; + } + } + else if (existingControls!.TryGetValue(row.ObjName, out var existingKey)) + { + // 레거시 bzSaveSheetDesignNControlInfo.vb:378 — 기존 행은 SctObjTxt 만 덮어쓴다. + // SctObjTyp/SctParObj/SctObjSeq/SctObjID 는 의도적으로 갱신하지 않는다(SctKey 보존 의미론). + // 삭제된 컨트롤의 잔존 행도 레거시와 동일하게 지우지 않는다. + NonQuery(connection, transaction, + "UPDATE E_SctMst SET SctObjTxt = :txt WHERE SctKey = :k", + new OracleParameter("txt", row.ObjText.Length == 0 ? (object)DBNull.Value : row.ObjText), + new OracleParameter("k", existingKey)); + continue; + } } + var sctKey = NextVal(connection, transaction, "E_SCTMST_SCTKEY", "SELECT NVL(MAX(SctKey),0)+1 FROM E_SctMst"); NonQuery(connection, transaction, @@ -413,14 +451,15 @@ public sealed class OracleLegacyFormStore return command.ExecuteScalar(); } - private static void NonQuery(OracleConnection connection, OracleTransaction transaction, string sql, params OracleParameter[] parameters) + /// DML 실행 — 영향 행 수 반환(호출부가 무시해도 무방) + private static int NonQuery(OracleConnection connection, OracleTransaction transaction, string sql, params OracleParameter[] parameters) { using var command = connection.CreateCommand(); command.Transaction = transaction; command.BindByName = true; command.CommandText = sql; command.Parameters.AddRange(parameters); - command.ExecuteNonQuery(); + return command.ExecuteNonQuery(); } #endregion } diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index cdbb6d1..47c8c7c 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -1,4 +1,5 @@ using System.IO; +using SheetMe.Core.Models; using SheetMe.Core.Serialization; using SheetMe.Data.Stores; using SheetMe.Designer.Services; @@ -337,6 +338,107 @@ public static class DbSmoke } } + /// + /// 제자리 갱신(ShtCneYon='Y') 서식에서 컨트롤 문구를 실제로 바꿔 E_SctMst.SctObjTxt 반영을 확인하고 원복한다. + /// 레거시 bzSaveSheetDesignNControlInfo.vb:378 은 기존 행의 SctObjTxt 만 덮어쓰고 SctKey 는 보존하므로 + /// 둘 다 검사한다. 확인 후 원문으로 되돌려 DB 상태를 원위치시킨다. + /// + private static bool VerifySctObjTxtUpdate(OracleLegacyFormStore store, LegacyXmlSerializer serializer, + string connectionString, string shtCod, decimal sdgKey, List 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, Environment.UserName); + 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, Environment.UserName); + var (_, txtRestored) = ReadSctRow(connectionString, sdgKey, target.Id); + lines.Add($"[SctObjTxt] 원복 → '{Trim30(txtRestored)}' " + + $"{(string.Equals(txtRestored, originalText, StringComparison.Ordinal) ? "확인" : "불일치!")}"); + } + } + } + + /// E_SctMst 단일 행의 (SctKey, SctObjTxt) — 동명 중복 시 최초 1건 + 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); + } + + /// 문서의 모든 컨트롤(자식 포함) 평탄화 + private static IEnumerable 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 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; + /// 무변경 재저장 스모크 — 저장 경로 전체(트랜잭션/버저닝/E_SctMst) 검증 public static int RunSaveSmoke(string shtCod, string reportPath) { @@ -378,6 +480,14 @@ public static class DbSmoke } var ok = diffs.Count == 0 && sctCount == walkerCount; + + // 제자리 갱신 경로에서만 의미가 있는 검사 — 무변경 재저장으로는 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;