diff --git a/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs b/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs index 61c02c9..cbf0f56 100644 --- a/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs +++ b/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs @@ -214,6 +214,17 @@ public sealed class OracleLegacyFormStore } /// 특정 버전(SdgKey) 디자인 로드 — 이력 열람/복원용. 없으면 null + /// 특정 버전의 원문 XML — 이력 행이 옛 내용을 담았는지 확인하는 데 쓴다 + public string? LoadDesignRawByKey(string shtCod, decimal sdgKey) + { + using var connection = Open(); + using var command = connection.CreateCommand(); + command.BindByName = true; + command.CommandText = "SELECT SdgDesign FROM E_SdgMst WHERE SdgKey = :k"; + command.Parameters.Add(new OracleParameter("k", sdgKey)); + return command.ExecuteScalar() as string; + } + public FormDocument? LoadDesignByKey(string shtCod, decimal sdgKey) { using var connection = Open(); diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs index 1bbb001..3983cba 100644 --- a/src/SheetMe.Designer/App.xaml.cs +++ b/src/SheetMe.Designer/App.xaml.cs @@ -158,6 +158,11 @@ public partial class App : Application return Diagnostics.DbSmoke.RunReadSmoke(args[1], max); } + if (args.Length >= 3 && args[0] == "--db-modify-smoke") + { + return Diagnostics.DbSmoke.RunModifySmoke(args[1], args[2]); + } + if (args.Length >= 3 && args[0] == "--db-save-smoke") { return Diagnostics.DbSmoke.RunSaveSmoke(args[1], args[2]); diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index b757af4..489ff5c 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -1798,6 +1798,202 @@ public static class DbSmoke private static string Trim30(string value) => value.Length > 30 ? value[..30] + "…" : value; /// 무변경 재저장 스모크 — 저장 경로 전체(트랜잭션/버저닝/E_SctMst) 검증 + /// 리포트를 파일과 stderr 에 남기고 종료코드를 돌려준다 + private static int Finish(List lines, string reportPath, int code) + { + var text = string.Join(Environment.NewLine, lines); + File.WriteAllText(reportPath, text); + Console.Error.WriteLine(text); + return code; + } + + /// + /// 제자리 정정(TK_MODIFY) 왕복 검증 — --db-modify-smoke <서식코드> <리포트>. + /// + /// 왜 필요한가. ModifyDesign 은 운영 테이블에 쓰는데 한 번도 실행해 본 적이 없다. + /// 저장 경로에는 이런 검증이 있고(RunSaveSmoke) 수정 경로에만 없었다 — + /// "코드는 맞아 보이는데" 상태이고, 이 저장소에서 그건 여러 번 틀렸다. + /// + /// 무엇을 단정하는가. 수정은 저장과 이력 방향이 반대다. + /// ① 활성 SdgKey 가 바뀌지 않는다(EMR 이 참조하는 키가 유지되는 것이 이 동작의 존재 이유다) + /// ② 활성 행의 디자인이 새 XML 로 바뀐다 + /// ③ 옛 XML 을 담은 이력 행이 하나 생긴다(SdgDelYon='Y') + /// ④ 활성 행의 감사 컬럼이 지금 사용자·시각이다 — 레거시는 이걸 이력 행에 썼다(버그, 미복제) + /// ⑤ E_SctMst 행 수가 변하지 않는다(수정은 건드리지 않는다) + /// + /// DB 를 원상태로 되돌린다. 활성 행의 XML·감사값을 원문으로 UPDATE 하고, + /// 이 진단이 만든 이력 행을 SdgKey 로 지운다. 지우는 것은 방금 만든 그 행 하나뿐이고 + /// SdgDelYon='Y' 조건을 함께 걸어 활성 행은 어떤 경우에도 지워지지 않게 한다. + /// + public static int RunModifySmoke(string shtCod, string reportPath) + { + var lines = new List(); + 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 이력 행이 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); + } + } + + /// 글자를 담은 첫 컨트롤 — 갱신을 증명할 대상 + 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); + } + + /// 원복 전용 — 활성 행을 원문 XML·원 감사값으로 되돌린다 + 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(); + } + + /// + /// 이 진단이 만든 이력 행 하나를 지운다. + /// SdgDelYon='Y' 를 조건에 함께 건다 — SdgKey 를 잘못 짚어도 활성 행은 지워지지 않는다. + /// + 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();