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;