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();